Skip to content

Commit 3ad1d95

Browse files
cscheidclaude
andcommitted
feat(preview): sync sibling assets a single-file deck references (bd-kpuweafo)
`q2 preview deck.qmd` on a bare file (no `_quarto.yml`) runs single-file mode, which doesn't walk the directory — so `![](./sibling-image.png)` rendered broken (the image never reached the VFS; the `<img>` request fell through to the SPA `index.html`: `naturalWidth 0`, `content-type: text/html`). Project mode works because the dir-walk syncs the image → automerge → VFS → blob URL. Match project behavior *for the assets the deck actually references*, without walking the directory (preserves the `bd-tnm3k` "don't index arbitrary siblings" property and the VFS-only delivery model — no new disk-serving route): - quarto-core: `transforms::collect_referenced_asset_urls(blocks)` returns the relative `Image` URLs a document references (reuses the ResourceCollector traversal; external/absolute URLs dropped). - quarto-preview: `config::resolve_single_file_assets` parses the deck and keeps the referenced relative binary assets that exist and canonicalize *under* the deck dir (rejects `../` escapes). Injected via `HubConfig.single_file_assets`. - quarto-hub: `ProjectFiles::with_binary_files`; the single-file branch appends these so `reconcile_files_with_index` syncs them like project mode. E2E (`q2 preview page.qmd`, no `_quarto.yml`, sibling image): now a blob URL, `naturalWidth 320` — identical to project mode. Tests: collect_referenced_asset_ urls (quarto-core), resolve_single_file_assets + `../`-escape guard (quarto-preview), with_binary_files (quarto-hub). `cargo xtask verify` green. v1 limits (documented in the plan): load-time resolution only (a reference added mid-edit needs reload), direct (non-`{{< include >}}`) image references only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9a0da8c commit 3ad1d95

10 files changed

