diff --git a/README.md b/README.md index ab019698..341c3502 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ filters: - webr ``` -Then, place the code for `webr` in a code block marked with `{webr}` +Then, place the R code for `webR` in a code block marked with `{webr-r}` ````markdown --- @@ -52,33 +52,75 @@ summary(fit) ```` -When `quarto render` or `quarto preview` is called, the filter will execute under the `jupyter` compute engine if `engine: knitr` is not specified. +When `quarto render` or `quarto preview` is called, the filter will execute under `engine: knitr`. During the execution, the filter adds two files to the working directory: `webr-worker.js` and `webr-serviceworker.js`. These files allow for the -webR session to be started and must be present with the rendered output. +`webR` session to be started and must be present with the rendered output. + +**Note:** If `engine: knitr` is not specified, then the `jupyter` compute engine will be used by default. ### Packages -By default, the `quarto-webr` extension avoids loading or requesting additional packages. Additional packages can be added by including: +By default, the `quarto-webr` extension avoids loading or requesting additional packages. Additional packages can be added +when the document is first opened or on per-code cell basis. You can view what packages are available by either executing +the following R code (either with WebR or just R): ```r -webr::install("package") +available.packages(repos="https://repo.r-wasm.org/", type="source") ``` -For example, to install `ggplot2`, you would need to use: +Or, by navigating to the WebR repository: -```r -webr::install("ggplot2") + + + +#### Install on document open + +Add to the document header YAML the `packages` key under `webr` with each package listed using an array, e.g. + +```yaml +--- +webr: + packages: ['ggplot2', 'dplyr'] +--- ``` -You can view what packages are available by either executing the following R code (either with WebR or just R): +#### Install on an as needed basis + +Packages may also be installed inside of a code cell through the built-in [`webr::install()` function](https://docs.r-wasm.org/webr/latest/packages.html#example-installing-the-matrix-package). For example, to install `ggplot2`, you would need to use: ```r -available.packages(repos="https://repo.r-wasm.org/", type="source") +webr::install("ggplot2") ``` -Or, by navigating to the WebR repository: +### Customizing webR from the Quarto Extension - +The `quarto-webr` extension supports specifying the following `WebROptions` options: + +- `home-dir`: The WebAssembly user’s home directory and initial working directory ([`Documentation`](https://docs.r-wasm.org/webr/latest/api/js/interfaces/WebR.WebROptions.html#homedir)). Default: `'/home/web_user'`. +- `base-url`: The base URL used for downloading R WebAssembly binaries. ([`Documentation`](https://docs.r-wasm.org/webr/latest/api/js/interfaces/WebR.WebROptions.html#baseurl)). Default: `'https://webr.r-wasm.org/[version]/'`. +- `service-worker-url`: The base URL from where to load JavaScript worker scripts when loading webR with the ServiceWorker communication channel mode ([`Documentation`](https://docs.r-wasm.org/webr/latest/api/js/interfaces/WebR.WebROptions.html#serviceworkerurl)). Default: `''`. + +The extension also has native options for: + +- `show-startup-message`: Display in the document header the state of WebR initialization. Default: `true` +- `show-header-message`: Display in the document header whether COOP and COEP headers are in use for faster page loads. Default: `false` + +For these options to be active, they must be placed underneath the `webr` entry in the documentation header, e.g. + +```markdown +--- +title: WebR in Quarto HTML Documents +format: html +engine: knitr +webr: + show-startup-message: false + show-header-message: false + home-dir: '/home/r-user/' + packages: ['ggplot2', 'dplyr'] +filters: + - webr +--- +``` ## Known Hiccups @@ -96,6 +138,8 @@ If `webr-worker.js` or `webr-serviceworker.js` are not found when the document l └── webr-worker.js ``` +Still having trouble? Try specifying where the worker files are located using the `service-worker-url` option in the document's YAML header. + ### Directly accessing rendered HTML When using `quarto preview` or `quarto render`, the rendered HTML document is being shown by mimicking a server running under `https://localhost/`. Usually, everything works in this context assuming the above directory structure is followed. However, if you **directly** open the rendered HTML document, e.g. `demo-quarto-web.html`, inside of a Web Browser, then the required WebR components cannot be loaded for security reasons. You can read a bit more about the problem in this [StackOverflow answer](https://stackoverflow.com/questions/6811398/html5-web-workers-work-in-firefox-4-but-not-in-chrome-12-0-742-122/6823683#6823683). diff --git a/_extensions/webr/_extension.yml b/_extensions/webr/_extension.yml index 78972099..11784ec1 100644 --- a/_extensions/webr/_extension.yml +++ b/_extensions/webr/_extension.yml @@ -1,7 +1,7 @@ name: webr title: Embedded webr code cells author: James Joseph Balamuta -version: 0.0.4 +version: 0.1.0 quarto-required: ">=1.2.198" contributes: filters: diff --git a/_extensions/webr/template.qmd b/_extensions/webr/template.qmd index 49f01f65..a74441f9 100644 --- a/_extensions/webr/template.qmd +++ b/_extensions/webr/template.qmd @@ -2,6 +2,13 @@ title: "WebR-enabled code cell" format: html engine: knitr +#webr: +# show-startup-message: false # Display status of webR initialization +# show-header-message: false # Check to see if COOP&COEP headers are set for speed. +# packages: ['ggplot2', 'dplyr'] # Pre-install dependencies +# home-dir: "/home/rstudio" # Customize where the working directory is +# base-url: '' # Base URL used for downloading R WebAssembly binaries +# service-worker-url: '' # URL from where to load JavaScript worker scripts when loading webR with the ServiceWorker communication channel. filters: - webr --- @@ -10,15 +17,15 @@ filters: This is a webr-enabled code cell in a Quarto HTML document. -```{webr} +```{webr-r} 1 + 1 ``` -```{webr} +```{webr-r} fit = lm(mpg ~ am, data = mtcars) summary(fit) ``` -```{webr} +```{webr-r} plot(pressure) ``` diff --git a/_extensions/webr/webr-init.html b/_extensions/webr/webr-init.html index 909a99f7..c439fef1 100644 --- a/_extensions/webr/webr-init.html +++ b/_extensions/webr/webr-init.html @@ -13,17 +13,97 @@ diff --git a/_extensions/webr/webr-serviceworker.js b/_extensions/webr/webr-serviceworker.js index 153bf04b..7d4b6d20 100644 --- a/_extensions/webr/webr-serviceworker.js +++ b/_extensions/webr/webr-serviceworker.js @@ -1 +1 @@ -importScripts('https://webr.r-wasm.org/v0.1.0/webr-serviceworker.js'); +importScripts('https://webr.r-wasm.org/v0.1.1/webr-serviceworker.js'); diff --git a/_extensions/webr/webr-worker.js b/_extensions/webr/webr-worker.js index 1b9d01bb..6be544de 100644 --- a/_extensions/webr/webr-worker.js +++ b/_extensions/webr/webr-worker.js @@ -1 +1 @@ -importScripts('https://webr.r-wasm.org/v0.1.0/webr-worker.js'); +importScripts('https://webr.r-wasm.org/v0.1.1/webr-worker.js'); diff --git a/_extensions/webr/webr.lua b/_extensions/webr/webr.lua index e3592b01..be059a8e 100644 --- a/_extensions/webr/webr.lua +++ b/_extensions/webr/webr.lua @@ -1,16 +1,132 @@ +---- +--- Setup variables for default initialization + -- Define a variable to only include the initialization once local hasDoneWebRSetup = false + +--- Setup default initialization values +-- Default values taken from: +-- https://docs.r-wasm.org/webr/latest/api/js/interfaces/WebR.WebROptions.html + +-- Define a base compatibile version +local baseVersionWebR = "0.1.1" + +-- Define where WebR can be found +local baseUrl = "" +local serviceWorkerUrl = "" + +-- Define user directory +local homeDir = "/home/web_user" + +-- Define whether a startup message should be displayed +local showStartUpMessage = "true" + +-- Define whether header type messages should be displayed +local showHeaderMessage = "false" + +-- Define an empty string if no packages need to be installed. +local installRPackagesList = "''" +---- + +--- Setup variables for tracking number of code cells + -- Define a counter variable local counter = 0 + ---- +--- Process initialization --- Read in the editor template -function editorTemplateFile() - -- Establish a hardcoded path to where the webr-editor.html partial resides +-- Check if variable is present and not just the empty string +function is_variable_empty(s) + return s == nil or s == '' +end + +-- Parse the different webr options set in the YAML frontmatter, e.g. +-- +-- ```yaml +-- ---- +-- webr: +-- base-url: https://webr.r-wasm.org/[version] +-- service-worker-url: path/to/workers/{webr-serviceworker.js, webr-worker.js} +-- ---- +-- ``` +-- +-- +function setWebRInitializationOptions(meta) + + -- Let's explore the meta variable data! + -- quarto.log.output(meta) + + -- Retrieve the webr options from meta + local webr = meta.webr + + -- Does this exist? If not, just return meta as we'll just use the defaults. + if is_variable_empty(webr) then + return meta + end + + -- The base URL used for downloading R WebAssembly binaries + -- https://webr.r-wasm.org/[version]/webr.mjs + -- Documentation: + -- https://docs.r-wasm.org/webr/latest/api/js/interfaces/WebR.WebROptions.html#baseurl + if not is_variable_empty(webr["base-url"]) then + baseUrl = pandoc.utils.stringify(webr["base-url"]) + end + + -- The base URL from where to load JavaScript worker scripts when loading webR + -- with the ServiceWorker communication channel mode. + -- Documentation: + -- https://docs.r-wasm.org/webr/latest/api/js/interfaces/WebR.WebROptions.html#serviceworkerurl + if not is_variable_empty(webr["service-worker-url"]) then + serviceWorkerUrl = pandoc.utils.stringify(webr["service-worker-url"]) + end + + -- The WebAssembly user's home directory and initial working directory. Default: '/home/web_user' + -- Documentation: + -- https://docs.r-wasm.org/webr/latest/api/js/interfaces/WebR.WebROptions.html#homedir + if not is_variable_empty(webr['home-dir']) then + homeDir = pandoc.utils.stringify(webr["home-dir"]) + end + + -- Display a startup message indicating the WebR state at the top of the document. + if not is_variable_empty(webr['show-startup-message']) then + showStartUpMessage = pandoc.utils.stringify(webr["show-startup-message"]) + end + + -- Display a startup message indicating the WebR state at the top of the document. + if not is_variable_empty(webr['show-header-message']) then + showHeaderMessage = pandoc.utils.stringify(webr["show-header-message"]) + if showHeaderMessage == "true" then + showStartUpMessage = "true" + end + end + + + -- Attempt to install different packages. + if not is_variable_empty(webr["packages"]) then + -- Create a custom list + local package_list = {} + + -- Iterate through each list item and enclose it in quotes + for _, package_name in pairs(webr["packages"]) do + table.insert(package_list, "'" .. pandoc.utils.stringify(package_name) .. "'") + end + + installRPackagesList = table.concat(package_list, ", ") + end + + + return meta +end + + +-- Obtain a template file +function readTemplateFile(template) + -- Establish a hardcoded path to where the .html partial resides -- Note, this should be at the same level as the lua filter. - -- This is crazy fragile since lua lacks a directory representation (!?!?) + -- This is crazy fragile since Lua lacks a directory representation (!?!?) -- https://quarto.org/docs/extensions/lua-api.html#includes - local path = quarto.utils.resolve_path("webr-editor.html") + local path = quarto.utils.resolve_path(template) -- Let's hopefully read the template file... @@ -32,9 +148,18 @@ function editorTemplateFile() return content end +-- Obtain the editor template file at webr-editor.html +function editorTemplateFile() + return readTemplateFile("webr-editor.html") +end + +-- Obtain the initialization template file at webr-init.html +function initializationTemplateFile() + return readTemplateFile("webr-init.html") +end + -- Cache a copy of the template to avoid multiple read/writes. editor_template = editorTemplateFile() - ---- -- Define a function that escape control sequence @@ -48,8 +173,86 @@ function escapeControlSequences(str) end) end +-- Check if version is latest +function isLatestVersion(str) + return str == "latest" +end + + +-- Verify the string is a valid version +function isMajorMinorPatchFormat(version) + -- Create a regular expression pattern that matches: + -- major.minor.patch + local pattern = "^%d+%.%d+%.%d+$" + + -- If the pattern matches, then we're set! + return string.match(version, pattern) ~= nil +end + +function checkMajorMinorPatchVersionFormat(version_string) + -- Verify string matches a given format + if not isMajorMinorPatchFormat(version_string) then + error("Invalid version string: " .. version_string) + end + -- Empty return to use as enforcement + return +end + +-- Compare versions +function compareMajorMinorPatchVersions(v1, v2) + + -- Enforce a version string + checkMajorMinorPatchVersionFormat(v1) + checkMajorMinorPatchVersionFormat(v2) + + -- Extract version details + local v1_major, v1_minor, v1_patch = v1:match("(%d+)%.(%d+)%.(%d+)") + local v2_major, v2_minor, v2_patch = v2:match("(%d+)%.(%d+)%.(%d+)") + + -- Perform a comparison check on the dot releases, such that: + -- v1 > v2 returns 1 + -- v2 > v1 returns -1 + -- v1 == v2 returns 0 + if tonumber(v1_major) > tonumber(v2_major) then + return 1 + elseif tonumber(v2_major) > tonumber(v1_major) then + return -1 + elseif tonumber(v1_minor) > tonumber(v2_minor) then + return 1 + elseif tonumber(v2_minor) > tonumber(v1_minor) then + return -1 + elseif tonumber(v1_patch) > tonumber(v2_patch) then + return 1 + elseif tonumber(v2_patch) > tonumber(v1_patch) then + return -1 + else + return 0 + end +end ---- +function initializationWebR() + + -- Setup different WebR specific initialization variables + local substitutions = { + ["SHOWSTARTUPMESSAGE"] = showStartUpMessage, -- tostring() + ["SHOWHEADERMESSAGE"] = showHeaderMessage, + ["BASEURL"] = baseUrl, + ["SERVICEWORKERURL"] = serviceWorkerUrl, + ["HOMEDIR"] = homeDir, + ["INSTALLRPACKAGESLIST"] = installRPackagesList + -- ["VERSION"] = baseVersionWebR + } + + -- Make sure we perform a copy + local initializationTemplate = initializationTemplateFile() + + -- Make the necessary substitutions + local initializedWebRConfiguration = substitute_in_file(initializationTemplate, substitutions) + + return initializedWebRConfiguration +end + -- Setup WebR's pre-requisites once per document. function ensureWebRSetup() @@ -60,10 +263,12 @@ function ensureWebRSetup() -- Otherwise, let's include the initialization script _once_ hasDoneWebRSetup = true + + local initializedConfigurationWebR = initializationWebR() -- Insert the web initialization -- https://quarto.org/docs/extensions/lua-api.html#includes - quarto.doc.include_file("in-header", "webr-init.html") + quarto.doc.include_text("in-header", initializedConfigurationWebR) -- Copy the two web workers into the directory -- https://quarto.org/docs/extensions/lua-api.html#dependencies @@ -82,67 +287,72 @@ function substitute_in_file(contents, substitutions) return contents end -return { - { - CodeBlock = function(el) +function enableWebRCodeCell(el) - -- Let's see what's going on here: - -- quarto.log.output(el) + -- Let's see what's going on here: + -- quarto.log.output(el) + + -- Should display the following elements: + -- https://pandoc.org/lua-filters.html#type-codeblock + + -- Verify the element has attributes and the document type is HTML + -- not sure if this will work with an epub (may need html:js) + if el.attr and quarto.doc.is_format("html") then + + -- Check to see if any form of the {webr} tag is present + + -- Look for the original compute cell type `{webr}` + -- If the compute engine is: + -- - jupyter: this appears as `{webr}` + -- - knitr: this appears as `webr` + -- since the later dislikes custom engines + local originalEngine = el.attr.classes:includes("{webr}") or el.attr.classes:includes("webr") + + -- Check for the new engine syntax that allows for the cell to be + -- evaluated in VS Code or RStudio editor views, c.f. + -- https://github.com/quarto-dev/quarto-cli/discussions/4761#discussioncomment-5336636 + local newEngine = el.attr.classes:includes("{webr-r}") + + if (originalEngine or newEngine) then - -- Should display the following elements: - -- https://pandoc.org/lua-filters.html#type-codeblock + -- Make sure we've initialized the code block + ensureWebRSetup() + + -- Modify the counter variable each time this is run to create + -- unique code cells + counter = counter + 1 + + -- 7 is the default height and width for knitr. But, that doesn't translate to pixels. + -- So, we have 504 and 360 respectively. + -- Should we check the attributes for this value? Seems odd. + -- https://yihui.org/knitr/options/ + local substitutions = { + ["WEBRCOUNTER"] = counter, + ["WIDTH"] = 504, + ["HEIGHT"] = 360, + ["WEBRCODE"] = escapeControlSequences(el.text) + } - -- Verify the element has attributes and the document type is HTML - -- not sure if this will work with an epub (may need html:js) - if el.attr and quarto.doc.is_format("html") then - - -- Check to see if any form of the {webr} tag is present - - -- Look for the original compute cell type `{webr}` - -- If the compute engine is: - -- - jupyter: this appears as `{webr}` - -- - knitr: this appears as `webr` - -- since the later dislikes custom engines - local originalEngine = el.attr.classes:includes("{webr}") or el.attr.classes:includes("webr") - - -- Check for the new engine syntax that allows for the cell to be - -- evaluated in VS Code or RStudio editor views, c.f. - -- https://github.com/quarto-dev/quarto-cli/discussions/4761#discussioncomment-5336636 - local newEngine = el.attr.classes:includes("{webr-r}") - - if (originalEngine or newEngine) then - - -- Make sure we've initialized the code block - ensureWebRSetup() - - -- Modify the counter variable each time this is run to create - -- unique code cells - counter = counter + 1 - - -- 7 is the default height and width for knitr. But, that doesn't translate to pixels. - -- So, we have 504 and 360 respectively. - -- Should we check the attributes for this value? Seems odd. - -- https://yihui.org/knitr/options/ - local substitutions = { - ["WEBRCOUNTER"] = counter, - ["WIDTH"] = 504, - ["HEIGHT"] = 360, - ["WEBRCODE"] = escapeControlSequences(el.text) - } - - -- Make sure we perform a copy - local copied_editor_template = editor_template - - -- Make the necessary substitutions - local webr_enabled_code_cell = substitute_in_file(copied_editor_template, substitutions) - - -- Return the modified HTML template as a raw cell - return pandoc.RawInline('html', webr_enabled_code_cell) - end - end - -- Allow for a pass through in other languages - return el + -- Make sure we perform a copy + local copied_editor_template = editor_template + + -- Make the necessary substitutions + local webr_enabled_code_cell = substitute_in_file(copied_editor_template, substitutions) + + -- Return the modified HTML template as a raw cell + return pandoc.RawInline('html', webr_enabled_code_cell) end + end + -- Allow for a pass through in other languages + return el +end + +return { + { + Meta = setWebRInitializationOptions + }, + { + CodeBlock = enableWebRCodeCell } } diff --git a/webr-demo.qmd b/webr-demo.qmd index e5b9c1f7..b1f06988 100644 --- a/webr-demo.qmd +++ b/webr-demo.qmd @@ -98,6 +98,13 @@ add_one(2) ``` +### Prior code cell + +```{webr} + +``` + + ### Pre-rendered code cell ```{r}