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
15 changes: 15 additions & 0 deletions claude-notes/plans/2026-10-09-listing-description-precedence.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,21 @@ The plan1c caution points to real work, which is now part of Phase 4:
pipeline's profile should be identical for the same input. Add a test
for that, because it is the invariant that stops future drift.

## Follow-up after #815 (2026-10-09)

#814 (this plan) and #815 (bd-p80b9jy9, `fields:` gating) both merged.
The template conflict resolved as `$if(show.description)$` around
`$if(show-description)$`.

- [x] `fields_items_carry` (custom listings without `fields:`) counts a
description or image that L7 can derive, like `effective_fields`
does. Unit test
`custom_listing_without_fields_keeps_derivable_fields`; integration
test `listing_derives_description_present_only_in_rendered_output`
now also covers `custom.html`. Both fail without the fix.
- [x] Docs: the Fields section in `listings.qmd` says document items
count as having a description and an image.

## Risks / tradeoffs

- **Pass-1 cost and determinism.** L1 reads mtime per document. That makes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,14 @@ Identical to Q1 except c, where q2's documented `listing-item:` override
wins. No envelope markers remain in the output, and in the grid the
derived description keeps its link (`<a href="posts/b.html">BODY-B
paragraph.</a>`).

## Follow-up after #815 (2026-10-09)

#815 (bd-p80b9jy9) gave `type: custom` without `fields:` the fields some
item carries (`fields_items_carry`). That check didn't count a
description or image that only L7 can derive, so a custom listing whose
items had no L1 description dropped the envelope. `custom.qmd` lists
`derived-only/e.qmd`, whose only paragraph sits inside a div. L1 skips
that paragraph, but L7 finds it in the rendered page. Before the fix,
`INSIDE-DIV-E paragraph.` was missing from `custom.html`; after the fix,
it shows.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
title: custom listing (no fields:, only a derivable description)
listing:
type: custom
template: custom.template
contents: derived-only/e.qmd
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
$for(items)$
$it:item-default()$
$endfor$
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
title: Post E
---

::: {.wrapper}
INSIDE-DIV-E paragraph.
:::
56 changes: 54 additions & 2 deletions crates/quarto-core/src/project/listing/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,9 @@ pub(crate) fn effective_fields(listing: &Listing, items: &[ListingItem]) -> Vec<
}

/// Every field at least one item carries: the curated fields in a
/// fixed order, then custom (`extra`) keys in first-seen order.
/// fixed order, then custom (`extra`) keys in first-seen order. A
/// description or image L7 may still derive counts as carried (Q1:
/// every document item carries both placeholders).
fn fields_items_carry(items: &[ListingItem]) -> Vec<String> {
const CURATED: &[&str] = &[
"date",
Expand All @@ -148,8 +150,12 @@ fn fields_items_carry(items: &[ListingItem]) -> Vec<String> {
.iter()
.filter(|f| {
items.iter().any(|it| match **f {
"image" => it.image.is_some(),
"image" => it.image.is_some() || derivable(it, it.image_source),
// Presence only — the date style doesn't matter.
"description" => {
item_field_cell(it, "description", &DateStyle::Medium).is_some()
|| derivable(it, it.description_source)
}
f => item_field_cell(it, f, &DateStyle::Medium).is_some(),
})
})
Expand Down Expand Up @@ -1676,6 +1682,52 @@ mod tests {
);
}

fn field_names(ctx: &TemplateContext) -> Vec<String> {
let TemplateValue::List(fields) =
ctx.get("listing").unwrap().get_path(&["fields"]).unwrap()
else {
panic!("fields not a list");
};
fields
.iter()
.map(|f| match f {
TemplateValue::String(s) => s.clone(),
_ => panic!("non-string field"),
})
.collect()
}

// A custom listing without `fields:` keeps `description` and
// `image` when L7 can still derive them, so `$items:item-default()$`
// emits their envelopes instead of dropping them.
#[test]
fn custom_listing_without_fields_keeps_derivable_fields() {
let mut l = Listing {
kind: ListingType::Custom,
..Listing::default()
};
super::super::config::apply_type_defaults(&mut l);
let mut i = item("X");
i.description = None;
i.description_source = FieldSource::Absent;
i.image = None;
i.image_source = FieldSource::Absent;
let names = field_names(&ctx_for(&l, &[i.clone()]));
for f in ["description", "image"] {
assert!(names.contains(&f.to_string()), "missing `{f}` in {names:?}");
}

// A record with no link has nothing to derive from.
i.target = crate::project::listing::item::ItemTarget::None;
let names = field_names(&ctx_for(&l, &[i]));
for f in ["description", "image"] {
assert!(
!names.contains(&f.to_string()),
"unexpected `{f}` in {names:?}"
);
}
}

// `image` is exempt from presence filtering (Q1: placeholders
// can fill it at render time even when no item declares one).
#[test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,19 @@ fn listing_derives_description_present_only_in_rendered_output() {
for t in ["default", "grid", "table"] {
write(&dir.join(format!("{t}.qmd")), &listing_page(t));
}
// A custom listing without `fields:` takes the fields its
// items carry; a description only L7 can derive must count.
write(
&dir.join("custom.qmd"),
"---\ntitle: custom\nlisting:\n type: custom\n template: custom.template\n contents: posts\n---\n",
);
write(
&dir.join("custom.template"),
"$for(items)$\n$it:item-default()$\n$endfor$\n",
);
});
render(&dir);
for t in ["default", "grid", "table"] {
for t in ["default", "grid", "table", "custom"] {
let page = format!("{t}.html");
let html = read(&dir.join("_site").join(&page));
assert_shows(&html, &page, &["RENDERED-ONLY paragraph."], &[]);
Expand Down
4 changes: 3 additions & 1 deletion docs/guides/projects/listings.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,9 @@ These cards show only a date, a title and a description. There is no
image area, and no author, subtitle, reading time or categories, even
for items that have them. Without `fields:`, a listing uses its type's
default set and keeps only the fields at least one item has, plus
`image`.
`image`. An item that is a project document always counts as having a
description and an image, since Quarto can derive both from the
rendered page.

`image` is the exception: when it's in the set, every card gets an image
area. An item with no image of its own (see
Expand Down
Loading