Lines changed: 352 additions & 4 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Single-file `q2 preview`: resolve sibling assets the deck references
2+
3+
**Strand:** bd-kpuweafo · **Found:** 2026-06-16 (follow-up to bd-y259zb57 / bd-ggvq1j68)
4+
5+
## Problem
6+
7+
`q2 preview deck.qmd` on a bare file (no `_quarto.yml` ancestor) runs single-file
8+
mode (`bd-tnm3k`), which **does not walk the directory** — only the target `.qmd`
9+
(plus, now, `_brand.yml` siblings) reaches the preview VFS. So an image
10+
`![](./sibling-image.png)` renders broken: `vfsReadBinaryFile` fails, the asset
11+
manifest has no entry, and the `<img>` falls through to the SPA `index.html`
12+
(verified E2E: `naturalWidth = 0`, response `content-type: text/html`). In
13+
**project mode** (`_quarto.yml` present) the dir-walk syncs the image →
14+
automerge → VFS → blob URL, and it works (`naturalWidth = 320`).
15+
16+
## Constraint
17+
18+
Single-file mode must **not** walk the whole directory (`bd-tnm3k`): a bare
19+
`q2 preview ~/Downloads/note.qmd` must not index all of `~/Downloads`. So
20+
"match project behavior" = sync the **specific sibling assets the deck
21+
references**, not every sibling.
22+
23+
## Approach (reference-driven VFS sync)
24+
25+
Mirror project mode's *mechanism* (sync into VFS → blob URL), not a new
26+
disk-serving HTTP route (keeps the deliberate VFS-only security posture,
27+
`bd-teh4hbli`; avoids a single-file↔project mechanism divergence). Mirror the
28+
existing `resource_files` injection pattern (resolved in the preview layer,
29+
injected via `HubConfig`, synced by `reconcile_files_with_index`).
30+
31+
## Work items
32+
33+
- [x] **A. quarto-core — extract referenced asset URLs.** `transforms::
34+
collect_referenced_asset_urls(blocks) -> Vec<String>` (reuses the
35+
`ResourceCollectorTransform` traversal via empty anchors, so nested images
36+
in lists/Divs/figures/tables are found; external/absolute URLs dropped).
37+
Test: `collect_referenced_asset_urls_returns_relative_images_only`.
38+
- [x] **B. quarto-preview — resolve to on-disk siblings.**
39+
`config::resolve_single_file_assets(root, rel, runtime)`: parse deck via
40+
`pampa::readers::qmd::read`, collect URLs, keep relative binary assets that
41+
exist and canonicalize **under** `root` (no `../` escape). Returns
42+
root-relative paths. Tests: `single_file_assets_resolves_referenced_image_
43+
siblings_only`, `single_file_assets_rejects_parent_escape`.
44+
- [x] **C. config plumbing.** Resolved in `build_hub_config` (quarto-preview has
45+
the parser) → new `HubConfig.single_file_assets`. No `PreviewConfig` field
46+
(8 literal sites) — resolved at the mapping point instead.
47+
- [x] **D. quarto-hub — sync them.** `ProjectFiles::with_binary_files(...)`; the
48+
single-file branch in `context.rs` appends `config.single_file_assets` to
49+
`binary_files`, so `reconcile_files_with_index`'s binary loop syncs them.
50+
Test: `test_single_file_with_binary_files_adds_referenced_assets`.
51+
- [x] **E. E2E.** `q2 preview page.qmd` (no `_quarto.yml`) with sibling
52+
`./sibling-image.png`: before = broken (`naturalWidth 0`, raw HTTP url →
53+
SPA index.html); after = **blob URL, `naturalWidth 320`** — identical to
54+
project mode. Screenshot showed the rendered Quarto logo.
55+
56+
## Known v1 limitations (document, don't fix now)
57+
58+
- **Load-time only.** Assets are resolved when the session starts. An image
59+
reference *added while editing* won't sync until reload (project mode catches
60+
it via the dir watcher). Extending the single-file watch loop to re-extract on
61+
deck change is a follow-up.
62+
- **Direct references only** (not transitively through `{{< include >}}`).
63+
- **Images only** for v1 (the dominant case); other binary refs (e.g. linked
64+
PDFs/video) can extend `collect_referenced_asset_urls` later.
65+
- **Non-conventional brand paths** (`brand: ../shared/foo.yml`) remain
66+
project-only (bd-ggvq1j68).

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ pub use navbar_render::NavbarRenderTransform;
107107
pub use page_nav_generate::PageNavGenerateTransform;
108108
pub use page_nav_render::PageNavRenderTransform;
109109
pub use proof::ProofSugarTransform;
110-
pub use resource_collector::ResourceCollectorTransform;
110+
pub use resource_collector::{ResourceCollectorTransform, collect_referenced_asset_urls};
111111
pub use sectionize::SectionizeTransform;
112112
pub use shortcode_resolve::{ShortcodeResolveTransform, extract_shortcode_paths};
113113
pub use sidebar_generate::SidebarGenerateTransform;

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,35 @@ impl<'a> ResourceVisitor<'a> {
424424
}
425425
}
426426

427+
/// Collect the relative URLs of on-disk assets a document references
428+
/// (currently `Image` targets), in document order and deduplicated.
429+
///
430+
/// External (`http://`, `https://`, `//`, `data:`) and filesystem-root
431+
/// (`/...`) URLs are skipped — only document-relative paths the caller can
432+
/// resolve against the source directory are returned. This reuses the same
433+
/// AST traversal as [`ResourceCollectorTransform`] (so nested images — in
434+
/// lists, `Div`s, figures, tables — are found) but without the copy-intent /
435+
/// resolver machinery.
436+
///
437+
/// `q2 preview`'s single-file mode uses this to sync exactly the assets a deck
438+
/// references into the VFS, without walking the deck's directory (which the
439+
/// `bd-tnm3k` safety property forbids). See bd-kpuweafo.
440+
pub fn collect_referenced_asset_urls(blocks: &[Block]) -> Vec<String> {
441+
// Empty source/dest anchors: `collect_resource` does the external/absolute
442+
// filtering and dedup we want, and stores `Path::new("").join(url)` (== the
443+
// relative URL) as the copy source. We read those back as the raw URLs.
444+
let anchor = Path::new("");
445+
let mut visitor = ResourceVisitor::new(anchor, anchor);
446+
for block in blocks {
447+
visitor.visit_block(block);
448+
}
449+
visitor
450+
.copies
451+
.into_iter()
452+
.map(|(src, _dest)| src.to_string_lossy().into_owned())
453+
.collect()
454+
}
455+
427456
#[cfg(test)]
428457
mod tests {
429458
use super::*;
@@ -477,6 +506,58 @@ mod tests {
477506
)
478507
}
479508

