diff --git a/claude-notes/designs/path-resolution-model.md b/claude-notes/designs/path-resolution-model.md index f0932384fb..7edcae7df2 100644 --- a/claude-notes/designs/path-resolution-model.md +++ b/claude-notes/designs/path-resolution-model.md @@ -103,11 +103,23 @@ Three provenance-correct mechanisms coexist today. New code should prefer not in the per-document `SourceContext` is **stale** since bd-x113wg9v / bd-fu16z22k / bd-r64mj1aa: `MetadataMergeStage` registers `_quarto.yml`, the profile overlays, every `_metadata.yml` - and every `_extension.yml` (`metadata_merge.rs:214-266`). Remaining - caveat: a value rebased by `rebase_fragment_paths` keeps its manifest - span while its string is already project-root-relative (bd-0x1znhgb; - the fix decided 2026-10-28 is the leading-`/` spelling, option (a) in - `claude-notes/plans/2026-10-28-config-path-late-binding.md` §4.4). + and every `_extension.yml` (`metadata_merge.rs:214-266`). The former + caveat that a value rebased by `rebase_fragment_paths` kept its + manifest span while its string was already project-root-relative is + **resolved** (bd-0x1znhgb, 2026-10-28): rebased `website.*` strings + carry the site-rooted `/…` spelling (self-describing in URL space), + and an extension's project fragment (`contributes.project`, + `contributes.metadata.project`) is a *virtual file* registered under + `config_paths::project_fragment_file_id` with the manifest's path and + bytes, so `config_paths::file_base_dir` — shared by + `resolve_metadata_path`, `declaring_dir` and the glob provenance — + resolves its values against the project root from the id alone, + while the manifest's other fragments keep the extension dir and + diagnostics still render into `_extension.yml`. (A provenance + wrapper on the value — `Generated` + invocation anchor — was tried + first and rejected: the published error-reporting crate reads + `start_offset()`, which is 0 for `Generated`, so every such + diagnostic lost its snippet.) 2. **`ConfigValueKind::Path` + `adjust_paths_to_project_root`** (`crates/quarto-core/src/project/mod.rs`; `adjust_paths_to_document_dir` until bd-okqrjh8v) — merge-time rewrite of `Path`-kind values from the diff --git a/claude-notes/plans/2026-10-28-config-path-late-binding.md b/claude-notes/plans/2026-10-28-config-path-late-binding.md index 30c56f48d2..c8a5f6de49 100644 --- a/claude-notes/plans/2026-10-28-config-path-late-binding.md +++ b/claude-notes/plans/2026-10-28-config-path-late-binding.md @@ -560,6 +560,56 @@ The `config-path-base` xtask lint with the blessed-module list Separate strands, after Phase 1 shows which rows still earn their place. +#### Phase 3 handoff (2026-10-28, branch `braid/bd-0x1znhgb-defer-metadata-path-resolution`, stacked on PR #958) + +- [x] 3.1 §4.4 option (a): `rebase_fragment_paths` spells a rebased + `FRAGMENT_SITE_PATH_PATTERNS` value (`website.favicon`, + `navbar.logo`, `navbar.logo-href`, `sidebar.logo`) as + `/_extensions//…` — self-describing in URL space; every + consumer (`resolve_metadata_path` → `resolve_to_project_root`, + `normalize_favicon_path`, `copy_navbar_logo`, + `resolve_href_for_html`) already strips the `/`. A file outside + the project keeps its OS-absolute form. Visible in `get-config`. +- [x] 3.2 The `_extension.yml` file-name rule in `resolve_metadata_path` + is gone. What replaces it is not a per-value recorded base (see + the note below) but a **virtual file**: the project fragment + (`contributes.project`, `contributes.metadata.project`) is + re-keyed at config time to `config_paths::project_fragment_file_id` + (`rekey_project_fragment`, called from `rebase_fragment_paths`), + and the merge stage / every config-source binder + (`config_sources::config_file_ids`, `register_config_source`, + `bind_config_source`, `project_resources`, `compile_theme_css`) + registers that id with the manifest's path and bytes. One rule, + `config_paths::file_base_dir(id, path, project_dir)`, gives the + project root for a fragment id and the file's directory otherwise; + `declaring_dir`, `resolve_metadata_path` and + `glob::provenance::BaseDirContext::base_dir_for` all call it. So a + fragment `href: index.qmd` (bd-74ktvzv7 finding 2) and a fragment + `listing.contents` glob anchor at the project root, while the + manifest's `contributes.metadata` / `contributes.formats` values + keep resolving against the extension directory. +- [x] 3.3 The Phase-2 half of bd-74ktvzv7 (`Path` values + doc-relativized then page-relativized) is moot since Phase 1b: + marked values are the project-root pivot and are never + document-relativized. +- [ ] 3.4 §4.5 mechanism-3 row review — not started; separate strand + when a row is measured to break a cache key or block a consumer. + +**Why not "carry the base on the value".** The first cut wrapped each +fragment leaf's `SourceInfo` in `Generated { by: config-base {base}, +from: [Invocation anchor = the authored span] }`. `resolve_byte_range` +and `root_file_id` follow the anchor, but the published +`quarto-error-reporting` renders a location from `start_offset()` / +`length()`, which are `0` for `Generated`, so three CLI tests lost their +`_extension.yml` snippet (`extension_config_spans`, +`render_scripts_cli`). `ConfigValue` has ~216 struct-literal sites, so a +new field was out too. The virtual-file id keeps every span `Original` +and needs no plumbing: the base is a function of the id. If the +source-map crate later gains a first-class resolution base (or +error-reporting learns to render anchored `Generated` spans), the +per-value form becomes available again; `file_base_dir` is the one +place to switch. + ### Not in scope Include-shortcode nested retargeting (bd-udrn0q47; markdown space), listing diff --git a/crates/quarto-core/src/config_paths.rs b/crates/quarto-core/src/config_paths.rs index 9302801cfd..eff2feb9c1 100644 --- a/crates/quarto-core/src/config_paths.rs +++ b/crates/quarto-core/src/config_paths.rs @@ -48,11 +48,22 @@ //! 4. An OS-absolute path (a Windows drive or UNC prefix; on Unix this //! case is already step 3) is used as written. //! 5. Otherwise the base is the declaring file's directory, recovered -//! from the value's `SourceInfo` ([`declaring_dir`]). When there is -//! no usable span (a programmatic or runtime-layer value, a file not -//! registered in the context, no context at all) the base is the -//! **document directory**, which is what every consumer assumed -//! before this module existed (plan decision 2, 2026-10-28). +//! from the value's `SourceInfo` ([`declaring_dir`]): the registered +//! file's directory, except for an extension manifest's **project +//! fragment** (`contributes.project`, `contributes.metadata.project`), +//! which is `_quarto.yml` content and resolves against the project +//! root. The fragment is a *virtual file* of its own: at config time +//! its spans are re-keyed to [`project_fragment_file_id`] +//! ([`rekey_project_fragment`]) and the merge registers that id with +//! the manifest's path and content, so diagnostics still render into +//! `_extension.yml` while [`file_base_dir`] knows the base from the +//! id alone (bd-0x1znhgb; before this `resolve_metadata_path` +//! special-cased the manifest's file name, bd-74ktvzv7, which was +//! wrong for the manifest's other fragments). When there is no usable +//! span (a programmatic or runtime-layer value, a file not registered +//! in the context, no context at all) the base is the **document +//! directory**, which is what every consumer assumed before this +//! module existed (plan decision 2, 2026-10-28). //! //! The result is normalized lexically (`.`/`..` collapsed) and never //! canonicalized: the file may not exist, and a missing-file diagnostic @@ -343,8 +354,77 @@ fn file(path: PathBuf) -> ResolvedConfigPath { ResolvedConfigPath::File(quarto_util::normalize_lexically(&path)) } -/// The directory of the file a value was written in, when `source` -/// points at a registered file with a usable (rooted) path. +/// The [`FileId`] of an extension manifest's **project fragment**: the +/// `contributes.project` / `contributes.metadata.project` content of +/// `manifest`, which is merged into the project config and means what +/// it would mean in `_quarto.yml`. +/// +/// The fragment is registered as a virtual file with the manifest's +/// path and content (so a diagnostic anchored in it renders a snippet +/// into `_extension.yml`), but under this id rather than the +/// manifest's own `quarto_yaml::file_id_for_filename`, so that +/// [`file_base_dir`] can tell the fragment's values (base: the project +/// root) from the manifest's `contributes.metadata` / +/// `contributes.formats` values (base: the extension directory). +pub fn project_fragment_file_id(manifest: &Path) -> FileId { + quarto_yaml::file_id_for_filename(&format!( + "{}#project", + quarto_util::to_forward_slashes(manifest) + )) +} + +/// Re-key every span in a project fragment from the manifest's own +/// id to [`project_fragment_file_id`]. Called once, when the fragment +/// is merged into the project config (`project/mod.rs`). +pub fn rekey_project_fragment(fragment: &mut ConfigValue, manifest: &Path) { + let from = quarto_yaml::file_id_for_filename(&manifest.to_string_lossy()); + let to = project_fragment_file_id(manifest); + if from == to { + return; + } + let map = move |id: FileId| if id == from { to } else { id }; + rekey_config_value(fragment, &map); +} + +fn rekey_config_value(value: &mut ConfigValue, map: &impl Fn(FileId) -> FileId) { + value.source_info.remap_file_ids(map); + match &mut value.value { + ConfigValueKind::Map(entries) => { + for entry in entries { + entry.key_source.remap_file_ids(map); + rekey_config_value(&mut entry.value, map); + } + } + ConfigValueKind::Array(items) => { + for item in items { + rekey_config_value(item, map); + } + } + _ => {} + } +} + +/// The directory the values of the registered file `id` (at `path`) +/// resolve against: the project root for a project fragment +/// ([`project_fragment_file_id`]), else the file's own directory. +/// +/// `None` when the path has no usable (rooted) directory — an +/// in-memory `` document, a bare file name. +pub fn file_base_dir(id: FileId, path: &str, project_dir: &Path) -> Option { + let path = Path::new(path); + if id == project_fragment_file_id(path) { + return Some(project_dir.to_path_buf()); + } + let dir = path.parent()?; + if dir.as_os_str().is_empty() || !quarto_util::is_rooted(dir) { + return None; + } + Some(dir.to_path_buf()) +} + +/// The directory a value resolves against, from the file its span +/// points into ([`file_base_dir`]): the file's directory, or the +/// project root for an extension's project fragment. /// /// Returns `None` for programmatic sentinels (`config-default`, /// `programmatic-config`, `unknown`), for spans with no single byte @@ -359,13 +439,9 @@ pub fn declaring_dir(source: &SourceInfo, ctx: &PathCtx<'_>) -> Option return None; } let (file_id, _, _) = source.resolve_byte_range()?; - let source_file = ctx.source_context?.get_file(FileId(file_id))?; - let path = Path::new(&source_file.path); - let dir = path.parent()?; - if dir.as_os_str().is_empty() || !quarto_util::is_rooted(dir) { - return None; - } - Some(dir.to_path_buf()) + let file_id = FileId(file_id); + let source_file = ctx.source_context?.get_file(file_id)?; + file_base_dir(file_id, &source_file.path, ctx.project_dir) } #[cfg(test)] @@ -520,6 +596,84 @@ mod tests { } } + /// bd-0x1znhgb: an extension manifest's project fragment is a + /// virtual file of its own — same path and bytes, its own id — whose + /// values resolve against the project root, while the manifest's + /// other fragments keep resolving against the extension directory. + #[test] + fn project_fragment_resolves_against_the_project_root() { + let f = fixture(); + let d = dirs("/project", "/project/docs/deep"); + let c = ctx(&f, &d); + let manifest = "/project/_extensions/x/_extension.yml"; + let plain = quarto_yaml::file_id_for_filename(manifest); + let fragment = project_fragment_file_id(Path::new(manifest)); + assert_ne!(plain, fragment); + let mut sc = f.sc.clone(); + sc.add_file_with_id(plain, manifest.to_string(), Some(String::new())); + sc.add_file_with_id(fragment, manifest.to_string(), Some(String::new())); + let c = PathCtx { + source_context: Some(&sc), + ..c + }; + assert_eq!( + resolve("index.qmd", span(fragment), &c), + ResolvedConfigPath::File(p("/project/index.qmd")), + "a project-fragment value means what it would in _quarto.yml" + ); + assert_eq!( + resolve("scripts/x.lua", span(plain), &c), + ResolvedConfigPath::File(p("/project/_extensions/x/scripts/x.lua")), + "a contributes.metadata value names a bundled file" + ); + } + + #[test] + fn rekey_project_fragment_moves_every_span_to_the_fragment_id() { + let manifest = Path::new("/project/_extensions/x/_extension.yml"); + let plain = quarto_yaml::file_id_for_filename(&manifest.to_string_lossy()); + let other = FileId(7); + let leaf = |id: FileId| ConfigValue::new_string("v", SourceInfo::original(id, 0, 1)); + let mut value = ConfigValue::new_map( + vec![ + quarto_pandoc_types::ConfigMapEntry { + key: "a".into(), + key_source: SourceInfo::original(plain, 0, 1), + value: leaf(plain), + }, + quarto_pandoc_types::ConfigMapEntry { + key: "b".into(), + key_source: SourceInfo::original(other, 0, 1), + value: ConfigValue { + value: ConfigValueKind::Array(vec![leaf(plain), leaf(other)]), + source_info: SourceInfo::original(plain, 0, 1), + merge_op: Default::default(), + }, + }, + ], + SourceInfo::original(plain, 0, 1), + ); + rekey_project_fragment(&mut value, manifest); + let fragment = project_fragment_file_id(manifest); + let id_of = |v: &ConfigValue| v.source_info.root_file_id().unwrap(); + assert_eq!(id_of(&value), fragment); + assert_eq!(id_of(value.get("a").unwrap()), fragment); + let ConfigValueKind::Map(entries) = &value.value else { + panic!("map") + }; + assert_eq!(entries[0].key_source.root_file_id(), Some(fragment)); + assert_eq!( + entries[1].key_source.root_file_id(), + Some(other), + "other files untouched" + ); + let ConfigValueKind::Array(items) = &value.get("b").unwrap().value else { + panic!("array") + }; + assert_eq!(id_of(&items[0]), fragment); + assert_eq!(id_of(&items[1]), other); + } + #[test] fn programmatic_sentinel_falls_back_to_the_document_dir() { let f = fixture(); diff --git a/crates/quarto-core/src/config_sources.rs b/crates/quarto-core/src/config_sources.rs index 4959f3ff28..86f5acdfb6 100644 --- a/crates/quarto-core/src/config_sources.rs +++ b/crates/quarto-core/src/config_sources.rs @@ -64,13 +64,24 @@ pub fn bind_config_source<'a>( bind_source_candidates( source_context, info, - candidates.into_iter().map(|p| { - let fid = quarto_yaml::file_id_for_filename(&p.to_string_lossy()); - (fid, p) - }), + candidates.into_iter().flat_map(config_file_ids), ) } +/// The ids a config file's spans may carry: its own +/// `quarto_yaml::file_id_for_filename`, and for an extension manifest +/// also its project fragment's +/// ([`crate::config_paths::project_fragment_file_id`], bd-0x1znhgb). +/// Each is paired with the path so a binder can register it. +pub fn config_file_ids(path: &Path) -> impl Iterator { + let own = quarto_yaml::file_id_for_filename(&path.to_string_lossy()); + let fragment = path + .file_name() + .is_some_and(|name| name == crate::project::EXTENSION_MANIFEST) + .then(|| crate::config_paths::project_fragment_file_id(path)); + std::iter::once((own, path)).chain(fragment.map(|id| (id, path))) +} + /// Generalization of [`bind_config_source`] for candidate lists that /// span **both** FileId schemes: quarto-yaml filename-hash ids for /// standalone config files, and dense parse-context ids for documents @@ -181,15 +192,22 @@ pub fn rebase_source_candidates<'a>( /// candidates by a diagnostic's resolved id). Returns `true` when the /// file was registered (or already present). pub fn register_config_source(source_context: &mut SourceContext, path: &Path) -> bool { - let name = path.to_string_lossy(); - let fid = quarto_yaml::file_id_for_filename(&name); - if source_context.get_file(fid).is_some() { + // Every id the file's spans may carry ([`config_file_ids`]): an + // extension manifest's project fragment is registered alongside the + // manifest itself, under the same path and content. + let ids: Vec = config_file_ids(path).map(|(id, _)| id).collect(); + if ids.iter().all(|id| source_context.get_file(*id).is_some()) { return true; } let Ok(content) = std::fs::read_to_string(path) else { return false; }; - source_context.add_file_with_id(fid, name.into_owned(), Some(content)); + let name = path.to_string_lossy().into_owned(); + for id in ids { + if source_context.get_file(id).is_none() { + source_context.add_file_with_id(id, name.clone(), Some(content.clone())); + } + } true } diff --git a/crates/quarto-core/src/glob/provenance.rs b/crates/quarto-core/src/glob/provenance.rs index 33316f14c4..e41582e5e1 100644 --- a/crates/quarto-core/src/glob/provenance.rs +++ b/crates/quarto-core/src/glob/provenance.rs @@ -18,6 +18,13 @@ //! - `blog/_metadata.yml` → `blog/`; //! - `_quarto.yml` → the project root. //! +//! An extension's project fragment (`contributes.project`, +//! `contributes.metadata.project`) is `_quarto.yml` content: it is +//! registered as a virtual file under +//! [`crate::config_paths::project_fragment_file_id`], and its patterns +//! anchor at the project root although the bytes live under +//! `_extensions/` ([`crate::config_paths::file_base_dir`], bd-0x1znhgb). +//! //! Values with no recoverable file — runtime `--metadata`, //! programmatic config, extension metadata — fall back to the //! caller-supplied directory. @@ -47,8 +54,14 @@ impl<'a> BaseDirContext<'a> { pub fn base_dir_for(&self, source: &SourceInfo) -> String { source .root_file_id() - .and_then(|id| self.source_context?.get_file(id)) - .and_then(|f| project_relative_dir_of(&f.path, self.project_dir)) + .and_then(|id| Some((id, self.source_context?.get_file(id)?))) + .and_then(|(id, f)| { + if id == crate::config_paths::project_fragment_file_id(Path::new(&f.path)) { + Some(String::new()) + } else { + project_relative_dir_of(&f.path, self.project_dir) + } + }) .unwrap_or_else(|| self.fallback_dir.to_string()) } } @@ -66,12 +79,18 @@ pub fn project_relative_dir_of(file_path: &str, project_dir: &Path) -> Option Option { + let dir = if quarto_util::is_rooted(dir) { + dir.strip_prefix(project_dir).ok()? } else { - path + dir }; - let dir = relative.parent().unwrap_or(Path::new("")); let mut segments: Vec<&str> = Vec::new(); for comp in dir.components() { match comp { @@ -146,6 +165,29 @@ mod tests { assert_eq!(ctx.base_dir_for(&source), "sub"); } + /// bd-0x1znhgb: an extension's project fragment anchors at the + /// project root; the manifest's own id anchors at the extension dir. + #[test] + fn project_fragment_anchors_at_the_project_root() { + let mut sc = SourceContext::new(); + sc.add_file("/proj/index.qmd".to_string(), None); + let path = "/proj/_extensions/x/_extension.yml"; + let manifest = quarto_yaml::file_id_for_filename(path); + let fragment = crate::config_paths::project_fragment_file_id(Path::new(path)); + sc.add_file_with_id(manifest, path.to_string(), None); + sc.add_file_with_id(fragment, path.to_string(), None); + let ctx = BaseDirContext { + source_context: Some(&sc), + project_dir: Path::new("/proj"), + fallback_dir: "sub", + }; + assert_eq!( + ctx.base_dir_for(&SourceInfo::original(manifest, 0, 10)), + "_extensions/x" + ); + assert_eq!(ctx.base_dir_for(&SourceInfo::original(fragment, 0, 10)), ""); + } + #[test] fn metadata_layer_resolves_to_its_own_dir() { let mut sc = SourceContext::new(); diff --git a/crates/quarto-core/src/project/mod.rs b/crates/quarto-core/src/project/mod.rs index 6374da8e32..33dbd8e100 100644 --- a/crates/quarto-core/src/project/mod.rs +++ b/crates/quarto-core/src/project/mod.rs @@ -776,15 +776,22 @@ const FRAGMENT_PATH_PATTERNS: &[&[&str]] = &[ &["brand"], ]; +/// File name of an extension manifest (`_extensions//_extension.yml`). +pub const EXTENSION_MANIFEST: &str = "_extension.yml"; + /// Site-level counterparts of [`FRAGMENT_PATH_PATTERNS`]: `website.*` -/// keys whose consumers read the value as project-root-relative and -/// relativize it per page themselves. +/// keys that live in URL space — their consumers read the value as +/// site-root-relative and relativize it per page themselves. /// /// Rebased leaves stay plain strings, exactly as if written in -/// `_quarto.yml`. A [`ConfigValueKind::Path`] would be adjusted to the -/// document dir by the per-document merge and then relativized again -/// per page, climbing one level too many for pages in subdirectories -/// (bd-74ktvzv7). +/// `_quarto.yml`, spelled with a leading `/` +/// (`/_extensions/x/assets/logo.svg`): in URL space a leading `/` +/// means the site root (decision 4 of +/// bd-root-relative-paths-design-fc5pvkcv), so the string says what +/// it is relative to and no consumer needs the value's provenance to +/// read it (bd-0x1znhgb, plan §4.4 option a). A file outside the +/// project (a built-in extension extracted to a temp dir) keeps its +/// OS-absolute form. const FRAGMENT_SITE_PATH_PATTERNS: &[&[&str]] = &[ &["website", "favicon"], &["website", "navbar", "logo"], @@ -793,13 +800,22 @@ const FRAGMENT_SITE_PATH_PATTERNS: &[&[&str]] = &[ ]; /// Rebase extension-bundled file references in a `contributes.project` -/// fragment from extension-dir-relative to project-root-relative. +/// fragment from extension-dir-relative to project-root-relative, and +/// record the project root as the fragment's resolution base. /// /// Rebased [`FRAGMENT_PATH_PATTERNS`] values become -/// [`ConfigValueKind::Path`] so the per-document metadata merge keeps -/// adjusting them (project root → document dir) for documents in -/// subdirectories; rebased [`FRAGMENT_SITE_PATH_PATTERNS`] values stay -/// project-root-relative strings. +/// [`ConfigValueKind::Path`] in pivot form (project-root-relative, +/// `crate::config_paths`); rebased [`FRAGMENT_SITE_PATH_PATTERNS`] +/// values stay strings in the site-rooted `/…` spelling. +/// +/// Everything else in the fragment keeps its manifest span, re-keyed +/// to the fragment's own virtual-file id +/// ([`crate::config_paths::rekey_project_fragment`]): the fragment is +/// `_quarto.yml` content, so an `href: index.qmd` in it names the +/// project page, not `_extensions//index.qmd`, and the consumers +/// (`resolve_metadata_path`, `config_paths::declaring_dir`, the glob +/// provenance) resolve it against the project root while diagnostics +/// still render into `_extension.yml` (bd-0x1znhgb). /// /// The pattern walk and the bundled-file existence check are shared /// with the `contributes.formats` marking (bd-of20unsb) via @@ -833,10 +849,23 @@ fn rebase_fragment_paths( } = &mut leaf.value && let Some(rebased) = rebase_candidate(s, ext_dir, project_dir, runtime) { - *s = rebased; + *s = site_rooted_spelling(rebased); } }, ); + crate::config_paths::rekey_project_fragment(fragment, &ext_dir.join(EXTENSION_MANIFEST)); +} + +/// The URL-space spelling of a rebased project-relative path: a +/// leading `/` for the site root. An OS-absolute path (a file outside +/// the project) is left alone. +fn site_rooted_spelling(rebased: String) -> String { + let path = Path::new(&rebased); + if quarto_util::is_rooted(path) || path.is_absolute() { + rebased + } else { + format!("/{rebased}") + } } /// Decide whether `s` names a file bundled with the extension, and if diff --git a/crates/quarto-core/src/project_resources.rs b/crates/quarto-core/src/project_resources.rs index 449ca9e33e..a3e3b17b61 100644 --- a/crates/quarto-core/src/project_resources.rs +++ b/crates/quarto-core/src/project_resources.rs @@ -1018,7 +1018,7 @@ pub fn resource_error_to_doc_parse_error( config .extension_manifest_paths .iter() - .map(|p| (hash(p), p.as_path())), + .flat_map(|p| crate::config_sources::config_file_ids(p)), ) .chain(layer_paths.iter().map(|p| (hash(p), p.as_path()))); let matched = crate::config_sources::bind_source_candidates( diff --git a/crates/quarto-core/src/stage/stages/compile_theme_css.rs b/crates/quarto-core/src/stage/stages/compile_theme_css.rs index 76901e85be..574bf15ca0 100644 --- a/crates/quarto-core/src/stage/stages/compile_theme_css.rs +++ b/crates/quarto-core/src/stage/stages/compile_theme_css.rs @@ -1175,7 +1175,9 @@ fn theme_error_candidates( candidates.push((hash(p), p.clone())); } for p in &ctx.project.config.extension_manifest_paths { - candidates.push((hash(p), p.clone())); + for (id, path) in crate::config_sources::config_file_ids(p) { + candidates.push((id, path.to_path_buf())); + } } for p in crate::project::directory_metadata_paths_for_document( &ctx.project, diff --git a/crates/quarto-core/src/stage/stages/metadata_merge.rs b/crates/quarto-core/src/stage/stages/metadata_merge.rs index 538d4e9494..1b4e243b60 100644 --- a/crates/quarto-core/src/stage/stages/metadata_merge.rs +++ b/crates/quarto-core/src/stage/stages/metadata_merge.rs @@ -215,9 +215,8 @@ impl PipelineStage for MetadataMergeStage { // and appending to only one would desynchronize the shared // index space. { - let mut register = |path: &std::path::Path| { + let mut register_as = |id: quarto_source_map::FileId, path: &std::path::Path| { let path_str = path.to_string_lossy().to_string(); - let id = quarto_yaml::file_id_for_filename(&path_str); let content = ctx.runtime.file_read_string(path).ok(); for source_context in [&mut doc.ast_context.source_context, &mut doc.source_context] { @@ -230,6 +229,12 @@ impl PipelineStage for MetadataMergeStage { } } }; + let mut register = |path: &std::path::Path| { + register_as( + quarto_yaml::file_id_for_filename(&path.to_string_lossy()), + path, + ); + }; if let Some(config_path) = ctx.project.config.config_path.as_deref() { register(config_path); } @@ -254,6 +259,12 @@ impl PipelineStage for MetadataMergeStage { for path in &ctx.project.config.extension_manifest_paths { register(path); } + // Each manifest's project fragment is a virtual file of its + // own — same path and bytes, its own id — so that its values + // resolve against the project root (bd-0x1znhgb). + for path in &ctx.project.config.extension_manifest_paths { + register_as(crate::config_paths::project_fragment_file_id(path), path); + } } // Directory-metadata and front-matter path-shaped entries diff --git a/crates/quarto-core/src/transforms/navigation_href.rs b/crates/quarto-core/src/transforms/navigation_href.rs index 4ae043fa5d..6e7711aa11 100644 --- a/crates/quarto-core/src/transforms/navigation_href.rs +++ b/crates/quarto-core/src/transforms/navigation_href.rs @@ -773,9 +773,13 @@ pub fn rewrite_config_inlines( /// - The source file's path can't be expressed relative to `project_root` /// (file lives outside the project). /// -/// Values authored in an extension manifest (`_extension.yml`) resolve -/// against the project root, as if written in `_quarto.yml` -/// (bd-74ktvzv7). +/// The base is the span's file's directory as +/// [`crate::config_paths::file_base_dir`] defines it: for an +/// extension's project fragment (registered under +/// [`crate::config_paths::project_fragment_file_id`]) that is the +/// project root, so fragment values resolve as if written in +/// `_quarto.yml` (bd-74ktvzv7 → bd-0x1znhgb; the manifest's file name +/// is no longer consulted). pub fn resolve_metadata_path( raw: &str, source: &SourceInfo, @@ -801,7 +805,8 @@ pub fn resolve_metadata_path( // Concat / FilterProvenance — no single contiguous range. return raw.to_string(); }; - let Some(source_file) = source_context.get_file(FileId(file_id_val)) else { + let file_id = FileId(file_id_val); + let Some(source_file) = source_context.get_file(file_id) else { // Source file not registered in this document's SourceContext. // Most commonly: a `_quarto.yml` value whose hash-based FileId // isn't in the per-doc context. Such paths are already @@ -809,9 +814,15 @@ pub fn resolve_metadata_path( // correct. return raw.to_string(); }; - let source_path = Path::new(&source_file.path); - // Express the source file's directory relative to project_root. - let source_dir = source_path.parent().unwrap_or_else(|| Path::new("")); + // The file's directory, or the project root for an extension's + // project fragment. An unrooted registered path (an in-memory + // document) has no usable base: keep the raw href. + let Some(source_dir) = + crate::config_paths::file_base_dir(file_id, &source_file.path, project_root) + else { + return raw.to_string(); + }; + // Express the base directory relative to project_root. let rel_dir = match source_dir.strip_prefix(project_root) { Ok(rel) => rel, Err(_) => { @@ -821,20 +832,6 @@ pub fn resolve_metadata_path( return raw.to_string(); } }; - // Extension manifests inside the project (bd-74ktvzv7): values from - // `contributes.project` / `metadata` / `formats` mean what they - // would in `_quarto.yml`. Bundled files were already rebased to - // `_extensions//…` at config time (keeping their manifest - // span for diagnostics), so resolving against the manifest's - // directory would prepend the extension dir a second time. - // TODO(bd-0x1znhgb): carry the resolution base with the value - // instead of re-deriving it from the source span. - if source_path - .file_name() - .is_some_and(|name| name == "_extension.yml") - { - return resolve_to_project_root("_", raw); - } let rel_dir_str = rel_dir.to_string_lossy().replace('\\', "/"); // Use the same path-normalizer the body-link helper uses so `..` // and leading `/` behaviour stays consistent across navigation @@ -1167,15 +1164,20 @@ mod tests { ); } - /// bd-74ktvzv7 — values authored in an extension manifest - /// (`contributes.project` / `metadata` / `formats`) are - /// project-root-relative, not relative to the extension dir: - /// bundled files were already rebased to `_extensions//…` at - /// config time, and other hrefs mean project pages (Q1 parity). + /// bd-74ktvzv7 / bd-0x1znhgb — values from an extension's project + /// fragment (registered as a virtual file under + /// `project_fragment_file_id`) resolve as if written in + /// `_quarto.yml`: bundled files were already rebased to + /// `_extensions//…` at config time, and other hrefs mean + /// project pages (Q1 parity). #[test] - fn metadata_path_extension_manifest_is_project_root_relative() { + fn metadata_path_project_fragment_is_project_root_relative() { let root = PathBuf::from("/project"); - let (ctx, src) = source_for("_extensions/acme/x/_extension.yml", &root); + let manifest = root.join("_extensions/acme/x/_extension.yml"); + let mut ctx = SourceContext::new(); + let fragment = crate::config_paths::project_fragment_file_id(&manifest); + ctx.add_file_with_id(fragment, manifest.to_string_lossy().into_owned(), None); + let src = SourceInfo::original(fragment, 0, 0); assert_eq!( resolve_metadata_path("_extensions/acme/x/assets/logo.svg", &src, &ctx, &root), "_extensions/acme/x/assets/logo.svg" @@ -1190,6 +1192,20 @@ mod tests { ); } + /// The manifest's own id buys nothing: a value written in a file + /// named `_extension.yml` resolves against that file's directory + /// like any other (the file-name rule of bd-74ktvzv7 is gone; the + /// manifest's `contributes.metadata` values do name bundled files). + #[test] + fn metadata_path_manifest_itself_resolves_against_its_dir() { + let root = PathBuf::from("/project"); + let (ctx, src) = source_for("_extensions/acme/x/_extension.yml", &root); + assert_eq!( + resolve_metadata_path("assets/logo.svg", &src, &ctx, &root), + "_extensions/acme/x/assets/logo.svg" + ); + } + /// Leading `/` in the href is treated as project-root-relative /// (Q1 parity), regardless of where the source lives. #[test] diff --git a/crates/quarto-core/tests/integration/custom_project_type.rs b/crates/quarto-core/tests/integration/custom_project_type.rs index 4a75f25771..dfaff98559 100644 --- a/crates/quarto-core/tests/integration/custom_project_type.rs +++ b/crates/quarto-core/tests/integration/custom_project_type.rs @@ -534,22 +534,22 @@ fn meta_at<'a>( #[test] fn extension_favicon_and_logo_rebased_to_project_root() { let (project, _tmp) = themed_project(); + // Site-rooted spelling (bd-0x1znhgb): a leading `/` is the site + // root in URL space, so the string is self-describing. assert_eq!( meta_at(&project, &["website", "favicon"]).and_then(|v| v.as_str()), - Some("_extensions/acme/fancysite/assets/favicon.svg") + Some("/_extensions/acme/fancysite/assets/favicon.svg") ); assert_eq!( meta_at(&project, &["website", "navbar", "logo"]).and_then(|v| v.as_str()), - Some("_extensions/acme/fancysite/assets/logo.svg") + Some("/_extensions/acme/fancysite/assets/logo.svg") ); assert_eq!( meta_at(&project, &["website", "navbar", "logo-href"]).and_then(|v| v.as_str()), - Some("_extensions/acme/fancysite/assets/logo.svg") + Some("/_extensions/acme/fancysite/assets/logo.svg") ); // Site-level values stay plain strings, as if written in - // `_quarto.yml`: their consumers relativize per page, so a Path - // kind (document-dir-adjusted by metadata_merge) would climb one - // level too many in subdirectories (bd-74ktvzv7). + // `_quarto.yml`: their consumers relativize per page. for key in [ &["website", "favicon"][..], &["website", "navbar", "logo"], diff --git a/crates/quarto-core/tests/integration/metadata_path_resolution.rs b/crates/quarto-core/tests/integration/metadata_path_resolution.rs index ac97c49071..1e28225362 100644 --- a/crates/quarto-core/tests/integration/metadata_path_resolution.rs +++ b/crates/quarto-core/tests/integration/metadata_path_resolution.rs @@ -412,6 +412,31 @@ fn extension_fragment_navbar_href_resolves_against_project_root() { assert!(missing.is_empty(), "unexpected Q-13-2: {missing:?}"); } +/// bd-0x1znhgb: a fragment value that is *not* a bundled file resolves +/// against the project root for globs too — a `project.resources` +/// pattern contributed by an extension names project files, exactly as +/// it would in `_quarto.yml`, not files under `_extensions//`. +#[test] +fn extension_fragment_resource_glob_anchors_at_project_root() { + let (dir, _outputs, diags) = render_project(|project_dir| { + write( + &project_dir.join("_extensions/x/_extension.yml"), + "title: X\nauthor: Test\nversion: 1.0.0\ncontributes:\n project:\n project:\n type: website\n output-dir: _site\n resources:\n - \"data/*.csv\"\n", + ); + write(&project_dir.join("_quarto.yml"), "project:\n type: x\n"); + write( + &project_dir.join("index.qmd"), + "---\ntitle: Home\n---\n\nH.\n", + ); + write(&project_dir.join("data/table.csv"), "a,b\n1,2\n"); + }); + assert!( + dir.join("_site/data/table.csv").is_file(), + "a fragment `resources:` glob must anchor at the project root; diagnostics: {:?}", + diags.iter().map(|d| &d.title).collect::>() + ); +} + // ----- helpers ------------------------------------------------------------ fn snippet<'a>(html: &'a str, needle: &str) -> &'a str { diff --git a/docs/guides/authoring/extensions.qmd b/docs/guides/authoring/extensions.qmd index 40e9146774..1748a62a2b 100644 --- a/docs/guides/authoring/extensions.qmd +++ b/docs/guides/authoring/extensions.qmd @@ -121,6 +121,8 @@ Files the extension ships next to its manifest — SCSS themes, CSS, header incl A path that does not name a bundled file keeps its `_quarto.yml` meaning: it is relative to the project root. A navbar or sidebar entry with `href: index.qmd` links to the project's own `index.qmd`, not to a file inside the extension. +In the merged configuration (`q2 get-config`), a bundled site asset such as `website.favicon` or `website.navbar.logo` appears in site-root form, `/_extensions/acme/fancy-docs/assets/logo.svg`; bundled files under `format:` appear as project-root-relative `!path` values. + The same resolution applies to format contributions (`contributes: formats:`): a format extension can bundle theme SCSS, CSS, or header includes next to its manifest and reference them by bare name. A `theme:` entry that names a `.scss`/`.css` file Quarto cannot find — bundled or otherwise — fails the render with [Q-14-4](../../errors/theme/Q-14-4.qmd). ### Errors you might see