Skip to content

Commit c6c591e

Browse files
authored
Merge pull request #326 from quarto-dev/feature/bd-5b21rbaq-video-shortcode-preview
fix(video): embed + size video shortcode in preview, hub-client, and revealjs render
2 parents e8fe71d + 784acea commit c6c591e

13 files changed

Lines changed: 821 additions & 32 deletions

File tree

claude-notes/plans/2026-06-22-video-shortcode-preview.md

Lines changed: 328 additions & 0 deletions
Large diffs are not rendered by default.

crates/quarto-core/src/format.rs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,37 @@ pub fn is_revealjs_target(target_format: &str) -> bool {
138138
matches!(target_format, "revealjs" | "q2-slides")
139139
}
140140

141+
/// The canonical Pandoc output format a Lua filter or shortcode should see as
142+
/// its `FORMAT` global, given the pipeline's `target_format`.
143+
///
144+
/// The preview pipeline runs with a *pseudo-format* `target_format`
145+
/// (`q2-preview`, `q2-slides`, …) so q2-core can branch on it for AST-vs-HTML
146+
/// output and reveal-tree construction (see [`builtin_pseudo_format`] /
147+
/// [`is_revealjs_target`]). But Lua extensions don't know about those
148+
/// pseudo-formats: their `quarto.doc.is_format("html:js")` /
149+
/// `is_format("revealjs")` checks (e.g. the built-in `video` shortcode) only
150+
/// recognize real Pandoc formats. If we hand the pseudo-format straight to Lua,
151+
/// those checks fail and format-gated shortcodes/filters silently degrade
152+
/// (`{{< video >}}` collapsed to a plain link in preview — bd-5b21rbaq).
153+
///
154+
/// This maps each preview pseudo-format back to the real format it emulates so
155+
/// preview Lua behaves identically to render:
156+
/// - `q2-preview` / `q2-debug` / `q2-raw` → `html`
157+
/// - `q2-slides` → `revealjs` (so `is_format("revealjs")` is true, matching
158+
/// what [`is_revealjs_target`] already does for pipeline decisions; note this
159+
/// intentionally differs from [`builtin_pseudo_format`], which reports the
160+
/// *output writer* base `html`).
161+
///
162+
/// Any non-pseudo `target_format` (`html`, `revealjs`, `latex`, …) passes
163+
/// through unchanged.
164+
pub fn lua_format_for(target_format: &str) -> &str {
165+
match target_format {
166+
"q2-preview" | "q2-debug" | "q2-raw" => "html",
167+
"q2-slides" => "revealjs",
168+
other => other,
169+
}
170+
}
171+
141172
/// Extract the chosen output format from a document's leading YAML
142173
/// front-matter: the `format:` scalar, or the first key when `format:` is a
143174
/// map (e.g. `format: {revealjs: {...}}`). Returns `None` when there is no
@@ -329,6 +360,35 @@ impl Format {
329360
}
330361
}
331362

363+
/// The canonical Pandoc output format a Lua filter or shortcode should see
364+
/// as its `FORMAT` global.
365+
///
366+
/// Lua extensions branch on real Pandoc formats
367+
/// (`quarto.doc.is_format("html:js")`, `is_format("revealjs")`, …), not on
368+
/// q2's preview pseudo-formats or extension-style format strings. This
369+
/// resolves a `Format` to the base format it emulates:
370+
/// - reveal targets (`revealjs` render **and** `q2-slides` preview) →
371+
/// `"revealjs"`, so `is_format("revealjs")` fires in preview too. The
372+
/// bare [`identifier`](Self::identifier) would otherwise collapse
373+
/// `q2-slides` to `Html` (its *output writer* is HTML), losing
374+
/// reveal-ness — the reason a user filter in a reveal preview saw
375+
/// `"html"` (bd-5b21rbaq).
376+
/// - everything else → the identifier base (`html`, `pdf`, …), which
377+
/// already canonicalizes extension formats (`acm-pdf` → `pdf`) and the
378+
/// HTML preview pseudo-formats (`q2-preview`/`q2-debug`/`q2-raw` → `html`).
379+
///
380+
/// This is the [`Format`]-aware companion to the string-only
381+
/// [`lua_format_for`] (used where only a `target_format` string is in hand,
382+
/// e.g. the transform-pipeline builder); the two agree on the pseudo-format
383+
/// and base cases.
384+
pub fn lua_format(&self) -> &str {
385+
if is_revealjs_target(&self.target_format) {
386+
"revealjs"
387+
} else {
388+
self.identifier.as_str()
389+
}
390+
}
391+
332392
/// Parse a format string into a Format.
333393
///
334394
/// Accepts:
@@ -821,6 +881,79 @@ mod tests {
821881
assert_eq!(f.pipeline_kind, Some("preview"));
822882
}
823883

