diff --git a/_extensions/quarto-ext/shinylive/README.md b/_extensions/quarto-ext/shinylive/README.md new file mode 100644 index 0000000..55bfed2 --- /dev/null +++ b/_extensions/quarto-ext/shinylive/README.md @@ -0,0 +1,126 @@ +# Shinylive package methods + +## Methods + +### R + +Interaction: + +``` +Rscript -e 'shinylive:::quarto_ext()' [methods] [args] +``` + +### Python + +Interaction: + +``` +shinylive [methods] [args] +``` + +## CLI Methods + +* `extension info` + * Package, version, asset version, and script paths information +* `extension base-htmldeps` + * Quarto html dependencies for the base shinylive integration +* `extension language-resources` + * Language specific resource files for the quarto html dependency named `shinylive` +* `extension app-resources` + * App specific resource files for the quarto html dependency named `shinylive` + +### CLI Interface +* `extension info` + * Prints information about the extension including: + * `version`: The version of the R package + * `assets_version`: The version of the web assets + * `scripts`: A list of paths scripts that are used by the extension, + mainly `codeblock-to-json` + * Example + ``` + { + "version": "0.1.0", + "assets_version": "0.2.0", + "scripts": { + "codeblock-to-json": "//shinylive-0.2.0/scripts/codeblock-to-json.js" + } + } + ``` +* `extension base-htmldeps` + * Prints the language agnostic quarto html dependencies as a JSON array. + * The first html dependency is the `shinylive` service workers. + * The second html dependency is the `shinylive` base dependencies. This + dependency will contain the core `shinylive` asset scripts (JS files + automatically sourced), stylesheets (CSS files that are automatically + included), and resources (additional files that the JS and CSS files can + source). + * Example + ``` + [ + { + "name": "shinylive-serviceworker", + "version": "0.2.0", + "meta": { "shinylive:serviceworker_dir": "." }, + "serviceworkers": [ + { + "source": "//shinylive-0.2.0/shinylive-sw.js", + "destination": "/shinylive-sw.js" + } + ] + }, + { + "name": "shinylive", + "version": "0.2.0", + "scripts": [{ + "name": "shinylive/load-shinylive-sw.js", + "path": "//shinylive-0.2.0/shinylive/load-shinylive-sw.js", + "attribs": { "type": "module" } + }], + "stylesheets": [{ + "name": "shinylive/shinylive.css", + "path": "//shinylive-0.2.0/shinylive/shinylive.css" + }], + "resources": [ + { + "name": "shinylive/shinylive.js", + "path": "//shinylive-0.2.0/shinylive/shinylive.js" + }, + ... # [ truncated ] + ] + } + ] + ``` +* `extension language-resources` + * Prints the language-specific resource files as JSON that should be added to the quarto html dependency. + * For r-shinylive, this includes the webr resource files + * For py-shinylive, this includes the pyodide and pyright resource files. + * Example + ``` + [ + { + "name": "shinylive/webr/esbuild.d.ts", + "path": "//shinylive-0.2.0/shinylive/webr/esbuild.d.ts" + }, + { + "name": "shinylive/webr/libRblas.so", + "path": "//shinylive-0.2.0/shinylive/webr/libRblas.so" + }, + ... # [ truncated ] + ] +* `extension app-resources` + * Prints app-specific resource files as JSON that should be added to the `"shinylive"` quarto html dependency. + * Currently, r-shinylive does not return any resource files. + * Example + ``` + [ + { + "name": "shinylive/pyodide/anyio-3.7.0-py3-none-any.whl", + "path": "//shinylive-0.2.0/shinylive/pyodide/anyio-3.7.0-py3-none-any.whl" + }, + { + "name": "shinylive/pyodide/appdirs-1.4.4-py2.py3-none-any.whl", + "path": "//shinylive-0.2.0/shinylive/pyodide/appdirs-1.4.4-py2.py3-none-any.whl" + }, + ... # [ truncated ] + ] + ``` diff --git a/_extensions/quarto-ext/shinylive/_extension.yml b/_extensions/quarto-ext/shinylive/_extension.yml index 453c84d..01b4d68 100644 --- a/_extensions/quarto-ext/shinylive/_extension.yml +++ b/_extensions/quarto-ext/shinylive/_extension.yml @@ -1,7 +1,7 @@ name: shinylive title: Embedded Shinylive applications author: Winston Chang -version: 0.0.4 +version: 0.1.0 quarto-required: ">=1.2.198" contributes: filters: diff --git a/_extensions/quarto-ext/shinylive/shinylive.lua b/_extensions/quarto-ext/shinylive/shinylive.lua index e54493b..e2828db 100644 --- a/_extensions/quarto-ext/shinylive/shinylive.lua +++ b/_extensions/quarto-ext/shinylive/shinylive.lua @@ -1,9 +1,51 @@ -local hasDoneShinyliveSetup = false +-- Notes: +-- * 2023/10/04 - Barret: +-- Always use `callShinyLive()` to call a shinylive extension. +-- `callPythonShinyLive()` and `callRShinyLive()` should not be used directly. +-- Instead, always use `callShinyLive()`. +-- * 2023/10/04 - Barret: +-- I could not get `error(msg)` to quit the current function execution and +-- bubble up the stack and stop. Instead, I am using `assert(false, msg)` to +-- achieve the desired behavior. Multi-line error messages should start with a +-- `\n` to keep the message in the same readable area. + + +-- `table` to organize flags to have code only run once. +local hasDoneSetup = { base = false, r = false, python = false, python_version = false } +-- `table` to store `{ version, assets_version }` for each language's extension. +-- If both `r` and `python` are used in the same document, then the +-- `assets_version` for each language must be the same. +local versions = { r = nil, python = nil } +-- Global variable for the codeblock-to-json.js script file location local codeblockScript = nil +-- Global hash table to store app specific dependencies to avoid calling +-- `quarto.doc.attach_to_dependency()` multiple times for the same dependency. +local appSpecificDeps = {} + +-- Display error message and throw error w/ short message +-- @param msg: string Error message to be displayed +-- @param short_msg: string Error message to be thrown +function throw_quarto_error(err_msg, ...) + n = select("#", ...) + if n > 0 then + -- Display any meta information about the error + -- Add blank lines after msg for line separation for better readability + quarto.log.error(...) + else + quarto.log.error(err_msg .. "\n\n") + end + -- Add blank lines after short_msg for line separation for better readability + -- Use assert(false, msg) to quit the current function execution and + -- bubble up the stack and stop. Barret: I could not get this to work with `error(msg)`. + assert(false, err_msg .. "\n") +end --- Try calling `pandoc.pipe('shinylive', ...)` and if it fails, print a message --- about installing shinylive python package. +-- 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. local res local status, err = pcall( function() @@ -12,19 +54,27 @@ function callPythonShinylive(args, input) ) if not status then - print(err) - error("Error running 'shinylive' command. Perhaps you need to install the 'shinylive' Python package?") + throw_quarto_error( + "Error running 'shinylive' command. Perhaps you need to install / update the 'shinylive' Python package?", + "Error running 'shinylive' command. Perhaps you need to install / update the 'shinylive' Python package?\n", + "Error:\n", + err + ) end return res end --- Try calling `pandoc.pipe('Rscript', ...)` and if it fails, print a message --- about installing shinylive R package. +-- R specific method to call {r-shinylive} +-- @param args: list of string arguments to pass to r-shinylive +-- @param input: string to pipe into to r-shinylive function callRShinylive(args, input) args = { "-e", "shinylive:::quarto_ext()", table.unpack(args) } + + -- Try calling `pandoc.pipe('Rscript', ...)` and if it fails, print a message + -- about installing shinylive R package. local res local status, err = pcall( function() @@ -33,42 +83,219 @@ function callRShinylive(args, input) ) if not status then - print(err) - error( - "Error running 'Rscript' command. Perhaps you need to install the 'shinylive' R package?") + throw_quarto_error( + "Error running 'Rscript' command. Perhaps you need to install / update the 'shinylive' R package?", + "Error running 'Rscript' command. Perhaps you need to install / update the 'shinylive' R package?\n", + "Error:\n", + err + ) end return res end -function callShinylive(language, args, input) - -- print("Calling " .. language .. " shinylive with args: " .. table.concat(args, " ")) +-- Returns decoded object +-- @param language: "python" or "r" +-- @param args, input: see `callPythonShinylive` and `callRShinylive` +function callShinylive(language, args, input, parseJson) + if input == nil then + input = "" + end + if parseJson == nil then + parseJson = true + end + + local res + -- print("Calling " .. language .. " shinylive with args: ", args) if language == "python" then - return callPythonShinylive(args, input) + res = callPythonShinylive(args, input) elseif language == "r" then - return callRShinylive(args, input) + res = callRShinylive(args, input) + else + throw_quarto_error("internal - Unknown language: " .. language) + end + + if not parseJson then + return res + end + + -- Remove any unwanted output before the first curly brace or square bracket. + -- print("res: " .. string.sub(res, 1, math.min(string.len(res), 100)) .. "...") + local curly_start = string.find(res, "{", 0, true) + local brace_start = string.find(res, "[", 0, true) + local min_start + if curly_start == nil then + min_start = brace_start + elseif brace_start == nil then + min_start = curly_start else - error("Unknown language: " .. language) + min_start = math.min(curly_start, brace_start) + end + if min_start == nil then + local res_str = res + if string.len(res) > 100 then + res_str = string.sub(res, 1, 100) .. "... [truncated]" + end + throw_quarto_error( + "Could not find start curly brace or start brace in " .. + language .. " shinylive response. Is JSON being returned from the " .. language .. " `shinylive` package?", + "Could not find start curly brace or start brace in " .. language .. " shinylive response.\n", + "JSON string being parsed:\n", + res_str + ) end + if min_start > 1 then + res = string.sub(res, min_start) + end + + + -- Decode JSON object + local result + local status, err = pcall( + function() + result = quarto.json.decode(res) + end + ) + if not status then + throw_quarto_error( + "Error decoding JSON response from `shinylive` " .. language .. " package.", + "Error decoding JSON response from `shinylive` " .. language .. " package.\n", + "JSON string being parsed:\n", + res, + "Error:\n", + err + ) + end + return result end --- Do one-time setup when a Shinylive codeblock is encountered. -function ensureShinyliveSetup(language) - if hasDoneShinyliveSetup then +function parseVersion(versionTxt) + local versionParts = {} + for part in string.gmatch(versionTxt, "%d+") do + table.insert(versionParts, tonumber(part)) + end + local ret = { + major = nil, + minor = nil, + patch = nil, + extra = nil, + length = #versionParts, + str = versionTxt + } + + if ret.length >= 1 then + ret.major = versionParts[1] + if ret.length >= 2 then + ret.minor = versionParts[2] + if ret.length >= 3 then + ret.patch = versionParts[3] + if ret.length >= 4 then + ret.extra = versionParts[4] + end + end + end + end + + return ret +end + +-- If verA > verB, return 1 +-- If verA == verB, return 0 +-- If verA < verB, return -1 +function compareVersions(verA, verB) + if verA.major == nil or verB.major == nil then + throw_quarto_error("Trying to compare an invalid version: " .. verA.str .. " or " .. verB.str) + end + + for index, key in ipairs({ "major", "minor", "patch", "extra" }) do + local partDiff = compareVersionPart(verA[key], verB[key]) + if partDiff ~= 0 then + return partDiff + end + end + + -- Equal! + return 0 +end + +function compareVersionPart(aPart, bPart) + if aPart == nil and bPart == nil then + return 0 + end + if aPart == nil then + return -1 + end + if bPart == nil then + return 1 + end + if aPart > bPart then + return 1 + elseif aPart < bPart then + return -1 + end + + -- Equal! + return 0 +end + +function ensurePyshinyliveVersion(language) + -- Quit early if not python + if language ~= "python" then + return + end + -- Quit early if already completed check + if hasDoneSetup.python_version then return end - hasDoneShinyliveSetup = true + hasDoneSetup.python_version = true - -- Find the path to codeblock-to-json.ts and save it for later use. - codeblockScript = callShinylive(language, { "codeblock-to-json-path" }, "") + -- Verify that min python shinylive version is met + pyShinyliveVersion = callShinylive(language, { "--version" }, "", false) -- Remove trailing whitespace - codeblockScript = codeblockScript:gsub("%s+$", "") + pyShinyliveVersion = pyShinyliveVersion:gsub("%s+$", "") + -- Parse version into table + parsedVersion = parseVersion(pyShinyliveVersion) + + -- Verify that the version is at least 0.1.0 + if + (parsedVersion.length < 3) or + -- Major and minor values are 0. Ex: 0.0.18 + (parsedVersion.major == 0 and parsedVersion.minor == 0) + then + assert(false, + "\nThe shinylive Python package must be at least version v0.1.0 to be used in a Quarto document." .. + "\n\nInstalled Python Shinylive package version: " .. pyShinyliveVersion .. + "\n\nPlease upgrade the Python Shinylive package by running:" .. + "\n\tpip install --upgrade shinylive" .. + "\n\n(If you are using a virtual environment, please activate it before running the command above.)" + ) + end +end + +-- Do one-time setup for language agnostic html dependencies. +-- This should only be called once per document +-- @param language: "python" or "r" +function ensureBaseSetup(language) + -- Quit early if already done + if hasDoneSetup.base then + return + end + hasDoneSetup.base = true + + -- Find the path to codeblock-to-json.ts and save it for later use. + local infoObj = callShinylive(language, { "extension", "info" }) + -- Store the path to codeblock-to-json.ts for later use + codeblockScript = infoObj.scripts['codeblock-to-json'] + -- Store the version info for later use + versions[language] = { version = infoObj.version, assets_version = infoObj.assets_version } + -- Add language-agnostic dependencies local baseDeps = getShinyliveBaseDeps(language) for idx, dep in ipairs(baseDeps) do quarto.doc.add_html_dependency(dep) end + -- Add ext css dependency quarto.doc.add_html_dependency( { name = "shinylive-quarto-css", @@ -77,19 +304,92 @@ function ensureShinyliveSetup(language) ) end +-- Do one-time setup for language specific html dependencies. +-- This should only be called once per document +-- @param language: "python" or "r" +function ensureLanguageSetup(language) + -- Min version check must be done first + ensurePyshinyliveVersion(language) + + -- Make sure the base setup is done before the langage setup + ensureBaseSetup(language) + + if hasDoneSetup[language] then + return + end + hasDoneSetup[language] = true + + -- Only get the asset version value if it hasn't been retrieved yet. + if versions[language] == nil then + local infoObj = callShinylive(language, { "extension", "info" }) + versions[language] = { version = infoObj.version, assets_version = infoObj.assets_version } + end + -- Verify that the r-shinylive and py-shinylive supported assets versions match + if + (versions.r and versions.python) and + ---@diagnostic disable-next-line: undefined-field + versions.r.assets_version ~= versions.python.assets_version + then + local parsedRAssetsVersion = parseVersion(versions.r.assets_version) + local parsedPythonAssetsVersion = parseVersion(versions.python.assets_version) + + local verDiff = compareVersions(parsedRAssetsVersion, parsedPythonAssetsVersion) + local verDiffStr = "" + if verDiff == 1 then + -- R shinylive supports higher version of assets. Upgrade python shinylive + verDiffStr = + "The currently installed python shinylive package supports a lower assets version, " .. + "therefore we recommend updating your python shinylive package to the latest version." + elseif verDiff == -1 then + -- Python shinylive supports higher version of assets. Upgrade R shinylive + verDiffStr = + "The currently installed R shinylive package supports a lower assets version, " .. + "therefore we recommend updating your R shinylive package to the latest version." + end + + throw_quarto_error( + "The shinylive R and Python packages must support the same Shinylive Assets version to be used in the same Quarto document.", + "The shinylive R and Python packages must support the same Shinylive Assets version to be used in the same Quarto document.\n", + "\n", + "Python shinylive package version: ", + ---@diagnostic disable-next-line: undefined-field + versions.python.version .. " ; Supported assets version: " .. versions.python.assets_version .. "\n", + "R shinylive package version: " .. + ---@diagnostic disable-next-line: undefined-field + versions.r.version .. " ; Supported assets version: " .. versions.r.assets_version .. "\n", + "\n", + verDiffStr .. "\n", + "\n", + "To update your R Shinylive package, run:\n", + "\tR -e \"install.packages('shinylive')\"\n", + "\n", + "To update your Python Shinylive package, run:\n", + "\tpip install --upgrade shinylive\n", + "(If you are using a virtual environment, please activate it before running the command above.)\n", + "\n" + ) + end + + -- Add language-specific dependencies + local langResources = callShinylive(language, { "extension", "language-resources" }) + for idx, resourceDep in ipairs(langResources) do + -- No need to check for uniqueness. + -- Each resource is only be added once and should already be unique. + quarto.doc.attach_to_dependency("shinylive", resourceDep) + end +end + function getShinyliveBaseDeps(language) -- Relative path from the current page to the root of the site. This is needed -- to find out where shinylive-sw.js is, relative to the current page. if quarto.project.offset == nil then - error("The shinylive extension must be used in a Quarto project directory (with a _quarto.yml file).") + throw_quarto_error("The `shinylive` extension must be used in a Quarto project directory (with a _quarto.yml file).") end - local depJson = callShinylive( + local deps = callShinylive( language, - { "base-deps", "--sw-dir", quarto.project.offset }, + { "extension", "base-htmldeps", "--sw-dir", quarto.project.offset }, "" ) - - local deps = quarto.json.decode(depJson) return deps end @@ -101,6 +401,7 @@ return { return end + local language if el.attr.classes:includes("{shinylive-r}") then language = "r" elseif el.attr.classes:includes("{shinylive-python}") then @@ -109,12 +410,13 @@ return { -- Not a shinylive codeblock, return return end - ensureShinyliveSetup(language) + -- Setup language and language-agnostic dependencies + ensureLanguageSetup(language) -- Convert code block to JSON string in the same format as app.json. local parsedCodeblockJson = pandoc.pipe( "quarto", - { "run", codeblockScript }, + { "run", codeblockScript, language }, el.text ) @@ -122,16 +424,19 @@ return { local parsedCodeblock = quarto.json.decode(parsedCodeblockJson) -- Find Python package dependencies for the current app. - local appDepsJson = callShinylive( + local appDeps = callShinylive( language, - { "package-deps" }, + { "extension", "app-resources" }, + -- Send as piped input to the shinylive command quarto.json.encode(parsedCodeblock["files"]) ) - local appDeps = quarto.json.decode(appDepsJson) - + -- Add app specific dependencies for idx, dep in ipairs(appDeps) do - quarto.doc.attach_to_dependency("shinylive", dep) + if not appSpecificDeps[dep.name] then + appSpecificDeps[dep.name] = true + quarto.doc.attach_to_dependency("shinylive", dep) + end end if el.attr.classes:includes("{shinylive-python}") then