From b7bfeef82aa2802297560f5d1422e843d7f8e25a Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Fri, 14 Aug 2026 15:08:30 -0500 Subject: [PATCH 1/2] llms.txt header: use resolved site title/description (bd-6m1iyxl6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A website.title written with qmd markup (raw HTML inlines, shortcodes) leaked its literal syntax into the llms.txt H1 — the assembler read the raw project config, while the browser reads the merged page metadata where ConfigMarkdownTransform has inline-parsed the string and ShortcodeResolveTransform has resolved it. Fix in two halves: - LlmsCaptureTransform stashes the flattened website.title/description from the merged page metadata as Project artifacts; the llms.txt assembler prefers them (falling back to raw config for e.g. all-draft renders). Same data the <title> renders, so no new parsing and no drift. - website.description joins MARKDOWN_CONFIG_PATHS (it is presentation text exactly like website.title), so its markup flattens and its shortcodes resolve too. Connect docs verified end-to-end: the H1 is now '# Connect Documentation Version development' (was raw backtick/ {=html}/shortcode soup), matching the browser title byte-for-byte. Tests: llms_txt_title_flattens_markup_and_resolves_shortcodes (e2e, written first and observed red), website_description_scalar_becomes_ inlines (unit). Full workspace suite (12,143) + verify green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .../src/project/llms_post_render.rs | 30 +++++++++++-- .../src/transforms/config_markdown.rs | 36 ++++++++++++++++ crates/quarto-core/src/transforms/llms.rs | 32 ++++++++++++++ .../quarto-core/tests/integration/llms_txt.rs | 43 +++++++++++++++++++ 4 files changed, 137 insertions(+), 4 deletions(-) diff --git a/crates/quarto-core/src/project/llms_post_render.rs b/crates/quarto-core/src/project/llms_post_render.rs index f9253a1e0..c37d35583 100644 --- a/crates/quarto-core/src/project/llms_post_render.rs +++ b/crates/quarto-core/src/project/llms_post_render.rs @@ -182,8 +182,29 @@ pub(super) fn write_llms_artifacts( // ── Assemble. ───────────────────────────────────────────────── let site_url = website_site_url(meta).map(|u| u.trim_end_matches('/').to_string()); - let (llms_txt, reading_order) = - assemble_llms_txt(meta, index, &pages, site_url.as_deref(), diagnostics); + // Site title / description: prefer the resolved values the + // capture transform stashed from the merged page metadata + // (inline-parsed, shortcodes resolved — bd-6m1iyxl6); fall back + // to the raw project config for e.g. all-draft renders where no + // page captured. + let site_title = project_artifacts + .get(crate::transforms::llms::LLMS_SITE_TITLE_KEY) + .map(|a| a.as_string()) + .or_else(|| website_title(meta)) + .unwrap_or_else(|| "Untitled".to_string()); + let site_description = project_artifacts + .get(crate::transforms::llms::LLMS_SITE_DESCRIPTION_KEY) + .map(|a| a.as_string()) + .or_else(|| website_description(meta)); + let (llms_txt, reading_order) = assemble_llms_txt( + meta, + index, + &pages, + &site_title, + site_description.as_deref(), + site_url.as_deref(), + diagnostics, + ); let llms_full = assemble_llms_full(&pages, &reading_order, &contents, site_url.as_deref()); // ── Write. ──────────────────────────────────────────────────── @@ -323,6 +344,8 @@ fn assemble_llms_txt( meta: &ConfigValue, index: &ProjectIndex, pages: &[LlmsPage], + site_title: &str, + site_description: Option<&str>, site_url: Option<&str>, diagnostics: &mut Vec<DiagnosticMessage>, ) -> (String, Vec<String>) { @@ -445,11 +468,10 @@ fn assemble_llms_txt( } // ── Emit. ───────────────────────────────────────────────────── - let site_title = website_title(meta).unwrap_or_else(|| "Untitled".to_string()); let mut out = String::new(); let mut reading_order: Vec<String> = Vec::new(); out.push_str(&format!("# {site_title}\n")); - if let Some(desc) = website_description(meta).map(|d| d.trim().to_string()) + if let Some(desc) = site_description.map(str::trim) && !desc.is_empty() { out.push('\n'); diff --git a/crates/quarto-core/src/transforms/config_markdown.rs b/crates/quarto-core/src/transforms/config_markdown.rs index de076fd0d..35943da37 100644 --- a/crates/quarto-core/src/transforms/config_markdown.rs +++ b/crates/quarto-core/src/transforms/config_markdown.rs @@ -105,6 +105,11 @@ const CODE_CONFIG_NESTING_TOO_DEEP: &str = "Q-1-27"; /// consumption time). const MARKDOWN_CONFIG_PATHS: &[&[&str]] = &[ &["website", "title"], + // Site description: presentation text like the title — consumed + // by the llms.txt header (bd-6m1iyxl6), which needs raw inlines + // dropped and shortcodes resolved the same way the browser + // `<title>` path resolves `website.title`. + &["website", "description"], &["website", "navbar", "title"], &["navbar", "title"], &["website", "sidebar", "title"], @@ -310,6 +315,37 @@ mod tests { &meta.get_path(path).expect("path exists").value } + /// `website.description` is presentation text like the title + /// (bd-6m1iyxl6): a scalar string becomes PandocInlines so the + /// llms.txt header flattens raw markup away and resolves + /// shortcodes. + #[test] + fn website_description_scalar_becomes_inlines() { + let mut meta = map(vec![( + "website", + map(vec![("description", s("Docs for *My Site*"))]), + )]); + let mut diags = Vec::new(); + apply_markdown_config_paths(&mut meta, &mut diags); + + assert!( + matches!( + kind_at(&meta, &["website", "description"]), + ConfigValueKind::PandocInlines(_) + ), + "expected PandocInlines, got {:?}", + kind_at(&meta, &["website", "description"]) + ); + assert_eq!( + meta.get_path(&["website", "description"]) + .unwrap() + .as_plain_text() + .as_deref(), + Some("Docs for My Site"), + "emphasis flattens to plain text" + ); + } + /// `website.title` scalar string becomes PandocInlines; a shortcode /// in it becomes a live `Inline::Shortcode` node. #[test] diff --git a/crates/quarto-core/src/transforms/llms.rs b/crates/quarto-core/src/transforms/llms.rs index 3fb62d575..f77d15b5e 100644 --- a/crates/quarto-core/src/transforms/llms.rs +++ b/crates/quarto-core/src/transforms/llms.rs @@ -87,6 +87,18 @@ pub const LLMS_FORMAT: &str = "llms"; /// is `llms-md/<output_href with .html → .md>`. pub const LLMS_ARTIFACT_PREFIX: &str = "llms-md/"; +/// Artifact keys for the *resolved* site title / description +/// (bd-6m1iyxl6). The raw `_quarto.yml` strings may carry qmd markup +/// (raw HTML inlines, shortcodes) that only the page pipeline knows +/// how to parse and resolve — the browser `<title>` reads +/// `website.title` from the merged page metadata *after* shortcode +/// resolution, and the llms.txt header must use the same data. The +/// capture transform stores the flattened values here; the +/// post-render assembler prefers them over re-reading the raw +/// project config. +pub const LLMS_SITE_TITLE_KEY: &str = "llms-site-title"; +pub const LLMS_SITE_DESCRIPTION_KEY: &str = "llms-site-description"; + /// Output href of the conventional 404 page, excluded from the /// companion set and the index. pub const HREF_404: &str = "404.html"; @@ -159,6 +171,26 @@ impl AstTransform for LlmsCaptureTransform { let index = ctx.project_index.clone(); let capture = capture_target(index.as_deref(), ctx); + // Stash the *resolved* site title / description for the + // llms.txt header (bd-6m1iyxl6): in the merged page metadata + // these are inline-parsed with shortcodes already resolved — + // the same data the browser `<title>` renders — while the raw + // project config the post-render assembler could read may + // still carry literal qmd markup. Every page stores the same + // values; last write wins. + if let Some(title) = crate::project::website_config::website_title(&ast.meta) { + ctx.artifacts.store( + LLMS_SITE_TITLE_KEY, + Artifact::from_string(title, "text/plain").with_scope(ArtifactScope::Project), + ); + } + if let Some(desc) = crate::project::website_config::website_description(&ast.meta) { + ctx.artifacts.store( + LLMS_SITE_DESCRIPTION_KEY, + Artifact::from_string(desc, "text/plain").with_scope(ArtifactScope::Project), + ); + } + if let Some((md_href, cur_dir)) = capture { let mut view = build_llms_view( ast.blocks.clone(), diff --git a/crates/quarto-core/tests/integration/llms_txt.rs b/crates/quarto-core/tests/integration/llms_txt.rs index d400d91ce..6837d627f 100644 --- a/crates/quarto-core/tests/integration/llms_txt.rs +++ b/crates/quarto-core/tests/integration/llms_txt.rs @@ -205,6 +205,49 @@ fn llms_txt_emitted_with_sidebar_sections() { assert_not_contains(&llms, ".html", "index links .md companions only"); } +/// A `website.title` / `website.description` written with qmd markup +/// — raw HTML inlines and shortcodes — flattens cleanly into the +/// llms.txt header: raws dropped, shortcodes resolved, formatting +/// reduced to its text (bd-6m1iyxl6). The reference behavior is the +/// browser `<title>`, which already renders this title clean; the +/// index header must use the same resolved data. +#[test] +fn llms_txt_title_flattens_markup_and_resolves_shortcodes() { + let (project_dir, _summary) = render_project(|dir| { + write( + &dir.join("_quarto.yml"), + "project:\n type: website\n output-dir: _site\n\ + sitever: \"9.9\"\n\ + website:\n\ + \x20 title: \"My Site `<small>`{=html}v{{< meta sitever >}}`</small>`{=html}\"\n\ + \x20 description: \"Docs for *My Site*\"\n\ + \x20 llms-txt: true\n", + ); + write(&dir.join("index.qmd"), "---\ntitle: Home\n---\n\nHi.\n"); + }); + + let llms = read(&project_dir.join("_site/llms.txt")); + assert_contains(&llms, "# My Site v9.9\n", "clean resolved title"); + assert_contains( + &llms, + "> Docs for My Site\n", + "description formatting flattened", + ); + assert_not_contains(&llms, "{=html}", "no raw-inline syntax in llms.txt header"); + assert_not_contains(&llms, "<small>", "no raw HTML in llms.txt header"); + assert_not_contains(&llms, "{{<", "no shortcode syntax in llms.txt header"); + + // llms-full.txt headers share the same site data path only for + // page titles; the site title appears nowhere there — but the + // companions must be equally free of leaked site-title syntax. + let full = read(&project_dir.join("_site/llms-full.txt")); + assert_not_contains( + &full, + "{=html}", + "no raw-inline syntax leaks into llms-full", + ); +} + /// With `site-url` set, index hrefs are absolute. #[test] fn llms_txt_absolute_urls_with_site_url() { From 0ff7d7953748ee50d343ebc95c28ab45dfc7f19c Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger <carlos.scheidegger@posit.co> Date: Fri, 14 Aug 2026 15:32:51 -0500 Subject: [PATCH 2/2] llms companions: synthesize clean item lists for listing pages (bd-5w81o2dh) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Characterization first: L7 placeholder envelopes are raw-HTML comments, which the llms view already drops — no envelope syntax leaked. What the companion carried instead was the rendered listing DOM as fenced-div soup (thumbnail wrappers with empty links, .metadata spans, .listing-* chrome), and descriptions diverged from the L7-upgraded HTML. Fix: LlmsCaptureTransform now recognizes a rendered listing container (div id matching a ResolvedListing from ctx.resolved_listings) and replaces its contents with a synthesized markdown list: * [Title](href) (date, author): description with page-relative links retargeted to .md companions, items in listing order, and the id kept on a minimal wrapper (float-anchor policy). Custom-template listings whose container id matches still synthesize; containers with no matching resolved listing fall through to normal cleanup. Tests: llms_listing_page_companion_synthesizes_item_list (e2e, written first and observed red against the div-soup output) + relativize unit test. Full workspace suite (12,144) + verify --skip-hub-build green; real-binary render inspected (companion is the clean list; zero chrome in llms-full.txt; HTML keeps its full listing DOM). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- crates/quarto-core/src/transforms/llms.rs | 107 ++++++++++++++++++ .../quarto-core/tests/integration/llms_txt.rs | 71 ++++++++++++ 2 files changed, 178 insertions(+) diff --git a/crates/quarto-core/src/transforms/llms.rs b/crates/quarto-core/src/transforms/llms.rs index f77d15b5e..47df601e2 100644 --- a/crates/quarto-core/src/transforms/llms.rs +++ b/crates/quarto-core/src/transforms/llms.rs @@ -197,6 +197,7 @@ impl AstTransform for LlmsCaptureTransform { &ViewContext { index: index.as_deref(), cur_dir, + listings: &ctx.resolved_listings, }, ); // The HTML `<h1>` title comes from the template, not the @@ -285,6 +286,11 @@ struct ViewContext<'a> { /// Directory prefix of the current page's output href /// (forward-slash, no trailing slash; `""` at the site root). cur_dir: String, + /// Resolved listings for this page (`ListingGenerateTransform` + /// output). A rendered listing container whose id matches one of + /// these is replaced by a synthesized markdown item list + /// (bd-5w81o2dh) instead of carrying the listing DOM chrome. + listings: &'a [crate::project::listing::ResolvedListing], } fn has_class(attr: &Attr, class: &str) -> bool { @@ -363,6 +369,21 @@ fn clean_block_into(mut block: Block, cx: &ViewContext, out: &mut Vec<Block>) { if has_class(&div.attr, LLMS_OMIT_CLASS) { return; } + // A rendered listing container: replace the listing DOM + // (thumbnail/metadata chrome, L7 placeholder envelopes) + // with a clean list synthesized from the resolved items. + // The wrapper keeps its id, minimal, like float anchors. + if !div.attr.0.is_empty() + && let Some(listing) = cx.listings.iter().find(|l| l.listing.id == div.attr.0) + { + out.push(Block::Div(quarto_pandoc_types::block::Div { + attr: (div.attr.0.clone(), vec![], hashlink::LinkedHashMap::new()), + content: vec![synthesize_listing_list(listing, cx)], + source_info: div.source_info, + attr_source: AttrSourceInfo::empty(), + })); + return; + } if has_class(&div.attr, "callout") { out.extend(rebuild_callout(div, cx)); return; @@ -446,6 +467,81 @@ fn clean_block_into(mut block: Block, cx: &ViewContext, out: &mut Vec<Block>) { } } +/// Synthesize a listing's markdown view: one bullet per item, +/// `- [title](href) (date, author): description` (each part +/// optional), links page-relative and retargeted to `.md` +/// companions where they exist (bd-5w81o2dh). +fn synthesize_listing_list( + listing: &crate::project::listing::ResolvedListing, + cx: &ViewContext, +) -> Block { + let gen_si = || quarto_source_map::SourceInfo::generated(quarto_source_map::By::unknown()); + let str_inline = |text: String| { + Inline::Str(quarto_pandoc_types::inline::Str { + text, + source_info: gen_si(), + }) + }; + + let items: Vec<Vec<Block>> = listing + .items + .iter() + .map(|item| { + let href = retarget_href(&relativize(&cx.cur_dir, &item.output_href), cx); + let mut inlines: Vec<Inline> = vec![Inline::Link(quarto_pandoc_types::inline::Link { + attr: (String::new(), vec![], hashlink::LinkedHashMap::new()), + content: vec![str_inline(item.title.clone())], + target: (href, String::new()), + source_info: gen_si(), + attr_source: AttrSourceInfo::empty(), + target_source: quarto_pandoc_types::TargetSourceInfo::empty(), + })]; + let meta_bits: Vec<&str> = [item.date.as_deref(), item.author.as_deref()] + .into_iter() + .flatten() + .filter(|s| !s.trim().is_empty()) + .collect(); + if !meta_bits.is_empty() { + inlines.push(str_inline(format!(" ({})", meta_bits.join(", ")))); + } + if let Some(desc) = item.description.as_deref().map(str::trim) + && !desc.is_empty() + { + inlines.push(str_inline(format!(": {desc}"))); + } + vec![Block::Plain(quarto_pandoc_types::block::Plain { + content: inlines, + source_info: gen_si(), + })] + }) + .collect(); + + Block::BulletList(quarto_pandoc_types::block::BulletList { + content: items, + source_info: gen_si(), + }) +} + +/// Page-relative form of a project-relative `target` href as seen +/// from the directory `from_dir` (both forward-slash; `from_dir` +/// has no trailing slash and is `""` at the site root). +fn relativize(from_dir: &str, target: &str) -> String { + if from_dir.is_empty() { + return target.to_string(); + } + let from: Vec<&str> = from_dir.split('/').collect(); + let tgt: Vec<&str> = target.split('/').collect(); + let common = from + .iter() + .zip(tgt.iter()) + .take_while(|(a, b)| a == b) + .count(); + let ups = from.len() - common; + let mut parts: Vec<&str> = vec![".."; ups]; + parts.extend(&tgt[common..]); + parts.join("/") +} + /// Rebuild a resolved callout (the HTML scaffold `callout-resolve` /// produced) back into the author-side form the qmd writer can emit /// legibly: `::: {.callout-note}` with an optional `## title` and @@ -983,6 +1079,7 @@ mod tests { let cx = ViewContext { index: Some(&index), cur_dir: String::new(), + listings: &[], }; // Eligible same-site link, with and without fragment. assert_eq!(retarget_href("about.html", &cx), "about.md"); @@ -1006,11 +1103,21 @@ mod tests { let cx_sub = ViewContext { index: Some(&index), cur_dir: "guide".to_string(), + listings: &[], }; assert_eq!(retarget_href("../about.html", &cx_sub), "../about.md"); assert_eq!(retarget_href("intro.html", &cx_sub), "intro.md"); } + #[test] + fn relativize_computes_page_relative_hrefs() { + assert_eq!(relativize("", "posts/a.html"), "posts/a.html"); + assert_eq!(relativize("posts", "posts/a.html"), "a.html"); + assert_eq!(relativize("posts", "index.html"), "../index.html"); + assert_eq!(relativize("a/b", "a/c/d.html"), "../c/d.html"); + assert_eq!(relativize("a/b", "a/b/c.html"), "c.html"); + } + #[test] fn profile_has_companion_excludes_drafts_and_404() { assert!(profile_has_companion(&profile("about.html", false))); diff --git a/crates/quarto-core/tests/integration/llms_txt.rs b/crates/quarto-core/tests/integration/llms_txt.rs index 6837d627f..6ddcb342c 100644 --- a/crates/quarto-core/tests/integration/llms_txt.rs +++ b/crates/quarto-core/tests/integration/llms_txt.rs @@ -713,6 +713,77 @@ fn llms_companion_collision_with_copied_resource_fails() { /// anchors, callouts, code blocks, footnotes, tables with crossrefs, /// resolved `@ref` text, inline formatting. Snapshot-reviewed so /// quality regressions are visible in the diff. +/// A listing page's companion replaces the rendered listing DOM +/// (thumbnail/metadata div chrome, L7 placeholder envelopes) with a +/// clean markdown list synthesized from the resolved listing items: +/// `- [title](href) (date, author): description` (bd-5w81o2dh). The +/// HTML keeps its full listing DOM. +#[test] +fn llms_listing_page_companion_synthesizes_item_list() { + let (project_dir, summary) = render_project(|dir| { + write( + &dir.join("_quarto.yml"), + "project:\n type: website\n output-dir: _site\n\ + website:\n title: \"Test Site\"\n llms-txt: true\n", + ); + write(&dir.join("index.qmd"), "---\ntitle: Home\n---\n\nHi.\n"); + write( + &dir.join("posts/index.qmd"), + "---\ntitle: Blog\nlisting: default\n---\n\nRecent posts.\n", + ); + write( + &dir.join("posts/a.qmd"), + "---\ntitle: First\ndate: 2026-01-15\nauthor: Alice\ndescription: First desc.\n---\n\nFirst body.\n", + ); + write( + &dir.join("posts/b.qmd"), + "---\ntitle: Second\ndate: 2026-02-20\nauthor: Bob\n---\n\nSecond body.\n", + ); + }); + + let md = read(&project_dir.join("_site/posts/index.md")); + assert_contains(&md, "# Blog", "listing page title"); + assert_contains(&md, "Recent posts.", "page prose kept"); + // Synthesized entries: title link (companion href, page-relative), + // date + author parenthetical, description when present. + assert_contains( + &md, + "* [First](a.md) (2026-01-15, Alice): First desc.\n", + "first item entry", + ); + assert_contains( + &md, + "* [Second](b.md) (2026-02-20, Bob)\n", + "second item entry, no description", + ); + let p_first = pos(&md, "[First]", "order"); + let p_second = pos(&md, "[Second]", "order"); + assert!(p_first < p_second, "items in listing order:\n{md}"); + + // None of the rendered listing DOM leaks into the companion. + assert_not_contains(&md, "thumbnail", "no thumbnail chrome"); + assert_not_contains(&md, "listing-title", "no listing-title chrome"); + assert_not_contains(&md, "listing-description", "no description chrome"); + assert_not_contains(&md, "no-external", "no link-class chrome"); + assert_not_contains(&md, ".metadata", "no metadata chrome"); + + // The HTML page keeps the real rendered listing. + let html = { + let path = summary + .outputs + .iter() + .find(|o| { + o.output_path.ends_with("posts/index.html") + || o.output_path.ends_with("posts\\index.html") + }) + .expect("posts/index.html output") + .output_path + .clone(); + read(&path) + }; + assert_contains(&html, "data-listing-rendered", "html keeps the listing DOM"); +} + #[test] fn llms_companion_rich_content_snapshot() { let (project_dir, _summary) = render_project(|dir| {