Skip to content

Commit dbd7ceb

Browse files
authored
Merge pull request #958 from quarto-dev/feature/bd-7328beha-url-space-config-paths
Resolve the reveal.js logo and listing leading-/ images in URL space (bd-7328beha, bd-epk8cmdl)
2 parents 62601b9 + 9d763d4 commit dbd7ceb

10 files changed

Lines changed: 425 additions & 77 deletions

File tree

‎claude-notes/designs/path-resolution-model.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,15 +206,16 @@ Update this table when adding keys or migrating sites.
206206
| `project/book/render_item.rs` (`find_inputs`) | `book.chapters` / `appendices` / `references` | (5) with `project_dir` as both bases (`_quarto.yml`-only keys); leading `/` = project root; stored project-relative (bd-lwvumpfk) |
207207
| `stage/stages/include_resolve.rs` (`read_include_file`) | `include-*` | (3) marking + (5) at the read (bd-lwvumpfk; was a bare `doc_dir.join` on the marked value) |
208208
| `transforms/format_css.rs`, `transforms/components_resolve.rs` + `components/config.rs` (`ComponentSource::File(ConfigPath)`) | `css`, `components[].src` | (3) marking + (5) at the consumer (bd-lwvumpfk) |
209+
| `revealjs/footer_logo.rs` (`resolve_logo`) | revealjs `logo` | (5) for the file, then the URL-space exit: `page_url_for` for the `src`, `ResourceCopyIntent` for the copy (bd-7328beha; tests `tests/integration/config_path_consumers.rs`) |
209210

210211
### VIOLATIONS (each tracked by a strand — do not fix one without checking its siblings)
211212