509+
/// Build an `Image` inline with the given target URL.
510+
fn image(url: &str) -> Inline {
511+
Inline::Image(Image {
512+
attr: (String::new(), vec![], hashlink::LinkedHashMap::new()),
513+
content: vec![],
514+
target: (url.to_string(), String::new()),
515+
source_info: dummy_source_info(),
516+
attr_source: AttrSourceInfo::empty(),
517+
target_source: TargetSourceInfo::empty(),
518+
})
519+
}
520+
521+
fn para(inlines: Vec<Inline>) -> Block {
522+
Block::Paragraph(Paragraph {
523+
content: inlines,
524+
source_info: dummy_source_info(),
525+
})
526+
}
527+
528+
/// bd-kpuweafo: `collect_referenced_asset_urls` returns document-relative
529+
/// image URLs (including nested ones), in order, deduped; external and
530+
/// absolute URLs are dropped. The single-file preview uses this to sync
531+
/// exactly the assets a deck references into the VFS.
532+
#[test]
533+
fn collect_referenced_asset_urls_returns_relative_images_only() {
534+
let blocks = vec![
535+
para(vec![image("./sibling-image.png")]),
536+
// Nested in a bullet list — must still be found.
537+
Block::BulletList(quarto_pandoc_types::block::BulletList {
538+
content: vec![vec![para(vec![image("sub/diagram.svg")])]],
539+
source_info: dummy_source_info(),
540+
}),
541+
// External / absolute / duplicate — all dropped or deduped.
542+
para(vec![
543+
image("https://example.com/remote.png"),
544+
image("/etc/passwd"),
545+
image("data:image/png;base64,AAAA"),
546+
image("./sibling-image.png"),
547+
]),
548+
];
549+
550+
let urls = collect_referenced_asset_urls(&blocks);
551+
assert_eq!(
552+
urls,
553+
vec![
554+
"./sibling-image.png".to_string(),
555+
"sub/diagram.svg".to_string()
556+
],
557+
"only relative image URLs, in order, deduped; external/absolute dropped"
558+
);
559+
}
560+
480561
/// R5 (bd-cfl67): the collector pushes a `(src, dest)` pair
481562
/// into `ctx.resource_copies`, where `src` is the source-tree
482563
/// path the qmd points at and `dest` is the matching position

