diff --git a/services/clsi/app/js/QuartoRunner.js b/services/clsi/app/js/QuartoRunner.js index f57619741d..8cc2ffba0d 100644 --- a/services/clsi/app/js/QuartoRunner.js +++ b/services/clsi/app/js/QuartoRunner.js @@ -16,7 +16,17 @@ function runQuarto(compileName, options, callback) { 'starting quarto compile' ) - const command = _buildQuartoCommand(mainFile, options.exportMode) + // For the standalone-HTML export we must render a deck whose frontmatter + // carries embed-resources (it cannot be set from the CLI: Quarto only honours + // embed-resources when it is nested under the format, and a document's own + // format block fully overrides project/CLI metadata). So write a temporary + // copy of the root .qmd with the options injected and render that instead. + let renderTarget = mainFile + if (options.exportMode === 'html-standalone') { + renderTarget = _writeStandaloneVariant(directory, mainFile) + } + + const command = _buildQuartoCommand(renderTarget, options.exportMode) ProcessTable[compileName] = CommandRunner.run( compileName, @@ -50,19 +60,22 @@ function runQuarto(compileName, options, callback) { ) } -function _buildQuartoCommand(mainFile, exportMode) { +function _buildQuartoCommand(renderTarget, exportMode) { // Run through a POSIX shell so stderr is merged into stdout (2>&1). // LocalCommandRunner replaces $COMPILE_DIR before the shell sees it. // // We do NOT pass --to or --output: let the YAML frontmatter decide the // output format (typst → output.pdf, revealjs → output.html, etc.). // - // We do NOT pass --embed-resources. A self-contained single-file HTML - // breaks reveal.js plugins that load/store resources at runtime (e.g. - // chalkboard, multiplex) and is slow to transfer. Instead Quarto emits - // the HTML plus a sibling "_files/" asset directory; the HTML - // references it with relative paths. Both the html and the asset dir are - // served from the same .../output/ path, so the relative links resolve. + // For a normal preview compile we do NOT embed resources. A self-contained + // single-file HTML breaks reveal.js plugins that load/store resources at + // runtime (e.g. chalkboard, multiplex) and is slow to transfer. Instead + // Quarto emits the HTML plus a sibling "_files/" asset directory; + // the HTML references it with relative paths. Both the html and the asset + // dir are served from the same .../output/ path, so the relative links + // resolve. For the 'html-standalone' export, runQuarto instead renders a + // temporary copy of the deck (renderTarget) whose frontmatter enables + // embed-resources, producing a single portable file. // // After render we rename the produced top-level file to output.pdf or // output.html. The asset directory keeps its "_files" name; the @@ -72,31 +85,8 @@ function _buildQuartoCommand(mainFile, exportMode) { // trailing semicolon (so a missing /opt/quarto-extensions doesn't abort) // are kept. mv uses relative paths because LocalCommandRunner.replace() // only substitutes the FIRST $COMPILE_DIR and the shell CWD is the dir. - const inputPath = `$COMPILE_DIR/${mainFile}` - const baseName = mainFile.replace(/\.[^/.]+$/, '') // strip extension - - // Export modes (on-demand download from the editor), vs the normal preview - // compile: - // - 'html-standalone': a single self-contained .html (embed-resources). - // Runtime plugins like chalkboard won't work self-contained, but the - // deck compiles and is fully portable/offline. - // - 'pdf-slides': render the deck, then print it to a faithful slide PDF - // with decktape (headless Chromium), one slide per page. - // NB: Quarto's --metadata/-M flag uses COLON syntax (KEY:VALUE), not "=". - // Using "=" makes Quarto register a bogus key literally named - // "embed-resources=true" and silently leave the real option unset, so the - // deck stays non-self-contained (references a sibling "_files/" dir). - // - embed-resources: inline CSS/JS/images into the single .html - // - self-contained-math: also inline MathJax (not embedded by default), - // so equations render in a fully offline/portable file - // - chalkboard: must be off — it is incompatible with embed-resources and - // errors the render if left enabled - const renderFlags = - exportMode === 'html-standalone' - ? ' --metadata embed-resources:true' + - ' --metadata self-contained-math:true' + - ' --metadata chalkboard:false' - : '' + const inputPath = `$COMPILE_DIR/${renderTarget}` + const baseName = renderTarget.replace(/\.[^/.]+$/, '') // strip extension let tail = `(mv ${baseName}.pdf output.pdf 2>/dev/null || ` + @@ -123,14 +113,104 @@ function _buildQuartoCommand(mainFile, exportMode) { `"$(pwd)/output.html" output-slides.pdf 2>&1` } + // For the standalone export, remove the temporary render copy afterwards so + // it can't be mistaken for a project file or picked up by a later preview + // compile. Runs regardless of render success (";"). + const cleanup = + exportMode === 'html-standalone' + ? `; rm -rf ${baseName}.qmd ${baseName}_files` + : '' + const cmd = `mkdir -p _extensions && ` + `cp -rn /opt/quarto-extensions/_extensions/. _extensions/ 2>/dev/null; ` + - `quarto render ${inputPath}${renderFlags} 2>&1 && ` + - tail + `quarto render ${inputPath} 2>&1 && ` + + tail + + cleanup return ['/bin/sh', '-c', cmd] } +// Write a temporary copy of the root .qmd with embed-resources enabled in its +// frontmatter, returning the temp filename to render. On any problem (no +// frontmatter, not a nested revealjs deck, read/write error) it falls back to +// the original mainFile — the export then just isn't self-contained, which is +// no worse than before. The temp file lives in the same directory so relative +// resources (images, _extensions) still resolve. +function _writeStandaloneVariant(directory, mainFile) { + try { + const content = fs.readFileSync(Path.join(directory, mainFile), 'utf8') + const transformed = _injectRevealjsStandaloneOptions(content) + if (!transformed) return mainFile + const base = mainFile.replace(/\.[^/.]+$/, '') + const tempName = `${base}.verso-standalone.qmd` + fs.writeFileSync(Path.join(directory, tempName), transformed) + return tempName + } catch (err) { + logger.warn({ err, directory, mainFile }, 'could not prepare standalone qmd') + return mainFile + } +} + +// Inject the self-contained options into the `revealjs:` block of a deck's +// YAML frontmatter. embed-resources/self-contained-math inline all CSS/JS/ +// images/MathJax into one portable file; chalkboard must be off (it is +// incompatible with embed-resources and would error the render). Keys already +// present in the block are overwritten in place (so we never create duplicate +// YAML keys, e.g. an existing `chalkboard: true`); missing keys are inserted. +// Returns the new document text, or null if it isn't a nested-revealjs deck we +// can safely edit. +function _injectRevealjsStandaloneOptions(content) { + const fmMatch = content.match(/^(---\r?\n)([\s\S]*?)(\r?\n---\r?\n?)/) + if (!fmMatch) return null + const [, open, body, close] = fmMatch + const lines = body.split('\n') + + const revealIdx = lines.findIndex(l => /^\s*revealjs:\s*$/.test(l)) + if (revealIdx === -1) return null // not a `format:\n revealjs:` deck + + const revealIndent = lines[revealIdx].match(/^(\s*)/)[1] + + // Determine the block's child indent (from the first more-indented line) and + // where the block ends (the first later line indented at/under revealjs:). + let childIndent = revealIndent + ' ' + let blockEnd = lines.length + let seenChild = false + for (let i = revealIdx + 1; i < lines.length; i++) { + if (lines[i].trim() === '') continue + const indent = lines[i].match(/^(\s*)/)[1] + if (indent.length <= revealIndent.length) { + blockEnd = i + break + } + if (!seenChild) { + childIndent = indent + seenChild = true + } + } + + const desired = { + 'embed-resources': 'true', + 'self-contained-math': 'true', + chalkboard: 'false', + } + + const present = new Set() + for (let i = revealIdx + 1; i < blockEnd; i++) { + const km = lines[i].match(/^\s*([A-Za-z0-9_-]+):/) + if (km && Object.prototype.hasOwnProperty.call(desired, km[1])) { + lines[i] = `${childIndent}${km[1]}: ${desired[km[1]]}` + present.add(km[1]) + } + } + + const additions = Object.keys(desired) + .filter(k => !present.has(k)) + .map(k => `${childIndent}${k}: ${desired[k]}`) + if (additions.length) lines.splice(revealIdx + 1, 0, ...additions) + + return open + lines.join('\n') + close + content.slice(fmMatch[0].length) +} + function _writeLogOutput(compileName, directory, output, callback) { const content = (output && output.stdout) || '' if (!content) return callback()