212213
| Site | Keys | Defect | Strand |
213214
|---|---|---|---|
214215
| ~~`filter_resolve.rs:255-273`~~ | `filters` | ~~same~~ **FIXED 2026-10-28** (bd-nqq41zgx): mechanism 5 | ~~bd-rdcvjy2s~~ (filters half) |
215216
| `project_resources.rs:705-722,827-836` | engine/filter-declared resources | doc-dir join — runtime output, not YAML; out of this class unless a key is added | bd-hjv5o scope |
216-
| `revealjs/footer_logo.rs:168-176` | revealjs `logo` | raw string emitted, no `page_url_for`, no copy (URL space) | bd-7328beha |
217-
| `project/listing/item.rs:285-291,431-465`, `project/listing/helpers.rs:70-73,96-102`, `transforms/listing_generate.rs:503-507` | front-matter `image`, `listing-item.image` | hand-written external-prefix list; `/` treated as external (URL space) | bd-epk8cmdl |
217+
| ~~`revealjs/footer_logo.rs`~~ | revealjs `logo` | ~~raw string emitted, no `page_url_for`, no copy (URL space)~~ **FIXED 2026-10-28**: `resolve_logo` = (5) for the file, then `page_url_for` + a `ResourceCopyIntent` (skipped in VFS-root mode) | ~~bd-7328beha~~ |
218+
| ~~`project/listing/item.rs`, `project/listing/helpers.rs`, `transforms/listing_generate.rs`, `listing/post_render_upgrade/substitute.rs`~~ | front-matter `image`, `listing-item.image`, sibling preview `src` | ~~hand-written external-prefix list; `/` treated as external (URL space)~~ **FIXED 2026-10-28**: one classifier (`is_remote_src` = `quarto_util::is_external_url`); a leading `/` rebases to the bare project-relative path at hydration, so relativization, the copy intent and the feed all see one spelling; `resolve_preview_url` anchors `/x` at the output root | ~~bd-epk8cmdl~~ |
218219
| ~~`pampa/src/citeproc_filter.rs:133,151`~~ | `csl`, `bibliography` | ~~**process-CWD** read (no base at all)~~ **FIXED 2026-09-24** (book-projects P2): declaration-site base dir threaded into `apply_citeproc_filter` from both call paths (`UserFiltersStage` = document dir; book single-file merge = first file-chapter's dir); both keys added to `FORMAT_PATH_KEYS` (`ExistenceSilent` — URLs stay scalar) | ~~bd-oqoozmtr~~ (closed) |
219220

220221
Related open strands: bd-nqq41zgx (the convergence plan,

‎crates/quarto-core/src/project/listing/config.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,9 +1126,9 @@ pub fn flatten_content_globs(meta: &ConfigValue) -> Vec<ListingContents> {
11261126
fn record_path_as_glob(value: &ConfigValue) -> Option<ListingContents> {
11271127
let path_value = value.get("path")?;
11281128
let raw = path_value.as_plain_text()?;
1129-
// `is_remote_src`, not `is_external_src`: a leading `/` is the
1130-
// project root and *is* a dependency (plan §D4). The resolver
1131-
// re-anchors it, exactly as it does for a `/`-anchored glob.
1129+
// A leading `/` is the project root and *is* a dependency (plan
1130+
// §D4). The resolver re-anchors it, exactly as it does for a
1131+
// `/`-anchored glob.
11321132
if super::helpers::is_remote_src(&raw)
11331133
|| crate::glob::has_metacharacters(&raw)
11341134
|| !is_markdown_document_path(&raw)

‎crates/quarto-core/src/project/listing/helpers.rs‎

Lines changed: 29 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -65,20 +65,24 @@ pub fn image_html(item: &ListingItem, listing: &Listing, host_dir: &str) -> Stri
6565
/// Convert a project-relative URL to a host-page-relative one.
6666
/// `host_dir` is the host page's project-relative directory
6767
/// (empty at the project root). Walks up out of the host dir with
68-
/// `..` segments when the target is not beneath it. Remote URLs,
69-
/// `data:` URIs, and root-absolute paths pass through unchanged.
68+
/// `..` segments when the target is not beneath it. Remote URLs and
69+
/// `data:` URIs pass through unchanged. A leading `/` is
70+
/// site-root-relative — the same project-relative path (decision 4
71+
/// of bd-root-relative-paths-design-fc5pvkcv; bd-epk8cmdl) — so it
72+
/// relativizes like the bare form.
7073
pub(crate) fn host_relative_url(project_relative: &str, host_dir: &str) -> String {
71-
if is_external_src(project_relative) {
74+
if quarto_util::is_external_url(project_relative) {
7275
return project_relative.to_string();
7376
}
74-
if host_dir.is_empty() {
75-
return project_relative.to_string();
76-
}
77-
let host_segs: Vec<&str> = host_dir.split('/').filter(|s| !s.is_empty()).collect();
77+
// Dropping empty segments also strips the site-root `/`.
7878
let target_segs: Vec<&str> = project_relative
7979
.split('/')
8080
.filter(|s| !s.is_empty())
8181
.collect();
82+
if host_dir.is_empty() {
83+
return target_segs.join("/");
84+
}
85+
let host_segs: Vec<&str> = host_dir.split('/').filter(|s| !s.is_empty()).collect();
8286
let common = host_segs
8387
.iter()
8488
.zip(target_segs.iter())
@@ -89,31 +93,19 @@ pub(crate) fn host_relative_url(project_relative: &str, host_dir: &str) -> Strin
8993
out.join("/")
9094
}
9195

92-
/// True for src values that name no file in the project tree:
93-
/// remote URLs, `data:` URIs, and root-absolute paths. Shared by
94-
/// the hydration rebase, host-relativization, and the copy-intent
95-
/// registration so they agree on what "local" means (bd-qv2lsab0).
96-
pub(crate) fn is_external_src(src: &str) -> bool {
97-
let lower = src.to_ascii_lowercase();
98-
lower.starts_with("http://")
99-
|| lower.starts_with("https://")
100-
|| lower.starts_with("data:")
101-
|| src.starts_with('/')
102-
}
103-
104-
/// True for src values that name somewhere outside this machine:
105-
/// remote URLs, protocol-relative URLs, and `data:` URIs.
96+
/// True for src values that name somewhere outside the project tree:
97+
/// remote URLs, protocol-relative URLs, and `data:` URIs — the
98+
/// workspace's one classifier, [`quarto_util::is_external_url`].
10699
///
107-
/// Narrower than [`is_external_src`] on purpose: a leading `/` is
108-
/// *external* for an image `src` but *project-root-relative* for a
109-
/// config-authored path like a listing record's `path:`
110-
/// (`claude-notes/designs/path-resolution-model.md`).
100+
/// A leading `/` is **not** external: in URL space it means the site
101+
/// root, i.e. the same project-relative path as the bare form, for an
102+
/// image `src` exactly as for a record's `path:` (decision 4 of
103+
/// bd-root-relative-paths-design-fc5pvkcv; bd-epk8cmdl). The hydration
104+
/// rebase, host-relativization, and the copy-intent registration all
105+
/// share this predicate so they agree on what "local" means
106+
/// (bd-qv2lsab0).
111107
pub(crate) fn is_remote_src(src: &str) -> bool {
112-
let lower = src.to_ascii_lowercase();
113-
lower.starts_with("http://")
114-
|| lower.starts_with("https://")
115-
|| lower.starts_with("data:")
116-
|| src.starts_with("//")
108+
quarto_util::is_external_url(src)
117109
}
118110

119111
/// Build the per-item `data-*` attributes the client-side list reads
@@ -641,7 +633,13 @@ mod tests {
641633
host_relative_url("https://example.com/x.png", "posts"),
642634
"https://example.com/x.png"
643635
);
644-
assert_eq!(host_relative_url("/abs.png", "posts"), "/abs.png");
636+
// A leading `/` is the site root: relativized like the bare form.
637+
assert_eq!(host_relative_url("/abs.png", "posts"), "../abs.png");
638+
assert_eq!(host_relative_url("/abs.png", ""), "abs.png");
639+
assert_eq!(
640+
host_relative_url("data:image/png;base64,AAAA", "posts"),
641+
"data:image/png;base64,AAAA"
642+
);
645643
}
646644

647645
/// A listing with the type defaults applied — the field types

‎crates/quarto-core/src/project/listing/item.rs‎

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -426,8 +426,8 @@ fn prose_value<'a>(li: &'a ListingItemInfo, key: &str) -> Option<&'a Inlines> {
426426

427427
/// Rebase a document-relative image path onto the document's
428428
/// project-relative directory, normalizing `.`/`..` segments.
429-
/// Absolute URLs, `data:` URIs, and root-absolute paths pass
430-
/// through unchanged.
429+
/// Absolute URLs and `data:` URIs pass through unchanged; a leading
430+
/// `/` anchors at the project root (see [`rebase_image_from_dir`]).
431431
fn rebase_image(src: &str, source_path: &Path) -> String {
432432
let dir = source_path
433433
.parent()
@@ -446,11 +446,21 @@ fn rebase_image(src: &str, source_path: &Path) -> String {
446446

447447
/// Rebase a relative image path onto a project-relative directory
448448
/// (`""` for the project root), normalizing `.`/`..` segments.
449-
/// Absolute URLs, `data:` URIs, and root-absolute paths pass through.
449+
/// Absolute URLs and `data:` URIs pass through. A leading `/` is
450+
/// site-root-relative (decision 4 of
451+
/// bd-root-relative-paths-design-fc5pvkcv; bd-epk8cmdl): it ignores
452+
/// `dir` and yields the bare project-relative path, so every later
453+
/// consumer (host-relativization, the copy intent, the RSS builder)
454+
/// sees one spelling. Anchoring at the root also keeps `..` from
455+
/// climbing above the project.
450456
pub(crate) fn rebase_image_from_dir(src: &str, dir: &str) -> String {
451-
if super::helpers::is_external_src(src) {
457+
if super::helpers::is_remote_src(src) {
452458
return src.to_string();
453459
}
460+
let (dir, src) = match src.strip_prefix('/') {
461+
Some(rooted) => ("", rooted),
462+
None => (dir, src),
463+
};
454464
let mut segments: Vec<&str> = dir.split('/').filter(|s| !s.is_empty()).collect();
455465
for seg in src.split('/') {
456466
match seg {
@@ -600,12 +610,12 @@ mod tests {
600610
}
601611

602612
#[test]
603-
fn hydration_image_passes_through_absolute_forms() {
613+
fn hydration_image_passes_through_external_forms() {
604614
for src in [
605615
"https://example.com/x.png",
606616
"http://example.com/x.png",
617+
"//cdn.example.com/x.png",
607618
"data:image/png;base64,AAAA",
608-
"/site-absolute.png",
609619
] {
610620
let li = ListingItemInfo {
611621
image: Some(src.to_string()),
@@ -616,6 +626,19 @@ mod tests {
616626
}
617627
}
618628

629+
// bd-epk8cmdl: a leading `/` is the site root, not an external
630+
// location — the item image becomes the bare project-relative
631+
// path, ignoring the document's directory.
632+
#[test]
633+
fn hydration_image_anchors_leading_slash_at_the_project_root() {
634+
let li = ListingItemInfo {
635+
image: Some("/site-absolute.png".to_string()),
636+
..ListingItemInfo::default()
637+
};
638+
let item = hydrate_item(&profile_with(li));
639+
assert_eq!(item.image.as_deref(), Some("site-absolute.png"));
640+
}
641+
619642
// 18. item_extra_present_in_binding (via hydrate_item — the
620643
// bridge to TemplateValue happens later in the render transform)
621644
#[test]
@@ -729,7 +752,12 @@ mod tests {
729752
rebase_image_from_dir("../shared/x.png", "a/b"),
730753
"a/shared/x.png"
731754
);
732-
assert_eq!(rebase_image_from_dir("/site.png", "posts"), "/site.png");
755+
assert_eq!(rebase_image_from_dir("/site.png", "posts"), "site.png");
756+
assert_eq!(
757+
rebase_image_from_dir("/../../etc/x.png", "posts"),
758+
"etc/x.png",
759+
"root-anchored `..` cannot climb above the project"
760+
);
733761
}
734762

735763
// ─── bd-listing-description-precedence-x4bh6w3m ─────────────────

‎crates/quarto-core/src/project/listing/post_render_upgrade/substitute.rs‎

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,8 @@ fn substitute_images(
253253

254254
let replacement = if let Some(pi) = preview {
255255
// Engine-rendered preview image found.
256-
let resolved = resolve_preview_url(host_path, &sibling_abs, &pi.src);
256+
let resolved =
257+
resolve_preview_url(host_path, &sibling_abs, &project.output_dir, &pi.src);
257258
build_thumbnail_img(&resolved, &pi, attrs)
258259
} else if inner.contains("<img") {
259260
// The envelope wraps the image L1 derived from the body
@@ -323,25 +324,33 @@ fn escape_attr(s: &str) -> String {
323324
/// - `host_path`: absolute path of the host page (rendered file
324325
/// currently being substituted).
325326
/// - `sibling_abs`: absolute path of the sibling output file.
327+
/// - `site_root`: absolute path of the output tree's root, the
328+
/// anchor for a site-root-relative src.
326329
/// - `preview_src`: the `src` attribute of the preview `<img>` in
327-
/// the sibling. May be relative (to sibling's dir), absolute
328-
/// path-style (`/leading-slash`), absolute URL (`http://...`),
330+
/// the sibling. May be relative (to sibling's dir), site-root
331+
/// relative (`/leading-slash`), absolute URL (`http://...`),
329332
/// or a `data:` URI.
330333
///
331334
/// Returns the URL to embed in the host page's `<img src=...>`.
332-
fn resolve_preview_url(host_path: &Path, sibling_abs: &Path, preview_src: &str) -> String {
335+
/// A leading `/` is site-root-relative (decision 4 of
336+
/// bd-root-relative-paths-design-fc5pvkcv; bd-epk8cmdl): the target
337+
/// is `site_root/<rest>` and it relativizes to the host page like a
338+
/// sibling-relative src, so it survives a deploy subpath.
339+
fn resolve_preview_url(
340+
host_path: &Path,
341+
sibling_abs: &Path,
342+
site_root: &Path,
343+
preview_src: &str,
344+
) -> String {
333345
if quarto_util::is_external_url(preview_src) {
334346
return preview_src.to_string();
335347
}
336348
let host_dir = host_path.parent().unwrap_or(Path::new(""));
337349
let sibling_dir = sibling_abs.parent().unwrap_or(Path::new(""));
338-
// For relative `preview_src`, the absolute target is
339-
// `sibling_dir/preview_src`. For `/leading-slash`, treat as
340-
// already-absolute path (rare in Q2 output; pass through).
341-
if preview_src.starts_with('/') {
342-
return preview_src.to_string();
343-
}
344-
let preview_abs = sibling_dir.join(preview_src);
350+
let preview_abs = match preview_src.strip_prefix('/') {
351+
Some(rooted) => site_root.join(rooted),
352+
None => sibling_dir.join(preview_src),
353+
};
345354
pathdiff::diff_paths(&preview_abs, host_dir).map_or_else(
346355
|| preview_src.to_string(),
347356
|p| {
@@ -445,16 +454,58 @@ mod tests {
445454
fn resolve_preview_url_passes_any_scheme_through() {
446455
let host = Path::new("/site/index.html");
447456
let sibling = Path::new("/site/posts/foo.html");
457+
let root = Path::new("/site");
448458
for src in [
449459
"positron://settings/x",
450460
"vscode://schemas/settings",
451461
"data:image/png;base64,AAAA",
452462
"https://example.com/x.png",
463+
"//cdn.example.com/x.png",
453464
] {
454-
assert_eq!(resolve_preview_url(host, sibling, src), src);
465+
assert_eq!(resolve_preview_url(host, sibling, root, src), src);
455466
}
456467
}
457468

469+
#[test]
470+
fn resolve_preview_url_relativizes_sibling_relative_src() {
471+
let root = Path::new("/site");
472+
let sibling = Path::new("/site/posts/foo.html");
473+
assert_eq!(
474+
resolve_preview_url(Path::new("/site/index.html"), sibling, root, "cover.png"),
475+
"posts/cover.png"
476+
);
477+
assert_eq!(
478+
resolve_preview_url(
479+
Path::new("/site/posts/index.html"),
480+
sibling,
481+
root,
482+
"cover.png"
483+
),
484+
"cover.png"
485+
);
486+
}
487+
488+
// bd-epk8cmdl: a leading `/` is the site root, relativized per
489+
// host page instead of passed through as a host-absolute link.
490+
#[test]
491+
fn resolve_preview_url_anchors_leading_slash_at_the_site_root() {
492+
let root = Path::new("/site");
493+
let sibling = Path::new("/site/posts/foo.html");
494+
assert_eq!(
495+
resolve_preview_url(Path::new("/site/index.html"), sibling, root, "/img/c.png"),
496+
"img/c.png"
497+
);
498+
assert_eq!(
499+
resolve_preview_url(
500+
Path::new("/site/posts/index.html"),
501+
sibling,
502+
root,
503+
"/img/c.png"
504+
),
505+
"../img/c.png"
506+
);
507+
}
508+
458509
/// Build a minimal ProjectContext rooted in a tempdir.
459510
fn make_project(output_dir: &Path) -> ProjectContext {
460511
ProjectContext {

0 commit comments

Comments
 (0)