884+
#[test]
885+
fn test_lua_format_for_maps_preview_pseudo_formats() {
886+
// HTML-emulating pseudo-formats resolve to `html` so
887+
// `is_format("html:js")` fires in preview (bd-5b21rbaq).
888+
assert_eq!(lua_format_for("q2-preview"), "html");
889+
assert_eq!(lua_format_for("q2-debug"), "html");
890+
assert_eq!(lua_format_for("q2-raw"), "html");
891+
// The reveal preview pseudo-format resolves to `revealjs` so
892+
// `is_format("revealjs")` fires — distinct from
893+
// `builtin_pseudo_format`, which reports the output-writer base `html`.
894+
assert_eq!(lua_format_for("q2-slides"), "revealjs");
895+
}
896+
897+
#[test]
898+
fn test_lua_format_for_passes_through_real_formats() {
899+
for f in ["html", "revealjs", "latex", "pdf", "gfm", "typst", "docx"] {
900+
assert_eq!(lua_format_for(f), f, "real format {f} must pass through");
901+
}
902+
}
903+
904+
#[test]
905+
fn test_format_lua_format_canonicalizes() {
906+
// Real formats: identifier base.
907+
assert_eq!(
908+
Format::from_format_string("html").unwrap().lua_format(),
909+
"html"
910+
);
911+
assert_eq!(
912+
Format::from_format_string("revealjs").unwrap().lua_format(),
913+
"revealjs"
914+
);
915+
// Extension formats canonicalize to their base (the bug `identifier`
916+
// already handled; `lua_format` must preserve it).
917+
assert_eq!(
918+
Format::from_format_string("acm-pdf").unwrap().lua_format(),
919+
"pdf"
920+
);
921+
// HTML preview pseudo-formats → html.
922+
assert_eq!(
923+
Format::from_format_string("q2-preview")
924+
.unwrap()
925+
.lua_format(),
926+
"html"
927+
);
928+
assert_eq!(
929+
Format::from_format_string("q2-debug").unwrap().lua_format(),
930+
"html"
931+
);
932+
// Reveal preview pseudo-format → revealjs (NOT its html output base) —
933+
// the parity fix for user filters in reveal preview.
934+
assert_eq!(
935+
Format::from_format_string("q2-slides")
936+
.unwrap()
937+
.lua_format(),
938+
"revealjs"
939+
);
940+
}
941+
942+
/// `Format::lua_format` and the string-only `lua_format_for` must agree on
943+
/// the pseudo-format + base cases both handle, so the shortcode pipeline
944+
/// (string-only) and user-filter stage (Format-aware) don't drift.
945+
#[test]
946+
fn test_lua_format_helpers_agree_on_shared_cases() {
947+
for fmt in ["html", "revealjs", "q2-preview", "q2-slides", "q2-debug"] {
948+
let f = Format::from_format_string(fmt).unwrap();
949+
assert_eq!(
950+
f.lua_format(),
951+
lua_format_for(&f.target_format),
952+
"lua_format helpers disagree for {fmt}"
953+
);
954+
}
955+
}
956+
824957
#[test]
825958
fn test_from_format_string_typst_extension() {
826959
let f = Format::from_format_string("typst").unwrap();

crates/quarto-core/src/pipeline.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1113,14 +1113,22 @@ pub fn build_transform_pipeline(
11131113
// True for `revealjs` (native render) and `q2-slides` (preview).
11141114
let is_revealjs = crate::format::is_revealjs_target(&target_format);
11151115

1116+
// The Lua engines (shortcodes, user filters) see the *canonical* Pandoc
1117+
// format as their `FORMAT` global, not q2's preview pseudo-format. Under
1118+
// preview, `target_format` is `q2-preview` / `q2-slides`, which Lua's
1119+
// `is_format("html:js")` / `is_format("revealjs")` don't recognize — so
1120+
// format-gated shortcodes degrade (the `{{< video >}}` → plain-link bug,
1121+
// bd-5b21rbaq). Normalizing here makes preview Lua behave like render.
1122+
let lua_format = crate::format::lua_format_for(&target_format).to_string();
1123+
11161124
// === NORMALIZATION PHASE ===
11171125
pipeline.push(Box::new(CalloutTransform::new()));
11181126
pipeline.push(Box::new(CalloutResolveTransform::new()));
11191127
pipeline.push(Box::new(ShortcodeResolveTransform::with_lua_support(
11201128
shortcode_paths,
11211129
extensions,
11221130
runtime,
1123-
target_format,
1131+
lua_format,
11241132
)));
11251133
pipeline.push(Box::new(MetadataNormalizeTransform::new()));
11261134
// bd-1tl09 Phase 0: code-block decoration Generate runs after

crates/quarto-core/src/stage/stages/user_filters.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,12 @@ impl PipelineStage for UserFiltersStage {
131131
.join(", ")
132132
);
133133

134-
let target_format = ctx.format.identifier.as_str();
134+
// The canonical Pandoc format Lua filters see as FORMAT. Using
135+
// `Format::lua_format()` (not `identifier.as_str()`) makes the reveal
136+
// *preview* pseudo-format `q2-slides` resolve to `revealjs` rather than
137+
// its HTML output-writer base — so a user filter's `is_format("revealjs")`
138+
// fires in preview exactly as in native reveal render (bd-5b21rbaq).
139+
let target_format = ctx.format.lua_format();
135140

136141
// Build the attribution lookup handle when a sidecar is
137142
// present. `AttributionGenerateStage` runs before this stage

crates/quarto-core/src/transforms/shortcode_resolve.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -470,15 +470,25 @@ fn shortcode_to_lua_args(
470470
})
471471
.collect();
472472

473-
// Extract top-level metadata as string key-value pairs for Lua
473+
// Extract top-level metadata as string key-value pairs for Lua.
474+
//
475+
// Forward scalars of every stringifiable kind, not just strings: a handler
476+
// that gates on a boolean/numeric flag (e.g. the `video` shortcode reading
477+
// `auto-stretch: false` to decide reveal stretching — bd-5b21rbaq) needs to
478+
// see it. Booleans/ints are stringified ("false", "16"); string and
479+
// PandocInlines scalars come through `as_plain_text()`. Map/array values
480+
// remain dropped (no flat string form).
474481
let meta_entries: Vec<(String, String)> = if let Some(entries) = metadata.as_map_entries() {
475482
entries
476483
.iter()
477484
.filter_map(|entry| {
478-
entry
479-
.value
480-
.as_str()
481-
.map(|v| (entry.key.clone(), v.to_string()))
485+
let v = &entry.value;
486+
let s = v
487+
.as_bool()
488+
.map(|b| b.to_string())
489+
.or_else(|| v.as_int().map(|n| n.to_string()))
490+
.or_else(|| v.as_plain_text());
491+
s.map(|s| (entry.key.clone(), s))
482492
})
483493
.collect()
484494
} else {

crates/quarto-core/tests/integration/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ pub mod replay_engine;
4040
pub mod revealjs_features;
4141
pub mod revealjs_format;
4242
pub mod sidebar_pipeline;
43+
pub mod video_shortcode_preview;
4344
pub mod website_post_render;
4445

4546
fn main() {}

crates/quarto-core/tests/integration/revealjs_features.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,3 +444,57 @@ fn fragment_data_index_passes_through() {
444444
&html[..html.len().min(1500)]
445445
);
446446
}
447+
448+
// ── video shortcode auto-stretch on reveal (bd-5b21rbaq Phase 5) ──────────
449+
//
450+
// A `{{< video >}}` on a slide emits a bare `<iframe>` with no size, which
451+
// reveal renders at the browser default (~300×150). reveal sizes
452+
// `section > iframe.r-stretch` to fill the slide, so the video shortcode adds
453+
// `r-stretch` to the reveal iframe — but only when auto-stretch is on AND the
454+
// author gave no explicit width/height (so explicit sizing always wins).
455+
456+
fn video_iframe_line(html: &str) -> String {
457+
html.lines()
458+
.find(|l| l.contains("<iframe") && l.contains("youtube.com/embed"))
459+
.unwrap_or_else(|| panic!("no youtube iframe in reveal output:\n{html}"))
460+
.to_string()
461+
}
462+
463+
#[test]
464+
fn video_reveal_default_iframe_gets_r_stretch() {
465+
let html = render_revealjs(
466+
"---\nformat: revealjs\n---\n\n## Video\n\n{{< video https://youtu.be/sAWFsP0Bbbk >}}\n",
467+
);
468+
let iframe = video_iframe_line(&html);
469+
assert!(
470+
iframe.contains("r-stretch"),
471+
"default reveal video iframe must carry `r-stretch` so reveal sizes it; iframe:\n{iframe}"
472+
);
473+
}
474+
475+
#[test]
476+
fn video_reveal_explicit_width_no_r_stretch() {
477+
// Explicit author sizing must win — no auto-stretch.
478+
let html = render_revealjs(
479+
"---\nformat: revealjs\n---\n\n## Video\n\n{{< video https://youtu.be/sAWFsP0Bbbk width=\"640\" >}}\n",
480+
);
481+
let iframe = video_iframe_line(&html);
482+
assert!(
483+
!iframe.contains("r-stretch"),
484+
"reveal video with explicit width must NOT be stretched; iframe:\n{iframe}"
485+
);
486+
}
487+
488+
#[test]
489+
fn video_reveal_auto_stretch_false_no_r_stretch() {
490+
// The `auto-stretch: false` global opt-out must suppress the class —
491+
// requires the Lua meta bridge to forward the boolean to the handler.
492+
let html = render_revealjs(
493+
"---\nformat: revealjs\nauto-stretch: false\n---\n\n## Video\n\n{{< video https://youtu.be/sAWFsP0Bbbk >}}\n",
494+
);
495+
let iframe = video_iframe_line(&html);
496+
assert!(
497+
!iframe.contains("r-stretch"),
498+
"reveal video must NOT be stretched when `auto-stretch: false`; iframe:\n{iframe}"
499+
);
500+
}

0 commit comments

Comments
 (0)