Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 156 additions & 4 deletions _extensions/quarto-ext/shinylive/shinylive.lua
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,153 @@ function throw_quarto_error(err_msg, ...)
assert(false, err_msg .. "\n")
end

-- ===== Shinylive subprocess result cache =====================================
-- Memoizes subprocess results to disk so they are shared across documents
-- within a full-project render. Without this, every document re-runs several
-- identical subprocesses (`extension info`, `extension base-htmldeps`,
-- `extension language-resources`, plus two per code block), and their
-- interpreter startup cost dominates large-site render times.
--
-- Results are only cached once the live version of the tool that produced
-- them is known (see the `keySuffix` parameter of `cachedPipe`): every cache
-- key embeds that version, so upgrading the shinylive package (or quarto)
-- naturally invalidates the cache and stale reuse is impossible.
--
-- The cache is strictly an optimization: on any cache failure, the real
-- subprocess is run.

local pipeCacheState = nil -- nil = uninitialized, false = disabled, table = ready

local function initPipeCache()
if pipeCacheState ~= nil then
return pipeCacheState
end
local ok, result = pcall(function()
local projDir = quarto.project.directory or os.getenv("QUARTO_PROJECT_DIR")
if projDir == nil then
return false
end
local dir = tostring(projDir) .. "/.quarto/shinylive-cache"
pandoc.system.make_directory(dir, true)
return { dir = dir }
end)
if ok and result then
pipeCacheState = result
else
pipeCacheState = false
end
return pipeCacheState
end

local function pipeCacheKey(command, args, input, keySuffix)
return pandoc.utils.sha1(
command .. "\1" .. table.concat(args, "\1") .. "\1" .. input .. "\1" .. keySuffix
)
end

local function pipeCacheRead(cache, key)
local f = io.open(cache.dir .. "/" .. key, "rb")
if f == nil then
return nil
end
local contents = f:read("*a")
f:close()
return contents
end

-- Some subprocess results embed absolute paths to files elsewhere on disk,
-- and running the subprocess is what creates those files: notably
-- `extension base-htmldeps` downloads the shinylive web assets to the user
-- cache dir as a side effect. Only serve a hit whose referenced files all
-- still exist; otherwise re-run the subprocess so it can re-create them.
-- (Matches POSIX absolute paths in "source"/"path" JSON fields; on Windows
-- nothing matches, so hits are served unvalidated — same as no check.)
local function pipeCacheHitUsable(hit)
for _, field in ipairs({ "source", "path" }) do
for path in hit:gmatch('"' .. field .. '":%s*"(/[^"]+)"') do
local f = io.open(path, "r")
if f == nil then
return false
end
f:close()
end
end
return true
end

local function pipeCacheWrite(cache, key, value)
-- Write to a temp file then rename: atomic on POSIX, so a concurrent
-- render (e.g. quarto preview) can never observe a partial entry.
local path = cache.dir .. "/" .. key
local tmpPath = path .. ".tmp" .. tostring(math.random(1000000000))
local f = io.open(tmpPath, "wb")
if f == nil then
return
end
f:write(value)
f:close()
os.rename(tmpPath, path)
end

local pipeMemo = {} -- in-process memo: repeat calls skip hashing and disk I/O

-- Drop-in replacement for `pandoc.pipe()` that memoizes successful results
-- to disk. `keySuffix` must encode the live version of the tool being run
-- (and anything else the result depends on besides command/args/input); pass
-- nil to skip caching entirely — used for the version-discovery calls
-- themselves, which therefore still run once per document and keep the cache
-- honest. Failed subprocess calls are never cached (pandoc.pipe raises, so
-- the error propagates to the caller exactly as before).
local function cachedPipe(command, args, input, keySuffix)
if keySuffix == nil then
return pandoc.pipe(command, args, input)
end
local cache = initPipeCache()
if cache == false then
return pandoc.pipe(command, args, input)
end

local keyOk, key = pcall(pipeCacheKey, command, args, input, keySuffix)
if not keyOk then
return pandoc.pipe(command, args, input)
end

if pipeMemo[key] ~= nil then
return pipeMemo[key]
end

local hitOk, hit = pcall(pipeCacheRead, cache, key)
if hitOk and hit ~= nil then
local usableOk, usable = pcall(pipeCacheHitUsable, hit)
if usableOk and usable then
pipeMemo[key] = hit
return hit
end
end

local res = pandoc.pipe(command, args, input)
pipeMemo[key] = res
pcall(pipeCacheWrite, cache, key, res)
return res
end
-- ===== End shinylive subprocess result cache =================================

-- Python specific method to call py-shinylive
-- @param args: list of string arguments to pass to py-shinylive
-- @param input: string to pipe into to py-shinylive
function callPythonShinylive(args, input)
-- Try calling `pandoc.pipe('shinylive', ...)` and if it fails, print a message
-- about installing shinylive python package.
-- Cache results once the live package version is known; the `--version`
-- discovery call itself runs before that and is never cached.
local keySuffix = nil
if pyShinyliveVersion ~= nil then
keySuffix = "py-shinylive-" .. pyShinyliveVersion
end
local res
local status, err = pcall(
function()
res = pandoc.pipe("shinylive", args, input)
res = cachedPipe("shinylive", args, input, keySuffix)
end
)

Expand All @@ -75,10 +212,16 @@ function callRShinylive(args, input)

-- Try calling `pandoc.pipe('Rscript', ...)` and if it fails, print a message
-- about installing shinylive R package.
-- Cache results once the live package version is known; the initial
-- `extension info` call that discovers it is never cached.
local keySuffix = nil
if versions.r ~= nil then
keySuffix = "r-shinylive-" .. versions.r.version
end
local res
local status, err = pcall(
function()
res = pandoc.pipe("Rscript", args, input)
res = cachedPipe("Rscript", args, input, keySuffix)
end
)

Expand Down Expand Up @@ -424,10 +567,19 @@ return {
ensureLanguageSetup(language)

-- Convert code block to JSON string in the same format as app.json.
local parsedCodeblockJson = pandoc.pipe(
-- The result depends on the codeblock-to-json script (which ships with
-- the language's shinylive package) and the quarto CLI that runs it, so
-- key the cache on both of their versions.
local codeblockKeySuffix = nil
if versions[language] ~= nil then
codeblockKeySuffix = language .. "-shinylive-" .. versions[language].version ..
"\1quarto-" .. tostring(quarto.version)
end
local parsedCodeblockJson = cachedPipe(
quarto_cli_path,
{ "run", codeblockScript, language },
el.text
el.text,
codeblockKeySuffix
)

-- This contains "files" and "quartoArgs" keys.
Expand Down
Loading