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
30 changes: 26 additions & 4 deletions crates/quarto-core/src/project/llms_post_render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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. ────────────────────────────────────────────────────
Expand Down Expand Up @@ -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>) {
Expand Down Expand Up @@ -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');
Expand Down
36 changes: 36 additions & 0 deletions crates/quarto-core/src/transforms/config_markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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]
Expand Down
139 changes: 139 additions & 0 deletions crates/quarto-core/src/transforms/llms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -159,12 +171,33 @@ 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(),
&ViewContext {
index: index.as_deref(),
cur_dir,
listings: &ctx.resolved_listings,
},
);
// The HTML `<h1>` title comes from the template, not the
Expand Down Expand Up @@ -253,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 {
Expand Down Expand Up @@ -331,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;
Expand Down Expand Up @@ -414,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
Expand Down Expand Up @@ -951,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");
Expand All @@ -974,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)));
Expand Down
Loading
Loading