Fix standalone-HTML export: inject embed-resources into the deck frontmatter
Build and Deploy Verso / deploy (push) Has been cancelled

embed-resources cannot be enabled from the CLI: Quarto only honours it when
nested under the format, and a document's own format block fully overrides
project/CLI metadata (confirmed in Quarto docs). So --metadata embed-resources
was silently ignored and the 'standalone' HTML was the ordinary non-embedded
deck referencing a sibling _files/ dir — unstyled, no math, no images once
downloaded on its own.

For the html-standalone export, render a temporary copy of the root .qmd with
embed-resources/self-contained-math enabled and chalkboard disabled inside its
revealjs block (replacing an existing chalkboard key rather than duplicating
it), then clean the temp file up. Falls back to the original file if the deck
isn't an editable nested-revealjs document, so the export is never worse than
before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-02 09:51:52 +00:00
co-authored by Claude Opus 4.8
parent 67b27c2684
commit 7e1c2ce53a
+115 -35
View File
@@ -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 "<basename>_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 "<basename>_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 "<basename>_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 "<base>_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()