Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
304 changes: 304 additions & 0 deletions claude-notes/plans/2026-06-16-single-file-preview-transitive-deps.md

Large diffs are not rendered by default.

115 changes: 115 additions & 0 deletions claude-notes/research/2026-06-16-include-shortcode-path-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# `{{< include >}}` shortcode path resolution: the "no retargeting" design

**Captured:** 2026-06-16 (while planning bd-9cyza5vy, single-file preview deps).
**Why this exists in the repo, not in a personal memory:** this is a project-wide
design fact + a latent code/intent discrepancy that any contributor touching
include expansion, resource collection, or preview VFS population needs to know.

## The intended design (authoritative, from Carlos)

Quarto include shortcodes do **no path retargeting**. Every path a document
references — image/resource URLs **and** nested `{{< include >}}` arguments —
is meant to be resolved relative to the **original (top-level) file**, not the
file the path textually appears in.

History: Quarto 1's first include implementation *did* retarget paths (resolve
them relative to the included file). That was abandoned because users could not
reason about the boundary between:

- image paths parsed as markdown (retargetable in principle),
- raw-HTML image `src`s authored by the user (retargetable in principle),
- raw-HTML image `src`s emitted by an **executed code cell** (produced *after*
shortcode resolution — *cannot* be retargeted even in principle).

The simpler, teachable rule won: **no retargeting at all.** The recommended
authoring pattern in projects is **project-root-relative notation with a
leading slash** — `{{< include /path/with/leading/slash.qmd >}}` and
`![](/assets/img.png)` — because then the anchor is irrelevant and the
ambiguity disappears.

Docs: <https://quarto.org/docs/authoring/includes.html> (Carlos authored this
feature).

**Confirmed against the Quarto 1 implementation.**
`external-sources/quarto-cli/src/core/handlers/include-standalone.ts`
(`standaloneInclude`) resolves every include via the *same fixed*
`handlerContext.resolvePath(filename)`; the nested call
`retrieveInclude(params[0])` reuses the *same* `handlerContext`, whose anchor is
the original document's `target.source`. So all include paths — top-level and
nested — resolve against the **original** document. No retargeting. Q1 also
splices raw **text** (`mappedConcat(textFragments)`) and parses the whole
concatenation **once**, so image/resource paths are likewise un-retargeted by
construction. q2 instead splices at the **AST** level
(`expand_includes_in_blocks`), parsing each included file separately — which is
exactly where the nested-include asymmetry crept in (images stayed correct
because `ResourceCollectorTransform` re-anchors at the original deck dir).

## What q2 actually does today (empirically verified)

Fixture (`main.qmd` at root includes `sub/part.qmd`; `sub/part.qmd` contains
both an image and a nested include; `other.qmd`/`img.png` exist at BOTH root
and `sub/`):

```
main.qmd {{< include sub/part.qmd >}}
sub/part.qmd ![](img.png) + {{< include other.qmd >}}
other.qmd ROOT-OTHER sub/other.qmd SUB-OTHER
img.png ROOTPNG sub/img.png SUBPNG
```

`q2 render main.qmd --to html` produced:

- `<img src="img.png">` in `main.html` (at root) → resolves to **root `img.png`
(ROOTPNG)**. **No retargeting** — matches the intended design. ✓
- Included text was **`SUB-OTHER`** → the nested `{{< include other.qmd >}}`
resolved relative to **the including file (`sub/`)**, i.e. it **WAS
retargeted**. ✗ contradicts the intended design.

### So there is an asymmetry / latent bug

- **Images / resources:** resolved by `ResourceCollectorTransform` over the
*fully expanded* AST using `input_dir = ctx.document.input.parent()` — the
*original* deck dir — and `expand_includes_in_blocks` never rewrites URL
strings. ⇒ correctly **un-retargeted**.
(`crates/quarto-core/src/transforms/resource_collector.rs:79`,
`:440` `collect_referenced_asset_urls`.)
- **Nested includes:** `expand_includes_in_blocks` recurses with
`current_file = &resolved` (the just-included file), so the next level's
`base_dir = current_file.parent()` is the *including* file's dir — a
retarget. ⇒ **violates** the no-retargeting design for nested includes in
subdirectories.
(`crates/quarto-core/src/stage/stages/include_expansion.rs:90` and the
recursive call at `:248`.)

