diff --git a/llm-docs/revealjs-format-architecture.md b/llm-docs/revealjs-format-architecture.md new file mode 100644 index 00000000000..c818d692876 --- /dev/null +++ b/llm-docs/revealjs-format-architecture.md @@ -0,0 +1,173 @@ +--- +main_commit: 98be8a667 +analyzed_date: 2026-02-23 +key_files: + - src/format/reveal/format-reveal.ts + - src/format/reveal/constants.ts + - src/resources/formats/revealjs/pandoc/template.html + - src/resources/formats/revealjs/pandoc/revealjs.template +--- + +# Revealjs Format Architecture + +How Quarto configures reveal.js presentations, covering metadata handling, template rendering, and the division of responsibilities between TypeScript and Pandoc templates. + +## Key Files + +| File | Role | +|------|------| +| `src/format/reveal/format-reveal.ts` | Format definition, metadata normalization, extras | +| `src/format/reveal/constants.ts` | Metadata key constants | +| `src/resources/formats/revealjs/pandoc/template.html` | Quarto's active Pandoc template | +| `src/resources/formats/revealjs/pandoc/revealjs.template` | Reference copy of Pandoc's upstream template | + +## Two Template Files + +Quarto maintains two revealjs template files. They serve different purposes: + +**`revealjs.template`** is a direct copy of Pandoc's `default.revealjs`, auto-generated by `package/src/common/update-pandoc.ts` during Pandoc version updates. Do not modify this file — changes will be overwritten on the next Pandoc update. + +**`template.html`** is Quarto's active template, specified in `formatExtras()` via `templateContext`. It diverges from the upstream template where Quarto needs different behavior (type-safe rendering, additional features, etc.). + +## Metadata Handling Pattern + +Revealjs configuration flows through three stages with distinct responsibilities: + +### Stage 1: Normalization — `revealResolveFormat()` + +Maps user-facing YAML keys to reveal.js configuration keys. Runs early in format resolution. + +Responsibilities: +- Map compound YAML structures to flat metadata (e.g., `scroll-view.snap` → `scrollSnap`) +- Normalize values (e.g., `navigationMode: "vertical"` → `"default"`) +- Create helper flags for template type handling (e.g., `scrollProgressAuto`) +- Remove intermediate metadata keys (e.g., delete `scroll-view` after extracting sub-options) + +Does NOT set defaults — only transforms what the user provided. + +### Stage 2: Defaults — `extras.metadata` in `formatExtras()` + +Sets opinionated default values that can be overridden by user metadata. These are the lowest priority in the metadata chain. + +```typescript +// General revealjs defaults +extras.metadata = { + ...extras.metadata, + ...revealMetadataFilter({ + width: 1050, + height: 700, + center: false, + transition: "none", + // ... + }), +}; + +// Conditional defaults (e.g., scroll-view options when view is "scroll") +if (format.metadata[kView] === "scroll") { + extras.metadata = { + ...extras.metadata, + [kScrollSnap]: "mandatory", + [kScrollLayout]: "full", + [kScrollActivationWidth]: 0, + }; +} +``` + +Setting defaults explicitly (rather than relying on Pandoc's `defField`) ensures the template always has values to render. + +### Stage 3: Post-processing — `fixupRevealJsInitialization()` + +DOM manipulation of the rendered HTML, handling values that can't be fixed in the template: + +- Quoting `slideNumber` string values (e.g., `h.v` → `'h.v'`) +- Quoting percentage-based `width`/`height` values +- Injecting `extraConfig` values (options not in the template) +- Registering plugins + +Use this stage only when template-level handling isn't possible. + +### Metadata Priority (highest to lowest) + +1. `metadataOverride` — forces values regardless of user settings +2. `format.metadata` — user values + normalization from `revealResolveFormat()` +3. Pandoc `defField` — Pandoc writer defaults for unset variables +4. `extras.metadata` — Quarto's opinionated defaults + +## Template Type Handling + +Pandoc templates render values as text. This creates type mismatches when reveal.js expects specific JavaScript types. Quarto's `template.html` uses conditional guards to render correct JS types. + +### The Problem + +Pandoc template variables have limited type awareness: +- `$var$` renders `BoolVal True` as `true`, `BoolVal False` as `false` (correct for JS booleans) +- `'$var$'` always renders as a quoted string, even for `false` → `'false'` (wrong — truthy in JS) +- Numbers rendered inside quotes become strings: `'$var$'` with `0` → `'0'` (wrong if JS expects a number) + +### Pattern: Mixed-Type Options + +When an option accepts both strings and booleans (e.g., `scrollSnap: "mandatory" | "proximity" | false`): + +``` +$if(scrollSnap)$ + scrollSnap: '$scrollSnap/nowrap$', +$else$ + scrollSnap: false, +$endif$ +``` + +This works because Pandoc's `$if()$` evaluates `BoolVal False` as false, so: +- String values (`"mandatory"`, `"proximity"`) → `$if$` is true → quoted output +- Boolean `false` → `$if$` is false → `$else$` renders unquoted `false` + +### Pattern: String "auto" with Boolean Fallback + +When an option accepts `"auto" | true | false` (e.g., `scrollProgress`), a helper flag avoids rendering `"auto"` as a boolean: + +TypeScript (normalization stage): +```typescript +if (value === "auto" || value === undefined) { + format.metadata[kHelperFlag] = true; + delete format.metadata[kOriginalKey]; +} +``` + +Template: +``` +$if(helperFlag)$ + option: 'auto', +$elseif(option)$ + option: $option$, +$else$ + option: false, +$endif$ +``` + +### Pattern: Numeric Options + +When reveal.js checks `typeof value === 'number'`, the template must NOT quote the value: + +``` + scrollActivationWidth: $scrollActivationWidth$, +``` + +Not `'$scrollActivationWidth$'` — that renders as string `'0'` instead of number `0`. + +## Known Pandoc Template Issues + +Pandoc's upstream `revealjs.template` (copied to `revealjs.template`) has type issues in the scroll-view block that Quarto's `template.html` fixes. Tracked in [jgm/pandoc#11486](https://github.com/jgm/pandoc/issues/11486): + +- `scrollSnap: '$scrollSnap$'` renders `false` as string `'false'` (truthy in JS) +- `scrollActivationWidth: '$scrollActivationWidth$'` renders numbers as strings +- `scrollProgress` defField defaults to `true` instead of reveal.js's `'auto'` + +## Adding New Reveal.js Options + +When adding support for a new reveal.js configuration option: + +1. Add the constant to `constants.ts` +2. If the option needs YAML normalization (e.g., a compound structure), add to `revealResolveFormat()` +3. If the option needs a default value, add to `extras.metadata` in `formatExtras()` +4. If the option needs type-safe rendering, add to `template.html` with appropriate `$if/$else$` guards +5. If the option can only be handled post-render, add to `extraConfig` (last resort) +6. Add smoke-all tests covering type edge cases diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 8c1e5fafb84..5d597a67ab3 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -102,6 +102,7 @@ All changes included in 1.9: ### `revealjs` - ([#13722](https://github.com/quarto-dev/quarto-cli/issues/13722)): Fix `light-content` / `dark-content` SCSS rules not included in Reveal.js format. (author: @mcanouil) +- ([#13852](https://github.com/quarto-dev/quarto-cli/issues/13852)): Scroll-view options (`scrollSnap`, `scrollProgress`, `scrollActivationWidth`, `scrollLayout`) are now rendered in the Pandoc template instead of injected at runtime. Custom revealjs templates may need updating to include the scroll-view configuration block. ### `ipynb` diff --git a/src/format/reveal/constants.ts b/src/format/reveal/constants.ts index 6fd3caebc16..9d3ee248b42 100644 --- a/src/format/reveal/constants.ts +++ b/src/format/reveal/constants.ts @@ -31,3 +31,4 @@ export const kScrollProgress = "scrollProgress"; export const kScrollSnap = "scrollSnap"; export const kScrollActivationWidth = "scrollActivationWidth"; export const kScrollLayout = "scrollLayout"; +export const kScrollProgressAuto = "scrollProgressAuto"; diff --git a/src/format/reveal/format-reveal.ts b/src/format/reveal/format-reveal.ts index 9bf7b0e69ef..24a8494987f 100644 --- a/src/format/reveal/format-reveal.ts +++ b/src/format/reveal/format-reveal.ts @@ -68,6 +68,7 @@ import { kScrollActivationWidth, kScrollLayout, kScrollProgress, + kScrollProgressAuto, kScrollSnap, kScrollView, kSlideFooter, @@ -118,6 +119,21 @@ export function revealResolveFormat(format: Format) { } // remove scroll-view from metadata delete format.metadata[kScrollView]; + + // Handle scrollProgress "auto" for the template. + // Pandoc templates render BoolVal as true/false literals, but "auto" needs + // to be a quoted string. A helper variable scrollProgressAuto handles this. + // When no scrollProgress is specified and view is "scroll", default to "auto" + // (RevealJS default) rather than Pandoc's defField default of true. + if (format.metadata[kView] === "scroll") { + if ( + format.metadata[kScrollProgress] === "auto" || + format.metadata[kScrollProgress] === undefined + ) { + format.metadata[kScrollProgressAuto] = true; + delete format.metadata[kScrollProgress]; + } + } } export function revealjsFormat() { @@ -193,23 +209,9 @@ export function revealjsFormat() { format.metadata[kPdfMaxPagesPerSlide]; } - // pass scroll view settings as they are not yet in revealjs template - if (format.metadata[kView]) { - extraConfig[kView] = format.metadata[kView]; - } - if (format.metadata[kScrollProgress] !== undefined) { - extraConfig[kScrollProgress] = format.metadata[kScrollProgress]; - } - if (format.metadata[kScrollSnap] !== undefined) { - extraConfig[kScrollSnap] = format.metadata[kScrollSnap]; - } - if (format.metadata[kScrollLayout] !== undefined) { - extraConfig[kScrollLayout] = format.metadata[kScrollLayout]; - } - if (format.metadata[kScrollActivationWidth] !== undefined) { - extraConfig[kScrollActivationWidth] = - format.metadata[kScrollActivationWidth]; - } + // Scroll view settings (view, scrollProgress, scrollSnap, scrollLayout, + // scrollActivationWidth) are rendered by the template via metadata + // variables set in revealResolveFormat(). // get theme info (including text highlighing mode) const theme = await revealTheme( @@ -359,6 +361,18 @@ export function revealjsFormat() { }; } + // Scroll-view defaults (only when view is "scroll"). + // Set explicitly so the template $if/$else$ type guards always have + // values and don't depend on Pandoc's defField. + if (format.metadata[kView] === "scroll") { + extras.metadata = { + ...extras.metadata, + [kScrollSnap]: "mandatory", + [kScrollLayout]: "full", + [kScrollActivationWidth]: 0, + }; + } + // hash-type: number (as shorthand for -auto_identifiers) if (format.metadata[kHashType] === "number") { extras.pandoc = { diff --git a/src/resources/formats/revealjs/pandoc/template.html b/src/resources/formats/revealjs/pandoc/template.html index 5c1c1649e09..05c60609692 100644 --- a/src/resources/formats/revealjs/pandoc/template.html +++ b/src/resources/formats/revealjs/pandoc/template.html @@ -217,7 +217,27 @@ // devices. It is advisable to set this to a lower number than // viewDistance in order to save resources. mobileViewDistance: $mobileViewDistance$, -$-- // TODO: Add scroll view option in template: https://github.com/quarto-dev/quarto-cli/issues/13852 +$-- Scroll view: differs from Pandoc's revealjs.template to fix JS type issues. +$-- See jgm/pandoc#11486 and llm-docs/revealjs-format-architecture.md +$if(view)$ + + // Enable scroll view + view: '$view/nowrap$', +$if(scrollProgressAuto)$ + scrollProgress: 'auto', +$elseif(scrollProgress)$ + scrollProgress: $scrollProgress$, +$else$ + scrollProgress: false, +$endif$ + scrollActivationWidth: $scrollActivationWidth$, +$if(scrollSnap)$ + scrollSnap: '$scrollSnap/nowrap$', +$else$ + scrollSnap: false, +$endif$ + scrollLayout: '$scrollLayout/nowrap$', +$endif$ $if(parallaxBackgroundImage)$ // Parallax background image diff --git a/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-absent.qmd b/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-absent.qmd new file mode 100644 index 00000000000..c234bcf9420 --- /dev/null +++ b/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-absent.qmd @@ -0,0 +1,23 @@ +--- +title: "No scroll view" +format: + revealjs: default +_quarto: + tests: + revealjs: + ensureFileRegexMatches: + - [] + - + - 'scrollActivationWidth' + - 'scrollSnap' + - 'scrollLayout' + - 'scrollProgress' +--- + +## test + +Content + +## test + +Content diff --git a/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-activation-width.qmd b/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-activation-width.qmd new file mode 100644 index 00000000000..65830c26697 --- /dev/null +++ b/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-activation-width.qmd @@ -0,0 +1,24 @@ +--- +title: "Scroll view activation width" +format: + revealjs: + scroll-view: + activate: true + activation-width: 600 +_quarto: + tests: + revealjs: + ensureFileRegexMatches: + - + - 'view: ''scroll'',' + - 'scrollActivationWidth: 600,' + - [] +--- + +## test + +Content + +## test + +Content diff --git a/tests/docs/smoke-all/revealjs/scroll-view-config.qmd b/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-config.qmd similarity index 51% rename from tests/docs/smoke-all/revealjs/scroll-view-config.qmd rename to tests/docs/smoke-all/revealjs/scroll-view/scroll-view-config.qmd index 76c4089544a..2fdec915994 100644 --- a/tests/docs/smoke-all/revealjs/scroll-view-config.qmd +++ b/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-config.qmd @@ -1,6 +1,6 @@ --- -title: "Jump to slide" -format: +title: "Scroll view config" +format: revealjs: scroll-view: activate: true @@ -12,12 +12,12 @@ _quarto: tests: revealjs: ensureFileRegexMatches: - - - - '''view'': "scroll",' - - '''scrollProgress'': true,' - - '''scrollSnap'': false,' - - '''scrollLayout'': "compact",' - - '''scrollActivationWidth'': 0,' + - + - 'view: ''scroll'',' + - 'scrollProgress: true,' + - 'scrollSnap: false,' + - 'scrollLayout: ''compact'',' + - 'scrollActivationWidth: 0,' - [] --- diff --git a/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-defaults.qmd b/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-defaults.qmd new file mode 100644 index 00000000000..3df3876028b --- /dev/null +++ b/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-defaults.qmd @@ -0,0 +1,25 @@ +--- +title: "Scroll view defaults" +format: + revealjs: + scroll-view: true +_quarto: + tests: + revealjs: + ensureFileRegexMatches: + - + - 'view: ''scroll'',' + - 'scrollProgress: ''auto'',' + - 'scrollSnap: ''mandatory'',' + - 'scrollLayout: ''full'',' + - 'scrollActivationWidth: 0,' + - [] +--- + +## test + +Content + +## test + +Content diff --git a/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-direct-view.qmd b/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-direct-view.qmd new file mode 100644 index 00000000000..f1f42b77166 --- /dev/null +++ b/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-direct-view.qmd @@ -0,0 +1,25 @@ +--- +title: "Scroll view via direct view key" +format: + revealjs: + view: scroll +_quarto: + tests: + revealjs: + ensureFileRegexMatches: + - + - 'view: ''scroll'',' + - 'scrollProgress: ''auto'',' + - 'scrollSnap: ''mandatory'',' + - 'scrollLayout: ''full'',' + - 'scrollActivationWidth: 0,' + - [] +--- + +## test + +Content + +## test + +Content diff --git a/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-progress-auto.qmd b/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-progress-auto.qmd new file mode 100644 index 00000000000..990c5a69038 --- /dev/null +++ b/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-progress-auto.qmd @@ -0,0 +1,24 @@ +--- +title: "Scroll view progress auto" +format: + revealjs: + scroll-view: + activate: true + progress: "auto" +_quarto: + tests: + revealjs: + ensureFileRegexMatches: + - + - 'view: ''scroll'',' + - 'scrollProgress: ''auto'',' + - [] +--- + +## test + +Content + +## test + +Content diff --git a/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-progress-false.qmd b/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-progress-false.qmd new file mode 100644 index 00000000000..3c7040abe6a --- /dev/null +++ b/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-progress-false.qmd @@ -0,0 +1,24 @@ +--- +title: "Scroll view progress false" +format: + revealjs: + scroll-view: + activate: true + progress: false +_quarto: + tests: + revealjs: + ensureFileRegexMatches: + - + - 'view: ''scroll'',' + - 'scrollProgress: false,' + - [] +--- + +## test + +Content + +## test + +Content diff --git a/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-progress-true.qmd b/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-progress-true.qmd new file mode 100644 index 00000000000..5f290a8b93d --- /dev/null +++ b/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-progress-true.qmd @@ -0,0 +1,24 @@ +--- +title: "Scroll view progress true" +format: + revealjs: + scroll-view: + activate: true + progress: true +_quarto: + tests: + revealjs: + ensureFileRegexMatches: + - + - 'view: ''scroll'',' + - 'scrollProgress: true,' + - [] +--- + +## test + +Content + +## test + +Content diff --git a/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-snap-false.qmd b/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-snap-false.qmd new file mode 100644 index 00000000000..572183f34b4 --- /dev/null +++ b/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-snap-false.qmd @@ -0,0 +1,24 @@ +--- +title: "Scroll view snap false" +format: + revealjs: + scroll-view: + activate: true + snap: false +_quarto: + tests: + revealjs: + ensureFileRegexMatches: + - + - 'view: ''scroll'',' + - 'scrollSnap: false,' + - [] +--- + +## test + +Content + +## test + +Content diff --git a/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-snap-proximity.qmd b/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-snap-proximity.qmd new file mode 100644 index 00000000000..7789b0a3ca9 --- /dev/null +++ b/tests/docs/smoke-all/revealjs/scroll-view/scroll-view-snap-proximity.qmd @@ -0,0 +1,24 @@ +--- +title: "Scroll view snap proximity" +format: + revealjs: + scroll-view: + activate: true + snap: proximity +_quarto: + tests: + revealjs: + ensureFileRegexMatches: + - + - 'view: ''scroll'',' + - 'scrollSnap: ''proximity'',' + - [] +--- + +## test + +Content + +## test + +Content