crates/quarto-hub/src/context.rs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,18 @@ pub struct HubConfig {
8383
/// Empty for the standalone `quarto hub` server.
8484
pub resource_files: Vec<PathBuf>,
8585

86+
/// Single-file mode (bd-kpuweafo): binary **asset** siblings the deck
87+
/// references (project-root-relative paths), e.g. `![](./img.png)`.
88+
///
89+
/// Single-file mode does not walk the directory (the `bd-tnm3k` safety
90+
/// property), so the bare `single_file` discovery never finds these.
91+
/// `q2 preview` resolves them upstream in `quarto-preview` (which has the
92+
/// qmd parser) via `config::resolve_single_file_assets` and injects them
93+
/// here so they reach [`ProjectFiles::with_binary_files`] and flow through
94+
/// the same automerge → VFS → blob-URL path project mode uses. Empty for
95+
/// project mode (the dir walk finds binaries) and the `quarto hub` server.
96+
pub single_file_assets: Vec<PathBuf>,
97+
8698
/// OAuth2 auth configuration. None = auth disabled.
8799
pub auth_config: Option<AuthConfig>,
88100

@@ -121,6 +133,7 @@ impl Default for HubConfig {
121133
watch_filter: WatchFilter::default(),
122134
single_file: None,
123135
resource_files: Vec::new(),
136+
single_file_assets: Vec::new(),
124137
auth_config: None,
125138
allow_insecure_auth: false,
126139
register_root_ws: true,
@@ -213,9 +226,12 @@ impl HubContext {
213226
// that references `brand: _brand.yml` needs that sibling in the
214227
// VFS. Pick up the conventionally-named brand file next to the
215228
// target without walking the directory (stays narrow).
216-
Some(rel) => {
217-
ProjectFiles::single_file(rel.clone()).with_config_siblings(project_root, rel)
218-
}
229+
Some(rel) => ProjectFiles::single_file(rel.clone())
230+
.with_config_siblings(project_root, rel)
231+
// bd-kpuweafo: sync the sibling images the deck references
232+
// (resolved upstream by quarto-preview) so they reach the
233+
// VFS like project mode, without walking the directory.
234+
.with_binary_files(config.single_file_assets.clone()),
219235
// bd-kjrpya2d: the bare walk can't see resources-scoped
220236
// `.html` (it falls through every category), so the
221237
// caller-resolved set is injected here to ride the text

crates/quarto-hub/src/discovery.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,18 @@ impl ProjectFiles {
188188
self
189189
}
190190

191+
/// Attach binary asset files (project-root-relative) to be synced into the
192+
/// VFS, in addition to whatever discovery found. Used by single-file mode
193+
/// (bd-kpuweafo) to add the sibling images a deck references — resolved
194+
/// upstream in `quarto-preview` — without walking the directory. Sorted +
195+
/// deduped against any already-present binaries for deterministic ordering.
196+
pub fn with_binary_files(mut self, mut binary_files: Vec<PathBuf>) -> Self {
197+
self.binary_files.append(&mut binary_files);
198+
self.binary_files.sort();
199+
self.binary_files.dedup();
200+
self
201+
}
202+
191203
/// Attach resources-scoped `.html` files (resolved by the caller
192204
/// from `project.resources:`) to be synced into the VFS as text.
193205
/// Deduplicates and sorts for deterministic ordering. See
@@ -393,6 +405,25 @@ mod tests {
393405
);
394406
}
395407

408+
#[test]
409+
fn test_single_file_with_binary_files_adds_referenced_assets() {
410+
// bd-kpuweafo: single-file mode syncs the deck's referenced image
411+
// siblings (resolved upstream) via `with_binary_files`, keeping the
412+
// qmd as the only text file (no directory walk).
413+
let files = ProjectFiles::single_file(PathBuf::from("deck.qmd")).with_binary_files(vec![
414+
PathBuf::from("img.png"),
415+
PathBuf::from("sub/diagram.svg"),
416+
]);
417+
418+
assert_eq!(files.qmd_files, vec![PathBuf::from("deck.qmd")]);
419+
let mut bins = files.binary_files.clone();
420+
bins.sort();
421+
assert_eq!(
422+
bins,
423+
vec![PathBuf::from("img.png"), PathBuf::from("sub/diagram.svg")]
424+
);
425+
}
426+
396427
#[test]
397428
fn test_single_file_no_brand_sibling_stays_narrow() {
398429
// Without a `_brand.yml` next to the deck, single-file mode must NOT

crates/quarto-hub/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,8 @@ async fn main() -> anyhow::Result<()> {
222222
// Standalone `hub` server: resources-scoped `.html` sync is a
223223
// preview-only concern (bd-kjrpya2d).
224224
resource_files: Vec::new(),
225+
// Single-file asset sync is a preview-only concern (bd-kpuweafo).
226+
single_file_assets: Vec::new(),
225227
auth_config,
226228
allow_insecure_auth: args.allow_insecure_auth,
227229
register_root_ws: true,

crates/quarto-hub/tests/auth_bearer.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ impl TestHub {
361361
watch_filter: Default::default(),
362362
single_file: None,
363363
resource_files: Vec::new(),
364+
single_file_assets: Vec::new(),
364365
auth_config: Some(auth_config),
365366
allow_insecure_auth: true,
366367
register_root_ws: false,
@@ -481,6 +482,7 @@ async fn allowlist_setup() -> &'static (MockOidcProvider, TestHub) {
481482
watch_filter: Default::default(),
482483
single_file: None,
483484
resource_files: Vec::new(),
485+
single_file_assets: Vec::new(),
484486
auth_config: Some(auth_config),
485487
allow_insecure_auth: true,
486488
register_root_ws: false,
@@ -945,6 +947,7 @@ async fn secure_setup() -> &'static (MockOidcProvider, TestHub) {
945947
watch_filter: Default::default(),
946948
single_file: None,
947949
resource_files: Vec::new(),
950+
single_file_assets: Vec::new(),
948951
auth_config: Some(auth_config),
949952
allow_insecure_auth: false, // <-- the key difference
950953
register_root_ws: false,

0 commit comments

Comments
 (0)