A single-level include from the deck root is unaffected (the anchor is the deck
dir either way); the discrepancy only bites a nested include whose *including*
file lives in a subdirectory and whose argument is a relative path.

### Leading-slash caveat (separate, also worth a look)

`base_dir.join("/foo.qmd")` on Unix yields `/foo.qmd` (filesystem root), not
project-root. So the recommended `{{< include /... >}}` notation does **not**
resolve to the project root through this code path as-is — there must be
(or must be added) explicit project-root handling upstream of the bare
`join`. Verify before relying on leading-slash includes; not exercised by the
fixture above.

## Consequence for preview ↔ render parity (bd-9cyza5vy)

`q2 preview` must match `q2 render` **bug-for-bug**, including this asymmetry,
until render itself is changed. The safest way for the single-file preview
dependency resolver to stay in lock-step is to **reuse the renderer's actual
`IncludeExpansionStage`** (run it natively against the real filesystem) rather
than re-deriving include path resolution in a parallel walker — otherwise the
resolver would have to hard-code one side of a contested rule and could drift
from render now or after a future fix. See
`claude-notes/plans/2026-06-16-single-file-preview-transitive-deps.md`.

## Follow-up (filed)

**bd-udrn0q47** (bug, render-side): align nested-include resolution with the
documented no-retargeting design (anchor the recursion at the original
top-level document dir, not the including file), and fix/confirm leading-slash
project-root includes. That is a **render** behaviour change, distinct from the
preview parity work (bd-9cyza5vy), and should land on the render side so preview
inherits it.
55 changes: 54 additions & 1 deletion crates/quarto-hub/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,18 @@ pub struct HubConfig {
/// project mode (the dir walk finds binaries) and the `quarto hub` server.
pub single_file_assets: Vec<PathBuf>,

/// Single-file mode (bd-9cyza5vy): included `.qmd` files the deck pulls in
/// via `{{< include >}}` (transitively), project-root-relative.
///
/// Single-file mode does not walk the directory, so the bare `single_file`
/// discovery never finds these. `q2 preview` resolves the deck's transitive
/// include closure upstream in `quarto-preview` (which has the qmd parser)
/// via `config::resolve_single_file_deps` and injects them here so they reach
/// [`ProjectFiles::with_text_deps`] — synced into the VFS as text (readable
/// by the WASM include-expansion stage) but kept out of `qmd_files` (invisible
/// dependencies). Empty for project mode and the `quarto hub` server.
pub single_file_text_deps: Vec<PathBuf>,

/// OAuth2 auth configuration. None = auth disabled.
pub auth_config: Option<AuthConfig>,

Expand Down Expand Up @@ -134,6 +146,7 @@ impl Default for HubConfig {
single_file: None,
resource_files: Vec::new(),
single_file_assets: Vec::new(),
single_file_text_deps: Vec::new(),
auth_config: None,
allow_insecure_auth: false,
register_root_ws: true,
Expand Down Expand Up @@ -231,7 +244,11 @@ impl HubContext {
// bd-kpuweafo: sync the sibling images the deck references
// (resolved upstream by quarto-preview) so they reach the
// VFS like project mode, without walking the directory.
.with_binary_files(config.single_file_assets.clone()),
.with_binary_files(config.single_file_assets.clone())
// bd-9cyza5vy: sync the deck's transitive `{{< include >}}`
// closure as invisible text deps (also resolved upstream)
// so includes expand in the WASM pipeline.
.with_text_deps(config.single_file_text_deps.clone()),
// bd-kjrpya2d: the bare walk can't see resources-scoped
// `.html` (it falls through every category), so the
// caller-resolved set is injected here to ride the text
Expand All @@ -246,6 +263,7 @@ impl HubContext {
extension_count = files.extension_files.len(),
source_count = files.source_files.len(),
resource_count = files.resource_files.len(),
text_dep_count = files.text_dep_files.len(),
"Discovered project files"
);
Some(files)
Expand Down Expand Up @@ -788,6 +806,41 @@ mod tests {
assert_eq!(files.len(), 1);
}

/// bd-9cyza5vy: in single-file mode, included `.qmd` injected via
/// `single_file_text_deps` must be synced into the index as invisible text
/// deps — present in the VFS (so includes expand) but NOT in `qmd_files`.
#[tokio::test]
async fn test_single_file_mode_syncs_text_deps_invisibly() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join("main.qmd"), "{{< include part.qmd >}}\n").unwrap();
std::fs::write(temp.path().join("part.qmd"), "## Included\n").unwrap();

let storage = StorageManager::new(temp.path()).unwrap();
let config = HubConfig {
single_file: Some(PathBuf::from("main.qmd")),
single_file_text_deps: vec![PathBuf::from("part.qmd")],
..HubConfig::default()
};

let ctx = HubContext::new(storage, config).await.unwrap();

let pf = ctx.project_files().unwrap();
// The deck is the only nav qmd; the include is an invisible text dep.
assert_eq!(pf.qmd_files, vec![PathBuf::from("main.qmd")]);
assert_eq!(pf.text_dep_files, vec![PathBuf::from("part.qmd")]);

// Both reach the index (so both are in the VFS for include expansion).
let files = ctx.index().get_all_files();
assert!(
files.iter().any(|(f, _)| f.as_str() == "main.qmd"),
"deck missing from index: {files:?}"
);
assert!(
files.iter().any(|(f, _)| f.as_str() == "part.qmd"),
"included text dep missing from index: {files:?}"
);
}

/// Build an automerge text document with the given content.
fn text_doc(content: &str) -> Automerge {
let mut doc = Automerge::new();
Expand Down
80 changes: 76 additions & 4 deletions crates/quarto-hub/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ pub struct ProjectFiles {
/// post-processor can read them via `vfsReadFile` and inline them
/// as `srcdoc` — see `iframePostProcessor.ts`'s `readArtifactOrSource`.
pub resource_files: Vec<PathBuf>,

/// Single-file mode (bd-9cyza5vy): included `.qmd` files the deck pulls in
/// via `{{< include >}}` (transitively), project-root-relative.
///
/// These must be in the VFS for the WASM include-expansion stage to read
/// them, but they are **deliberately kept out of [`qmd_files`](Self::qmd_files)**
/// so they stay *invisible* VFS-only dependencies — single-file preview does
/// not surface included files as their own entries. Like
/// [`resource_files`](Self::resource_files), they ride the **text** sync path
/// ([`text_files`](Self::text_files)) into an automerge Text doc readable by
/// `vfsReadFile`. Resolved upstream in `quarto-preview` (which has the qmd
/// parser) via `config::resolve_single_file_deps` and injected through
/// [`with_text_deps`](Self::with_text_deps). Empty for project mode (the dir
/// walk finds them) and the `quarto hub` server.
pub text_dep_files: Vec<PathBuf>,
}

impl ProjectFiles {
Expand Down Expand Up @@ -211,6 +226,18 @@ impl ProjectFiles {
self
}

/// Attach included `.qmd` files (resolved by the caller from the deck's
/// transitive `{{< include >}}` closure) to be synced into the VFS as
/// **text**, without surfacing them in [`qmd_files`](Self::qmd_files).
/// Deduplicates and sorts for deterministic ordering. See
/// [`ProjectFiles::text_dep_files`]. (bd-9cyza5vy)
pub fn with_text_deps(mut self, mut text_dep_files: Vec<PathBuf>) -> Self {
text_dep_files.sort();
text_dep_files.dedup();
self.text_dep_files = text_dep_files;
self
}

/// Returns the total number of discovered files.
pub fn total_count(&self) -> usize {
self.qmd_files.len()
Expand All @@ -219,16 +246,18 @@ impl ProjectFiles {
+ self.extension_files.len()
+ self.source_files.len()
+ self.resource_files.len()
+ self.text_dep_files.len()
}

/// Returns the count of text files (config + qmd + extension text +
/// source files + resources-scoped `.html`).
/// source files + resources-scoped `.html` + single-file text deps).
pub fn text_file_count(&self) -> usize {
self.qmd_files.len()
+ self.config_files.len()
+ self.extension_files.len()
+ self.source_files.len()
+ self.resource_files.len()
+ self.text_dep_files.len()
}

/// Returns an iterator over all discovered file paths.
Expand All @@ -239,20 +268,23 @@ impl ProjectFiles {
.chain(self.extension_files.iter())
.chain(self.source_files.iter())
.chain(self.resource_files.iter())
.chain(self.text_dep_files.iter())
.chain(self.binary_files.iter())
}

/// Returns an iterator over text files only (config + qmd +
/// extension text + source files + resources-scoped `.html`).
/// Resources-scoped `.html` is text so the reconcile loop stores it
/// as an automerge Text doc readable by the SPA's `vfsReadFile`.
/// extension text + source files + resources-scoped `.html` +
/// single-file text deps). All ride the text sync path so the
/// reconcile loop stores each as an automerge Text doc readable by
/// the SPA's `vfsReadFile`.
pub fn text_files(&self) -> impl Iterator<Item = &PathBuf> {
self.config_files
.iter()
.chain(self.qmd_files.iter())
.chain(self.extension_files.iter())
.chain(self.source_files.iter())
.chain(self.resource_files.iter())
.chain(self.text_dep_files.iter())
}
}

Expand Down Expand Up @@ -703,6 +735,46 @@ mod tests {
assert_eq!(files.text_file_count(), 2);
}

#[test]
fn test_with_text_deps_syncs_as_text_but_not_in_qmd_files() {
// bd-9cyza5vy: included `.qmd` must reach the VFS via the TEXT sync path
// (so the WASM include-expansion stage can read them) but must NOT appear
// in `qmd_files` — they are invisible VFS-only dependencies.
let files = ProjectFiles::single_file(PathBuf::from("main.qmd")).with_text_deps(vec![
PathBuf::from("part.qmd"),
PathBuf::from("sub/inc.qmd"),
]);

// Deck stays the only nav qmd; included files are NOT surfaced there.
assert_eq!(files.qmd_files, vec![PathBuf::from("main.qmd")]);
assert!(!files.qmd_files.contains(&PathBuf::from("part.qmd")));

// ...but they DO flow through the text sync path.
let text: Vec<_> = files.text_files().collect();
assert!(text.contains(&&PathBuf::from("part.qmd")));
assert!(text.contains(&&PathBuf::from("sub/inc.qmd")));
let all: Vec<_> = files.all_files().collect();
assert!(all.contains(&&PathBuf::from("part.qmd")));
assert!(all.contains(&&PathBuf::from("sub/inc.qmd")));

// Counted in both totals (1 deck qmd + 2 text deps).
assert_eq!(files.text_file_count(), 3);
assert_eq!(files.total_count(), 3);
}

#[test]
fn test_with_text_deps_dedups_and_sorts() {
let files = ProjectFiles::default().with_text_deps(vec![
PathBuf::from("b/two.qmd"),
PathBuf::from("a/one.qmd"),
PathBuf::from("b/two.qmd"), // duplicate (diamond include)
]);
assert_eq!(
files.text_dep_files,
vec![PathBuf::from("a/one.qmd"), PathBuf::from("b/two.qmd")]
);
}

#[test]
fn test_with_resource_files_dedups_and_sorts() {
let files = ProjectFiles::default().with_resource_files(vec![
Expand Down
1 change: 1 addition & 0 deletions crates/quarto-hub/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ async fn main() -> anyhow::Result<()> {
resource_files: Vec::new(),
// Single-file asset sync is a preview-only concern (bd-kpuweafo).
single_file_assets: Vec::new(),
single_file_text_deps: Vec::new(),
auth_config,
allow_insecure_auth: args.allow_insecure_auth,
register_root_ws: true,
Expand Down
21 changes: 21 additions & 0 deletions crates/quarto-hub/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1242,10 +1242,31 @@ where
// an absolute target path. The project_root is the file's
// parent directory, so `project_root.join(rel)` is the file.
let single_file_abs = watch_single_file.as_ref().map(|rel| project_root.join(rel));
// bd-9cyza5vy: also watch the deck's resolved dependency closure
// (included `.qmd`, referenced images, sibling `_brand.yml`) so editing
// any of them re-renders — matching project mode. The closure is
// exactly what discovery synced; take every synced file except the deck
// itself (which `single_file_abs` already covers) and absolutize it the
// same way. Only the closure is watched — unrelated siblings are not,
// preserving the bd-tnm3k safety property.
let single_file_deps: Vec<std::path::PathBuf> = if single_file_abs.is_some() {
ctx_for_watch
.project_files()
.map(|pf| {
pf.all_files()
.filter(|rel| Some(rel.as_path()) != watch_single_file.as_deref())
.map(|rel| project_root.join(rel))
.collect()
})
.unwrap_or_default()
} else {
Vec::new()
};
let watch_config = WatchConfig {
debounce_ms: watch_debounce_ms,
filter: watch_filter,
single_file: single_file_abs,
single_file_deps,
};
match FileWatcher::new(&project_root, watch_config) {
Ok(watcher) => {
Expand Down
Loading
Loading