diff --git a/.claude/skills/ejs-listing-port/SKILL.md b/.claude/skills/ejs-listing-port/SKILL.md new file mode 100644 index 000000000..c35c76ade --- /dev/null +++ b/.claude/skills/ejs-listing-port/SKILL.md @@ -0,0 +1,169 @@ +--- +name: ejs-listing-port +description: Port a Quarto 1 EJS custom listing template to a Quarto 2 doctemplate, or write a new one. Use when a render warns Q-12-7 ("template: was set but type: is not custom"), Q-12-9 (".ejs" / ".ejs.md" extension), Q-12-24 ("template contains no doctemplate directives"), or Q-12-10 ("Undefined variable"); when a listing renders with the built-in layout instead of the custom one, or dumps the template verbatim into the page; when listing links point at .qmd files or listing images 404; or when writing any custom listing template for Quarto 2. +--- + +# ejs-listing-port Skill + +Quarto 1 custom listing templates were EJS — embedded JavaScript. Quarto 2 +does not run EJS: custom listing templates are **doctemplates**, the +`$variable$` / `$if(…)$` / `$for(…)$` syntax Pandoc templates use, so a +template can never execute code. + +The syntax translation is mechanical and self-announcing — a half-finished +port either warns or looks obviously wrong. **The two things that make ports +fail are not syntax**, and both are silent: no error, no warning, and no +visible difference in the template's text. State them out loud before you +start, or you will skip them. + +## The two silent failure modes + +### 1. Links and images must be markdown, never raw HTML + +A template's output is markdown, re-parsed into the page. Only *then* are +paths resolved: `LinkRewriteTransform` rewrites `.qmd` link targets to output +URLs, and resource collection notes every image so the file gets copied. +Both walk **parsed markdown nodes**. `Block::RawBlock` and `Inline::RawInline` +are no-op leaves in both +(`crates/quarto-core/src/transforms/link_rewrite.rs`, +`.../resource_collector.rs`), so a path inside a raw-HTML attribute is +invisible to each. + +`$it.path$` is deliberately a *source* path, which is what makes the markdown +form work: + +``` +[$it.title$]($it.path$) ✔ becomes href="…​.html" +``{=html} ✘ ships a dead .qmd href +``` + +For images the same split costs **two** things — rewriting *and* copying: + +``` +![$it.image-alt$]($it.image$) ✔ rewritten, and the file is copied +``{=html} ✘ neither: the src 404s in the deployed site +``` + +Raw HTML is fine *inside* the link text — the built-ins do exactly this with +``[`$image-html$`{=html}]($path$)``. **Anchor markdown, contents raw.** + +This is settled design, not a gap: Quarto 2 emits HTML from the AST and will +not parse HTML you author (`claude-notes/plans/2026-04-24-websites-phase-6.md` +Decision 1; `claude-notes/plans/2026-08-13-site-root-relative-paths.md` +Case C). Do not propose an HTML post-processor. Make the markdown path +obvious instead. + +**Why this survives review.** The image failure hides itself whenever the +image is an item *document's* own front-matter `image:` — that page's render +copies the file regardless, so a raw-`` template looks perfect. It only +bites for inline-record fields and custom fields, which is exactly where a +ported gallery or card grid gets its thumbnails. + +### 2. The description placeholder envelope must be unconditional + +Quarto 1 auto-filled a missing item description from the first paragraph of +the rendered item page. Quarto 2 does the same, via a post-render +substitution — but only inside the +`description-placeholder-begin` / `-end` markers, and only if they are +emitted **outside** the `$if$`: + +```` +::: {.listing-description} +```{=html} +$it.description-placeholder-begin$ +``` + +$if(it.description)$ +$it.description$ +$endif$ + +```{=html} +$it.description-placeholder-end$ +``` +::: +```` + +The markers delimit the *region* to substitute, so they must exist for +exactly the items the `$if$` skips. The built-in templates gate the whole +envelope on `$if(description)$` — so **copying the built-in shape loses +previews for precisely the items that need them.** A custom template can and +should do better. + +## Read this first + +`docs/guides/projects/listing-templates.qmd` in the q2 repo is the +authoritative treatment: both rules above, the doctemplate language subset, +what each built-in layout emits, the Q1 → Q2 mapping table, a full worked +before/after port, and what doctemplates cannot do. Its sibling +`docs/guides/projects/listings.qmd` (§"Custom templates") carries the syntax +table and the per-item value list. + +Canonical spellings, since Q1 habits produce the wrong ones: + +- `$it.$` inside `$for(items)$`. (`$items.$` is an accepted alias; + prefer `$it$`.) Inside a partial applied to an item, keys are bare: + `$title$`. +- `type: custom` **and** `template:` are both required. `template:` alone + warns `Q-12-7` and silently uses the built-in layout. +- Give the file a neutral extension (`.template`) so `Q-12-9` stops firing. + +## Worked examples + +- **The built-ins** — `crates/quarto-core/src/project/listing/templates/` + (`item-default`, `item-grid`, `item-table`, and the `listing-*` wrappers). + Short, idiomatic, and the reference for class names a custom template must + match to inherit the listing CSS. +- **`references/worked-examples.md`** in this skill — three annotated ports + in increasing difficulty, including the phrasing-content constraint that + bites card grids. +- **The in-repo contract tests** — + `crates/quarto-core/tests/integration/listing_pipeline.rs`, the three + `custom_template_*` tests. They pin both silent failure modes and the + unconditional envelope, so they are executable documentation of the rules + above. + +## Procedure + +1. **Read the Q1 template and inventory what it does** beyond + interpolation: helper calls, JS prologues, expressions, nested loops, + grouping. Those need decisions, not translation — see "What doctemplates + cannot do" in the guide (JS constants → `template-params:`; string + manipulation → pre-computed record key or `listing-item.extra`). +2. **Drop the outer `{=html}` fence.** A Q1 listing template is usually one + big raw-HTML block; that fence is what makes every path inside it + invisible. Removing it is most of the port. +3. **Convert `
` to `::: {.…}`.** Same output element, but the + contents are markdown, so links and images inside resolve. +4. **Convert every anchor and image to markdown** (rule 1). Keep raw HTML + only as link *text* or as genuinely inert markup. +5. **Add the description envelope unconditionally** (rule 2) if the listing + wants previews. +6. **Guard every optional read with `$if$`** — an absent value warns + `Q-12-10` rather than rendering blank. +7. **Verify against the rendered output** (below). Do not stop at "it + renders." + +## Verification — required, and not optional + +Neither silent failure produces a diagnostic, and neither changes the +template's text in a way review catches. A passing render proves nothing. +After porting, render the project and inspect the **output**: + +```bash +cargo run --bin q2 -- render # in q2; `quarto render .` for users +``` + +1. **Read the `href` values** in the listing's host page. Every link to a + project document must end in `.html`. A surviving `.qmd` means that anchor + is still raw HTML. +2. **Read the `src` values, then confirm each file exists** under the output + directory. A `src` that looks right with no file behind it is a raw + `` — this is the check that catches the masked case, and the one + people skip. +3. **Check an item with no front-matter `description:`.** It should show a + first-paragraph preview. Nothing shown means the envelope is inside the + `$if$` instead of around it. +4. **Grep the render output for `Q-12-`.** Any warning here is real. + +Report what you inspected, not just that the render succeeded. If you could +not check one of the four, say which. diff --git a/.claude/skills/ejs-listing-port/references/worked-examples.md b/.claude/skills/ejs-listing-port/references/worked-examples.md new file mode 100644 index 000000000..1324fb287 --- /dev/null +++ b/.claude/skills/ejs-listing-port/references/worked-examples.md @@ -0,0 +1,279 @@ +# Worked examples — Q1 EJS → Q2 doctemplate + +Three ports in increasing difficulty. Each states the Q1 shape, the Q2 +template, and the decisions that were not mechanical. + +The canonical reference for style is q2's own built-ins, +`crates/quarto-core/src/project/listing/templates/`. The user-facing +treatment is `docs/guides/projects/listing-templates.qmd`; this file is the +agent-facing companion with the parts a porter hits in practice. + +--- + +## 1. Minimal — one link, one description + +The most common Q1 listing template: a `{=html}` fence wrapping a loop that +emits an anchor and a paragraph. + +**Before:** + +```` +```{=html} +<% for (const item of items) { %> + <%= item.title %>
+

<%= item.description %>

+<% } %> +``` +```` + +**After:** + +```` +$for(items)$ +[$it.title$]($it.path$) + +::: {.listing-description} +```{=html} +$it.description-placeholder-begin$ +``` + +$if(it.description)$ +$it.description$ +$endif$ + +```{=html} +$it.description-placeholder-end$ +``` +::: + +$endfor$ +```` + +Two deliberate changes: + +- **The link is markdown, not raw HTML,** so `$it.path$` (a source path) is + rewritten to the output URL. Q1's EJS received already-resolved `.html` + hrefs, so raw HTML was the norm there and carries over as a dead `.qmd` + href. +- **The description is an envelope, not a plain variable.** Q1 auto-filled a + missing description from the item page's first paragraph; Q2 does the same + only inside the marker pair, and only if the markers are emitted + unconditionally. `$if(it.description)$` guards the *explicit* description + (reading it bare would warn `Q-12-10`); the markers sit outside it. + +One cosmetic consequence worth knowing: markdown wraps a standalone link in +`

`, so where Q1 emitted a bare `
`, this emits +`

`. That also makes the first `

` of a listing-only page an +item *title* — so if that page is itself an item in another listing, its +derived preview becomes a title rather than a description. Give such pages a +real `description:` in front matter. + +This template is the in-repo fixture for +`custom_template_it_spelling_derives_description_without_front_matter` in +`crates/quarto-core/tests/integration/listing_pipeline.rs`. + +--- + +## 2. A card grid that was reimplementing the `grid` built-in + +A very common Q1 shape: a template that hand-rolls Bootstrap card markup with +the same class names the built-in `grid` layout uses, plus a JavaScript +prologue of layout constants. + +**Before** (abridged): + +```` +```{=html} +<% +const cols = 3; +const align = "left"; +const hideBorders = false; +%> +

+<% for (const item of items) { %> + +<% } %> +
+``` +```` + +**Recognise this before porting it.** If the class names match a built-in +layout, the cheapest correct port is often not a port at all — it is +`type: grid` with the relevant `grid-columns` / `grid-item-align` / +`grid-item-border` options, or a thin template that calls the built-in +partial: + +``` +::: {.list .grid .quarto-listing-grid .quarto-listing-cols-3} +$items:item-grid()$ +::: +``` + +Port it faithfully only when the template genuinely differs. Then: + +**After:** + +``` +::: {.list .grid .quarto-listing-cols-$listing.template-params.columns$} +$for(items)$ +::: {.g-col-1} +::: {.quarto-grid-item .card .h-100 .card-left} +$if(it.image)$ +![$it.image-alt$]($it.image$){.card-img} +$endif$ + +::: {.card-body .post-contents} +##### [$it.title$]($it.exercise$){.no-anchor .card-title .listing-title} + +$if(it.description)$ +[$it.description$]{.card-text .listing-description} +$endif$ +::: +::: +::: + +$endfor$ +::: +``` + +with + +```yaml +listing: + type: custom + template: card.template + template-params: + columns: 3 +``` + +The decisions: + +- **The JS prologue became `template-params:`.** `cols`, `align` and + `hideBorders` were per-listing constants that happened to live in the + template. Doctemplates cannot declare variables, and these were never + per-item. +- **The ternary and template literal are gone.** `` `card-${align}` `` and + `hideBorders ? ' borderless' : ''` are expressions. Either fix the class + (as above) or branch explicitly with `$if(listing.template-params.borderless)$`. +- **The custom field `exercise` is the link target,** so it must be a markdown + link. Custom-field values are passed through verbatim, so the YAML must + write them relative to the page declaring the listing. +- **The image became a markdown image,** which is what gets it copied into + the output tree. A record- or custom-field image referenced only from a raw + `` is never copied at all. +- **`metadataAttrs(item)` was dropped.** `$it.metadata-attrs$` is the + equivalent and must go inside a ```` ```{=html} ```` block (interpolated as + markdown, its quotes are curled into invalid HTML) — but no Q2 built-in + layout emits it, so dropping it is usually right. Decide, don't translate. + +Beware a Q1 wart in templates of this shape: guards like +`<% if ('title' || 'subtitle') { %>` are **constant-true** — they are string +literals, typically left behind when an `otherFields.includes(…)` test was +stripped. Do not faithfully reproduce them. If the intent was "show this field +when the listing asks for it", that is `$if(it.show.title)$` — noting that +`type: custom` has no default field set, so every `show.*` is false unless the +listing declares `fields:` explicitly. + +--- + +## 3. A whole-card link — phrasing content only + +The hardest common shape: Q1 templates that wrap an entire card in one anchor, +so the whole card is clickable. + +**Before:** + +```` +```{=html} +<% for (const item of items) { %> + +
+
+

<%= item.title %>

+

<%= item.description %>

+
+
+<% } %> +``` +```` + +The anchor must become a markdown link so `item.link` is rewritten. But a +standalone markdown link is auto-wrapped in `

`, and **`

` may only +contain phrasing content** — so the card's `

`, `

` and `

` cannot +survive inside it. + +This is not a style preference. Run the invalid nesting through a +spec-compliant HTML5 parser and the tree comes out wrong: + +``` +

+ +parses as: +

+ <- empty +

<- SIBLING of the

, outside the anchor +

+ <- anchor reconstructed +

+ <- and again +

+``` + +The `

` is force-closed before the `

`, the card is reparented out of +the anchor, and the adoption-agency algorithm re-opens the anchor three +times. The whole-card link is destroyed and replaced by fragments. + +**After** — the card body is built from ``s: + +``` +$for(items)$ +[`$it.title$$it.description$`{=html}]($it.link$){.custom-card-wrapper} + +$endfor$ +``` + +The same markup verified with a parser: + +``` +

Title

+ +parses as written — card inside the anchor. +``` + +The decisions: + +- **`
`/`

`/`

` became ``.** CSS selects by class, not tag, so + this is visually a no-op — but only if the stylesheet's selectors are + class-based. Check for `div.custom-card` or bare-tag rules first, and for + `display` assumptions (a `` needs `display: block`/`flex` where a + `

` had it for free). +- **The heading's semantics are preserved explicitly** with + `role="heading" aria-level="3"`, since the `

` is gone. Do not skip + this; it is the accessibility contract the original `

` carried. +- **The card body stays raw HTML** — that is allowed, because it is link + *text*. Only the anchor had to be markdown. + +If the card contains a genuinely block-level region that cannot be flattened, +the whole-card link is not portable as-is. The honest options are a +title-and-image link pair (what the built-ins do) or a small amount of +site JavaScript — not a raw ``, which silently ships a dead `.qmd` href. + +### Related: a per-item id built by slugifying a title + +Q1 templates sometimes generated `id="…-"` to wire +`aria-labelledby`. Doctemplates have no slugify pipe and Q2 binds no per-item +slug, so the id cannot be reproduced. Use an id-free association instead — +`aria-label="$it.title$"` on the card — rather than dropping the +accessibility wiring. diff --git a/claude-notes/plans/2026-08-26-listings-L10-migration-docs.md b/claude-notes/plans/2026-08-26-listings-L10-migration-docs.md new file mode 100644 index 000000000..82abdc014 --- /dev/null +++ b/claude-notes/plans/2026-08-26-listings-L10-migration-docs.md @@ -0,0 +1,365 @@ +# L10 — Q1 → Q2 listing template migration docs + LLM skill (`bd-hzsi`) + +**Strand:** `bd-hzsi` (P2, task, parent `bd-61cd` Listings epic, blocked-by +`bd-rqgx` L8 — closed). +**Branch:** `braid/bd-hzsi-listing-template-migration-docs` (worktree +`.worktrees/workspace-4`), off `main` @ `c11aa0e4d`. + +## Overview + +Ship the two L10 artifacts: user-facing EJS → doctemplate migration +documentation under `docs/`, and an LLM skill under `.claude/skills/`. + +Both already exist in prototype form outside this repo, having been used +to complete two real Q1 → Q2 listing ports. This is a **reconciliation**, +not a copy-in: commit `fcd76aebd` (on `main`, unreleased, ships in 0.28) +added a "Custom templates" section to `docs/guides/projects/listings.qmd` +that supersedes much of the prototype guide. The prototype's remaining +unique value is two make-or-break semantics that q2's docs do not mention +at all, plus a handful of binding keys its values table omits. + +The prototype guide is stale in specific ways (version markers say 0.14.0; +`$items.key$` throughout where canonical is `$it.key$`; an obsolete +"Known q2 divergences" section; Connect-specific paths and strand ids). +None of that carries over. + +### Scope boundaries + +- ~~Extend the existing **"Custom templates" section** of + `docs/guides/projects/listings.qmd`. Do **not** add a new page.~~ + **Superseded by decision D1** once the migration treatment became + explicitly extensive: `listings.qmd` keeps a tight section and a new + sibling page carries the depth. See D1 below. +- The upstreamed artifacts must be **general**: every example re-rooted + in q2's own built-in templates and test fixtures. No reference to the + two external documentation projects, their paths, or their strand ids. +- `bd-o1meelim` (leading-`/` in `template:` resolves filesystem-absolute) + is a **bug**, and its fix owns the docs sentence about `/`. Do not + document the current behaviour as correct; do not touch it here. +- `bd-owflmojl` (Q-12-24 EJS-sniff escape hatch) is unrelated to these + artifacts. No overlap. + +## Verified ground truth + +Every claim below was checked against a real render +(`./target/debug/q2 render `), not inferred from source. These +are the facts the docs and skill will assert. + +| # | Claim | Evidence | +|---|---|---| +| 1 | Markdown link is rewritten; raw-HTML anchor is not | `[$it.title$]($it.path$)` → `href="posts/a.html"`; `` ``{=html} `` → `href="posts/a.qmd"` | +| 2 | Markdown image is collected+copied; raw `` is not | Record fields `mdpic`/`rawpic`: `_site/images/` contains `md.png` only. `raw.png` is never written; the `` 404s. | +| 3 | Claim 2 is **masked** when the image is an item document's own front-matter `image:` | That page's own render copies it, so the raw-`` template appears to work. It breaks for record fields and custom fields. | +| 4 | `$it.*` works throughout, incl. `$it.description-placeholder-begin$` | Envelope emitted unconditionally → derived first-paragraph preview substituted in, markers stripped | +| 5 | A bare optional variable warns | `$it.description$` on an item without one → `Warning [Q-12-10]: … Undefined variable: it.description`, renders empty | +| 6 | `type: custom` defaults to `fields: []` | `config.rs:956` `ListingType::Custom => vec![]`; so `$it.show.$` is false for everything unless the listing declares `fields:` | +| 7 | `metadata-attrs` interpolated as markdown is smart-quoted | `data-index="0"` → `data-index=“0”`; only usable inside a `{=html}` fence | + +Source confirmations: + +- `crates/quarto-core/src/transforms/link_rewrite.rs:247,327` — + `Block::RawBlock` / `Inline::RawInline` are no-op leaves. +- `crates/quarto-core/src/transforms/resource_collector.rs:288,421` — + same two excluded; `Inline::Image` at `:299` is the collection site. +- `crates/quarto-core/src/project/listing/binding.rs:256-511` + (`build_item_map`) — `outputHref`, `description-placeholder-begin`/`-end`, + `image-placeholder-begin`/`-end`, `word-count`, `metadata-attrs`, + `show.`, `table-row` all still bound. +- `crates/quarto-core/src/project/listing/config.rs:122` — + `max_description_length` default 175. +- `post_render_upgrade/reader.rs:116-125` — first non-empty `

` in + `main.content`, truncated at a word boundary. +- `crates/quarto-doctemplate/src/pipes.rs` — 16 pipes confirmed; + `ast.rs:31,104` — `$^$` nesting and `$~$` breakable spaces exist. + +Design context the docs should **reflect rather than apologise for**: +`claude-notes/plans/2026-04-24-websites-phase-6.md` Decision 1 (AST +rewrite, explicitly not an HTML post-processor) and +`claude-notes/plans/2026-08-13-site-root-relative-paths.md` Case C (q2 +will not parse HTML; the strategy is to remove the incentives to reach +for raw HTML). Settled design — make the markdown path obvious, do not +promise future HTML parsing. + +## Phase 1 — Tests that lock the documented idioms + +TDD gate: these must fail (or not exist) before the doc text is written, +and pass after. They exist so the documented idioms cannot silently rot. +All go in `crates/quarto-core/tests/integration/listing_pipeline.rs` +(per `.claude/rules/integration-tests.md` — no new top-level test files). + +- [x] `custom_template_it_spelling_derives_description_without_front_matter` + — the + documented `$it.*` template with an unconditional envelope; an item + with no front-matter `description:` gets the derived preview, and + the markers are stripped. (The existing + `custom_listing_emits_no_matching_placeholder_and_derived_ellipsis` + covers the `$items.*` alias; this locks the spelling the docs use.) +- [x] `custom_template_markdown_anchor_is_rewritten_raw_anchor_is_not` — + one template emitting both forms; asserts `href="a.html"` present + **and** `href="a.qmd"` present, pinning the split as contract. +- [x] `custom_template_markdown_image_is_copied_raw_image_is_not` — + record items carrying two distinct image fields; asserts the + markdown-image file lands in the output dir and the raw-`` + one does not. +- [x] Run: `cargo clippy -p quarto-core --all-targets -- -D warnings` + and `cargo nextest run -p quarto-core`. + +## Phase 2a — `docs/guides/projects/listings.qmd` (keep tight) + +- [x] **Qualify the raw-HTML sentence.** The section intro currently ends + "…so raw HTML goes in a ` ```{=html} ` block just as it would in a + `.qmd` file." True, and it reads as unqualified permission. Attach + the consequence, cross-link + `docs/guides/projects/paths.qmd#raw-html-is-not-rewritten` (same + rule, page-side instance), and point at the new page. +- [x] **Syntax table additions** — keeping only what a listing author + would use: `${var}` braced form, `$elseif$`, the pipes list. + `$^$` / `$~$` are Pandoc line-breaking machinery with no listing + use case — omit. +- [x] **Values-table additions**, each verified present in `binding.rs`: + `outputHref` (with "for feeds, not links" — it bypasses rewrite by + construction), `description-placeholder-begin`/`-end`, + `image-placeholder-begin`/`-end`, `word-count`, `show.` + (with the `type: custom` ⇒ empty-`fields:` caveat, finding 6), + `table-row`, `metadata-attrs` (with the `{=html}`-fence caveat). +- [x] **Guard every optional read with `$if$`** — new prose, from + finding 5. Currently undocumented and it produces a real warning. +- [x] **Move** `### Migrating a Quarto 1 template` to the new page, + leaving a short pointer. Keep the `#custom-templates` anchor + intact — `Q-12-7`, `Q-12-9` and `Q-12-24` link to it. + +## Phase 2b — New page `docs/guides/projects/listing-templates.qmd` + +- [x] Front matter (`title`, `description`) matching sibling + conventions; add to the `docs/_quarto.yml` sidebar directly after + `guides/projects/listings.qmd`. +- [x] **"Links and images must be markdown."** The two silent failure + modes as one rule with two costs. The anchor-markdown / + contents-raw idiom, citing the built-ins' + ``[`$image-html$`{=html}]($path$)``. Include the masking note + (finding 3) — it is why this survives testing. +- [x] **"Descriptions and the placeholder envelope."** Why the envelope + must be emitted unconditionally; the extraction rule (first + non-empty `

` in `main.content`, word-boundary truncation at + `max-description-length`, default 175); the styling-hook advice; + the note that the built-ins gate the envelope on + `$if(description)$` and a custom template can do better. +- [x] **"What the built-in shapes emit."** Per-shape anatomy for + `default`, `grid`, `table` — wrapper classes and per-item partial, + i.e. what a custom template must match to inherit the listing + CSS and filter/sort UI. Full sources linked, not pasted. +- [x] **"Porting a Quarto 1 template."** The mapping table moved from + `listings.qmd` and extended with the rows that fail silently: + `` → `[$it.title$]($it.path$)`, + `` → `![]($it.image$)`, + `metadataAttrs(item)` → `` `$it.metadata-attrs$`{=html} ``. +- [x] **The worked before/after**: quarto-web's `docs/gallery/gallery.ejs` + (attributed, linked). Cover the nested `$for(it.tiles)$` (finding + 8), the raw-anchor and raw-image conversions, the `alt` ternary + becoming `$if$`/`$else$`, and the category-grouping loop's + restructure. +- [x] **"What doctemplates cannot do."** No expressions: JS prologues + and per-item constants become `template-params:`; string + manipulation must be pre-computed into a record key or + `listing-item.extra`. +- [x] **"Verifying a port."** Inspect rendered `href`/`src` values and + confirm referenced assets landed in the output directory. Neither + failure produces a diagnostic or a text diff. + +## Phase 3 — Skill: `.claude/skills/ejs-listing-port/` + +Convention: `/SKILL.md` with `name`/`description` frontmatter plus +optional `references/` (`triage/` is the structural model). + +- [x] `SKILL.md` — thin pointer to q2's own docs (not the external + guide), with the two silent semantics **stated inline, not merely + named**: a skill that only names them gets them skipped. Fire on + the symptoms a user actually sees — `Q-12-7`, `Q-12-9`, `Q-12-24`, + a listing rendering with the built-in layout, a template dumped + verbatim into the page. +- [x] Add a **verification step**: after porting, inspect the rendered + `href` and `src` values directly, and confirm referenced assets + landed in the output directory. Neither failure produces a + diagnostic or a text diff. +- [x] `references/worked-examples.md` — the annotated ports, de-branded + and re-rooted: the minimal link+description template, a card grid, + and the phrasing-content lesson (a standalone markdown link is + auto-wrapped in `

`, so raw HTML inside it must be phrasing + content — ``, not `

`/`

`/`

`; the HTML5 parser + force-closes the `

` and reparents otherwise). That lesson is + general and is currently written down nowhere in this repo. + +## Phase 4 — Error-page prose pass (`Q-12-9`, `Q-12-24`) + +`fcd76aebd` rewrote these two for the EJS → doctemplate correction and +marked them `status: stub` pending a prose pass. This work touches both. + +- [x] `Q-12-24` — its "After" example already uses a markdown link but + does not say **why**. Add the reason and link the new subsection. +- [x] `Q-12-9` — same: the port advice stops at syntax. +- [x] Flip both `status: stub` → `status: complete`. +- [x] `cargo xtask lint` (error-docs rules; no new codes, so no sidebar + changes expected). + +## Phase 5 — Verification and close-out + +- [x] `cargo nextest run --workspace` — report the delta against the + live baseline, not a figure from an older document. +- [x] `cargo xtask verify --skip-hub-build --skip-hub-tests` (Rust-only + change; this is the `-D warnings` gate that plain build/nextest + miss). +- [x] **End-to-end**: `cargo run --bin q2 -- render docs/` succeeds, and + the rendered "Custom templates" section is inspected in the output + — not merely "no errors". Record the invocation and an output + snippet here. +- [x] Reconcile this checklist against what actually landed; commit the + corrected plan file. +- [x] Close `bd-hzsi`; check whether `bd-qb4o` (L11 close-out) is + thereby unblocked. + +## Decisions (settled with Gordon, 2026-08-26) + +**D1 — Doc structure: new sibling page.** The migration treatment is +explicitly *extensive*, even where it duplicates the skill. So +`listings.qmd` keeps a tight "Custom templates" section — syntax table, +values a template can read, the card example — and links to a new +`docs/guides/projects/listing-templates.qmd` carrying the two semantics, +the built-in anatomy, the migration treatment and the worked examples. +This supersedes the brief's original "extend the section, don't add a +page": at ~400 lines the migration content would have dominated +`listings.qmd`. The existing `### Migrating a Quarto 1 template` +subsection **moves** to the new page, leaving a pointer behind. + +**D2 — The wild worked example: `quarto-dev/quarto-web`'s +`docs/gallery/gallery.ejs`.** Chosen over `InseeFrLab/utilitR`'s +`listing.ejs` on provenance — same org, so no third-party licensing +question. It exercises `metadataAttrs(tile)`, three raw ``s, a +raw ``, an `alt`-building nested ternary, and a nested +`items` → `item.tiles` loop. Its outer category-grouping loop has no q2 +analogue, so the port restructures — worth showing honestly. + +The Quarto **extension catalogue is the wrong shelf** and the epic's +"find one in the Quarto user-extension catalogue" should be read as +superseded: `mcanouil/quarto-extensions` indexes 370 repos and contains +no listing-template extension, because a listing template is a per-site +file named by `listing: template:`, not a packaged extension. The wild +examples come from GitHub code search +(`"for (const item of items)" language:ejs`, 30+ hits). + +**D3 — `metadata-attrs` must be documented** (this resolves what was an +open question the other way): the chosen worked example calls +`metadataAttrs(tile)`, so the port has to say what happens to it. Document +with the `{=html}`-fence caveat from finding 7 — it is the one value in +the table that is unsafe to interpolate directly. + +**D4 — Skill name stays `ejs-listing-port`.** Proven over two real ports; +the migration case is where an agent actually needs the intervention, and +the frontmatter description carries the wider trigger set. + +## Additional verified finding + +| # | Claim | Evidence | +|---|---|---| +| 8 | Nested `$for$` over a list-of-maps custom field works; the inner `$it$` shadows the outer | Record with `tiles: [{title, href}, …]`; `$for(items)$…$for(it.tiles)$$it.title$` renders both tiles correctly | + +This is what makes the quarto-web gallery portable at all, and it is +undocumented today. + +## Outcome + +All five phases landed. Commits on +`braid/bd-hzsi-listing-template-migration-docs`: + +| Commit | Contents | +| --- | --- | +| `d2e6ad554` | Three contract tests in `listing_pipeline.rs` | +| `faec852d5` | New `listing-templates.qmd`; `listings.qmd` + sidebar | +| `28b159313` | `.claude/skills/ejs-listing-port/` (SKILL.md + references) | +| `8794ad4c7` | `Q-12-9` / `Q-12-24` prose pass; `stub` → `complete` | + +### Verification + +- `cargo xtask lint` — clean, 1059 files. +- `cargo clippy -p quarto-core --all-targets -- -D warnings` — clean. +- `cargo nextest run --workspace --no-fail-fast` — + **13450 passed, 199 skipped** on this branch vs **13447 passed, 199 + skipped** on `main` (`3e45bdd2b`). Delta **+3**, exactly the three + tests added here; no skip-count change. + - Two earlier fail-fast runs failed + `quarto-core engine::ts_engine::tests::test_race_free_instance_exclusive` + with `DEADLOCK DETECTED: test timed out`. That test uses a hard + wall-clock `watchdog(Duration)` helper (`ts_engine.rs:1297`) and + both runs happened immediately after heavy `q2 render docs/` + invocations. It passes 3/3 in isolation at 0.3 s against a 15 s + budget, and the quiet-machine workspace run is green on this branch + and on `main` alike. Load-induced flake, same family as + `bd-d8nol0xn` / `bd-fuw5gcni`; not attributable to this work. +- `cargo xtask verify --skip-hub-build --skip-hub-tests` — clean. The + hub/WASM legs are skipped deliberately: the only Rust change is added + `#[test]` functions in a `tests/integration/` file, which are not part + of any crate's lib and cannot reach the `wasm32` target. + +### End-to-end evidence + +Invocation (from the worktree root; `docs/examples/` staged first — see +the note below): + +```bash +./target/debug/q2 render docs/ +``` + +Result, against a true pre-change baseline taken with `git stash -u`: + +| | files | warnings | errors | +| --- | --- | --- | --- | +| baseline (`stash -u`) | 266 of 266 | 36 | 0 | +| with this change | 267 of 267 | 36 | 0 | + +Same 36 warnings either way (11 `Q-13-4`, 5 `Q-2-50`, 20 `Q-5-6`), none +citing any file touched here. Output was **inspected**, not inferred: + +- The new page's 13 headings render in order, and its 14 code blocks + survive intact — including the description-envelope block, whose + nested ` ```{=html} ` fences required a four-backtick outer fence + (three-backtick nesting silently truncated the block and cascaded 12 + parse errors, which is how the bug was caught). +- Every cross-link resolves: `listings.html#custom-templates`, + `paths.html#raw-html-is-not-rewritten`, + `../../errors/listing/Q-12-{9,10,13,24}.html`. +- `Q-12-24`'s mapping table renders 7 body rows (was 5), including the + two new markdown-link / markdown-image rows. + +The worked example in the doc was itself rendered before being written +down: a fixture reproducing the ported quarto-web gallery template +produced `href="examples/docs.html"` (rewritten from `.qmd`), +`src="thumbs/docs.png"` with **both** thumbnails copied into `_site/`, +an unchanged external `https://` href, and the `alt` conditional +resolving to `"Quarto Docs example"` (derived) vs `"A custom alt text"` +(explicit). + +The phrasing-content claim in the skill's worked examples was +demonstrated with `html5lib` rather than asserted: the `

`-inside- +link form has its `

` force-closed, the card reparented out of the +anchor as a sibling, and the anchor reconstructed three times by the +adoption-agency algorithm; the `` form parses as written. + +### Notes for whoever picks this up next + +- **`cargo xtask build-agents-docs` does not work from a worktree.** Its + staging step resolves `repo_root()` to the *main* checkout (the + `[workspace]` Cargo.toml is shared), so it stages + `docs/examples/` into `/Users/gordon/src/q2` and then renders the + worktree's `docs/`, which fails with "Declared resource + 'docs/examples' does not exist on disk". Pre-existing, unrelated to + this work, worked around here by copying the staged tree in. Same + trap `switch_task.rs` documents for `create_worktree::repo_root()`. + Not filed — flagging for a decision. +- `metadata-attrs` is bound but has **no consumer**: no built-in + template emits it and nothing in-tree reads `data-index` / + `data-categories`. `helpers.rs:117` claims "The list.min.js sort/filter + UI is gated on these attrs", which cannot be true today — a built-in + listing rendered with `sort-ui: true, filter-ui: true` emits no + `valueNames`, no `quarto-listings`, no `new List`, no `data-index`. + Recorded as a comment on `bd-nbv80e33`, which owns the underlying gap. + The docs therefore describe what `metadata-attrs` *is* and how to emit + it safely, and make no claim about it driving the filter UI. diff --git a/crates/quarto-core/tests/integration/listing_pipeline.rs b/crates/quarto-core/tests/integration/listing_pipeline.rs index 07f226eba..906bd69f5 100644 --- a/crates/quarto-core/tests/integration/listing_pipeline.rs +++ b/crates/quarto-core/tests/integration/listing_pipeline.rs @@ -1389,3 +1389,165 @@ fn explicit_description_untruncated_when_max_length_zero() { host ); } + +// ───────────────────────────────────────────────────────────────── +// L10 — contract tests for the idioms the migration docs teach +// (bd-hzsi). These lock behaviour that +// `docs/guides/projects/listing-templates.qmd` asserts. All three +// pass against unmodified production code: they exist so the +// documented idioms cannot silently rot, and so the two *silent* +// failure modes (raw HTML is neither link-rewritten nor +// resource-collected) stay pinned as deliberate contract rather +// than drifting into accidental "fixes". +// ───────────────────────────────────────────────────────────────── + +/// The custom-template spelling the docs use is `$it.$` inside +/// `$for(items)$` (`$items.$` is an accepted alias, covered by +/// `custom_listing_emits_no_matching_placeholder_and_derived_ellipsis`). +/// This pins the documented spelling *and* the documented envelope +/// shape: the `description-placeholder-begin`/`-end` markers are +/// emitted **unconditionally**, outside the `$if(it.description)$` +/// guard, so an item with no front-matter `description:` still gets +/// a post-render first-paragraph preview. The built-ins gate the +/// whole envelope on `$if(description)$` and therefore do *not* +/// get this; a custom template can do better, which is the point +/// the docs make. +#[test] +fn custom_template_it_spelling_derives_description_without_front_matter() { + let (_dir, outputs) = render_project(|p| { + write( + &p.join("_quarto.yml"), + "project:\n type: website\n output-dir: _site\nwebsite:\n title: \"My Site\"\n", + ); + write( + &p.join("index.qmd"), + "---\ntitle: Blog\nlisting:\n type: custom\n template: card.template\n contents: posts\nformat: html\n---\n", + ); + // Exactly the shape documented in the "Descriptions and the + // placeholder envelope" section: markers unconditional, the + // explicit-description fallback guarded. + write( + &p.join("card.template"), + "$for(items)$\n[$it.title$]($it.path$)\n\n::: {.listing-description}\n```{=html}\n$it.description-placeholder-begin$\n```\n\n$if(it.description)$\n$it.description$\n$endif$\n\n```{=html}\n$it.description-placeholder-end$\n```\n:::\n\n$endfor$\n", + ); + // No `description:` in front matter — the preview must come + // from the rendered page's first paragraph. + write( + &p.join("posts/a.qmd"), + "---\ntitle: Alpha\nformat: html\n---\n\nDerived from the first paragraph.\n", + ); + }); + + let host = html_for(&outputs, "index"); + assert!( + host.contains("Derived from the first paragraph."), + "unconditional envelope must yield a derived description for an item \ + with no front-matter `description:`; got:\n{}", + host + ); + assert!( + !host.contains("desc-begin(5A0113B34292)") && !host.contains("desc-end(5A0113B34292)"), + "envelope markers must be stripped from the output; got:\n{}", + host + ); +} + +/// The #1 porting trap, pinned in both directions: a markdown link +/// built from `$it.path$` is rewritten by `LinkRewriteTransform` to +/// the output URL, while a raw-HTML anchor over the same value is +/// not — `Inline::RawInline` is a no-op leaf in that transform +/// (`transforms/link_rewrite.rs`). Quarto 1's EJS received +/// already-resolved `.html` hrefs and raw HTML was the norm there, +/// so a carried-over template ships a dead `.qmd` href with no +/// diagnostic. +#[test] +fn custom_template_markdown_anchor_is_rewritten_raw_anchor_is_not() { + let (_dir, outputs) = render_project(|p| { + write( + &p.join("_quarto.yml"), + "project:\n type: website\n output-dir: _site\nwebsite:\n title: \"My Site\"\n", + ); + write( + &p.join("index.qmd"), + "---\ntitle: Blog\nlisting:\n type: custom\n template: card.template\n contents: posts\nformat: html\n---\n", + ); + write( + &p.join("card.template"), + "$for(items)$\nmd: [$it.title$]($it.path$)\n\nraw: `$it.title$`{=html}\n\n$endfor$\n", + ); + write( + &p.join("posts/a.qmd"), + "---\ntitle: Alpha\nformat: html\n---\n\nBody.\n", + ); + }); + + let host = html_for(&outputs, "index"); + assert!( + host.contains(r#"href="posts/a.html""#), + "markdown link must be rewritten to the output URL; got:\n{}", + host + ); + // Deliberate, documented behaviour — not a bug to be "fixed" + // without also updating listing-templates.qmd and paths.qmd. + assert!( + host.contains(r#"href="posts/a.qmd""#), + "raw-HTML anchor must pass through unrewritten (RawInline is a no-op \ + leaf in LinkRewriteTransform); got:\n{}", + host + ); +} + +/// The same split costs *two* things for images rather than one: a +/// markdown image is rewritten **and** collected for copying, while +/// a raw `` is neither — so the asset never reaches the output +/// tree at all and the `src` 404s. +/// +/// The two image paths come from inline-record fields on purpose. +/// When the image is an item *document's* own front-matter `image:`, +/// that page's render copies it regardless (see +/// `front_matter_image_is_rebased_and_copied`), which **masks** the +/// bug — the raw-`` template appears to work. Record and custom +/// fields have no such second copier, which is where it bites. +#[test] +fn custom_template_markdown_image_is_copied_raw_image_is_not() { + let (dir, outputs) = render_project(|p| { + write( + &p.join("_quarto.yml"), + "project:\n type: website\n output-dir: _site\nwebsite:\n title: \"My Site\"\n", + ); + write( + &p.join("index.qmd"), + concat!( + "---\ntitle: Blog\nlisting:\n type: custom\n template: card.template\n", + " contents:\n", + " - title: MdItem\n href: https://example.com/md\n pic-md: images/md.png\n", + " - title: RawItem\n href: https://example.com/raw\n pic-raw: images/raw.png\n", + "format: html\n---\n" + ), + ); + write( + &p.join("card.template"), + "$for(items)$\n$if(it.pic-md)$md: ![]($it.pic-md$)$endif$\n$if(it.pic-raw)$raw: ``{=html}$endif$\n\n$endfor$\n", + ); + write(&p.join("images/md.png"), "not-really-a-png"); + write(&p.join("images/raw.png"), "not-really-a-png"); + }); + + let host = html_for(&outputs, "index"); + // Both render a src; the difference is invisible in the HTML. + assert!( + host.contains(r#"src="images/md.png""#) && host.contains(r#"src="images/raw.png""#), + "both forms must emit a src (the failure is not visible in the markup); got:\n{}", + host + ); + assert!( + dir.join("_site/images/md.png").exists(), + "markdown image must be collected and copied into the output tree" + ); + // Deliberate, documented behaviour — see the doc-comment above. + assert!( + !dir.join("_site/images/raw.png").exists(), + "raw-HTML must not be collected (RawInline is a no-op leaf in \ + ResourceCollector), so the asset never reaches the output tree" + ); +} diff --git a/docs/_quarto.yml b/docs/_quarto.yml index c73316e66..f4ba9c019 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -32,6 +32,7 @@ website: - guides/projects/create.qmd - guides/projects/paths.qmd - guides/projects/listings.qmd + - guides/projects/listing-templates.qmd - guides/projects/navbar-logo.qmd - guides/projects/header-scrolling.qmd - guides/projects/breadcrumbs.qmd diff --git a/docs/errors/listing/Q-12-24.qmd b/docs/errors/listing/Q-12-24.qmd index afda97d85..67ef7f16b 100644 --- a/docs/errors/listing/Q-12-24.qmd +++ b/docs/errors/listing/Q-12-24.qmd @@ -3,7 +3,7 @@ title: "Custom Listing Template Is Not a Doctemplate" description: "A `type: custom` listing template has no doctemplate directives or still contains Quarto 1 EJS markup, so Quarto 2 skipped the listing rather than copy the file into the page." code: Q-12-24 subsystem: listing -status: stub +status: complete since: "99.9.9" categories: - listing @@ -51,6 +51,8 @@ Rewrite the template as a doctemplate. The common mappings: | `<% for (const item of items) { %> … <% } %>` | `$for(items)$ … $endfor$` | | `<% if (item.image) { %> … <% } %>` | `$if(it.image)$ … $endif$` | | `<%= item.myfield %>` (custom field) | `$it.myfield$` or `$it.extra.myfield$` | +| `` | `[$it.title$]($it.path$)` — a markdown link, not a raw anchor | +| `` | `![$it.image-alt$]($it.image$)` — a markdown image, not a raw `` | | JavaScript expressions | Pre-compute the value into a listing field | Before: @@ -73,8 +75,19 @@ $endfor$ ::: ``` -The [Listings guide](/guides/projects/listings.qmd#custom-templates) -documents the values a template can read and a complete card example. +Two changes in that example are not cosmetic. The `

` became a +fenced div, whose contents Quarto parses as markdown; and the raw +`` became a markdown link. That second change is what makes the +href work. A template's output is markdown, and Quarto rewrites `.qmd` +link targets only after parsing it — so a raw-HTML anchor keeps the +source path and ships a dead href, with no warning. Images behave the +same way, and cost more: an image written as raw HTML is never copied +into the site at all. + +[Listing templates](/guides/projects/listing-templates.qmd) covers both +rules and works through a full port. The [Listings +guide](/guides/projects/listings.qmd#custom-templates) documents the +values a template can read. ## Related diff --git a/docs/errors/listing/Q-12-9.qmd b/docs/errors/listing/Q-12-9.qmd index 37f0644cd..b4f5ab25a 100644 --- a/docs/errors/listing/Q-12-9.qmd +++ b/docs/errors/listing/Q-12-9.qmd @@ -3,7 +3,7 @@ title: "Quarto 1 EJS Listing Template Extension" description: "A listing `template:` ends in `.ejs` or `.ejs.md` — the Quarto 1 EJS convention. Quarto 2 does not run EJS; custom listing templates are doctemplates." code: Q-12-9 subsystem: listing -status: stub +status: complete since: "99.9.9" categories: - listing @@ -46,10 +46,19 @@ listing: ``` `<%= item.title %>` becomes `$it.title$` inside `$for(items)$ … -$endfor$`; `<% if (item.image) { %>` becomes `$if(it.image)$`. The +$endfor$`; `<% if (item.image) { %>` becomes `$if(it.image)$`. +[`Q-12-24`](Q-12-24.qmd) has a fuller mapping table, and the [Listings guide](/guides/projects/listings.qmd#custom-templates) -lists the values a template can read and shows a complete card -template; [`Q-12-24`](Q-12-24.qmd) has a fuller mapping table. +lists the values a template can read. + +That syntax translation is the mechanical half of the port. The half +that fails silently is link and image markup. A doctemplate's output +is markdown, and Quarto resolves paths only after parsing it — so a +link written as raw HTML keeps its `.qmd` href, and an image written +as raw HTML is never copied into the site at all. Neither failure +produces a warning. [Listing +templates](/guides/projects/listing-templates.qmd) covers both, and is +worth reading before you port rather than after. If you only renamed the file without porting its contents, you will see `Q-12-24` next. diff --git a/docs/guides/projects/listing-templates.qmd b/docs/guides/projects/listing-templates.qmd new file mode 100644 index 000000000..1b2b2cf00 --- /dev/null +++ b/docs/guides/projects/listing-templates.qmd @@ -0,0 +1,427 @@ +--- +title: "Listing Templates" +description: "Write a custom listing template, and port one from Quarto 1's EJS." +--- + +A listing can render through a template you write instead of one of the +built-in layouts. [Listings](listings.qmd#custom-templates) covers the +configuration and the values a template can read; this page covers the +parts that are easy to get wrong, the anatomy of the built-in layouts, +and how to port a Quarto 1 EJS template. + +Two rules on this page matter more than the rest, because breaking +either one produces **no error, no warning, and no visible difference in +the template's text**. A ported listing looks finished, renders without +complaint, and ships broken links or missing images. They are the first +two sections. + +## Links and images must be markdown + +A template's output is markdown. Quarto parses it back into the page, +and only *then* resolves paths: `LinkRewriteTransform` walks the parsed +result and rewrites `.qmd` link targets to their output URLs, and +resource collection notes every image so the file gets copied into the +output directory. + +Both of those work on parsed markdown nodes. Raw HTML is passed through +untouched — Quarto 2 does not parse HTML you author yourself, on this +page or [anywhere else](paths.qmd#raw-html-is-not-rewritten). So a path +inside a raw-HTML attribute is invisible to both. + +`$it.path$` is deliberately a **source** path (`posts/intro.qmd`, not +`posts/intro.html`), which is what makes the markdown form resolve: + +``` markdown +[$it.title$]($it.path$) +``{=html} +``` + +Raw HTML is still fine *inside* the link text — only the anchor itself +has to be markdown. The built-in layouts rely on this, wrapping a +pre-rendered HTML thumbnail in a markdown link: + +``` markdown +[`$image-html$`{=html}]($path$){.no-external} +``` + +Anchor in markdown, contents raw. That covers most of what tempts people +toward a raw ``. + +### Images cost twice + +For images the same split costs two things rather than one. A markdown +image is rewritten **and** collected for copying; a raw `` is +neither, so the file is never written into the site at all: + +``` markdown +![$it.image-alt$]($it.image$) +``{=html} +``` + +Markdown images take attributes, so class, width and height are not a +reason to reach for raw HTML: + +``` markdown +![$it.image-alt$]($it.image$){.card-img-top width=320} +``` + +If you genuinely need an asset referenced only from raw HTML, declare it +under `resources:` in `_quarto.yml` so it is copied anyway — see +[Paths in websites](paths.qmd#raw-html-is-not-rewritten). + +### Why this survives testing + +The image failure hides itself in the most common case. When the image +comes from an item document's own front matter: + +``` yaml +--- +title: Introducing Widgets +image: cover.png +--- +``` + +…then *that page's* own render collects `cover.png` and copies it. A +template using a raw `` appears to work perfectly. The file is in +the output directory; the `src` resolves; nothing is wrong. + +The failure appears only when the image has no other route into the +output tree — an [inline record](listings.qmd#records), or a custom +field: + +``` yaml +listing: + type: custom + template: card.template + contents: + - title: External Guide + href: https://example.com/guide + thumbnail: thumbs/guide.png # nothing else references this +``` + +Now `thumbs/guide.png` is copied if the template writes +`![]($it.thumbnail$)`, and silently absent if it writes +``. Which is why the check at the end of this +page is worth running even when the listing looks right. + +## Descriptions and the placeholder envelope + +When an item has no `description:` in its front matter, Quarto can fill +one in from the item page itself — the first paragraph of the rendered +document, truncated at a word boundary. This happens *after* both +documents are rendered, as a substitution pass over the host page, and +it only happens inside a marker envelope your template emits: + +```` markdown +::: {.listing-description} +```{=html} +$it.description-placeholder-begin$ +``` + +$if(it.description)$ +$it.description$ +$endif$ + +```{=html} +$it.description-placeholder-end$ +``` +::: +```` + +Three things about that shape are load-bearing: + +**The markers go outside the `$if$`, not inside.** The envelope marks +the *region* to substitute, so it has to exist for exactly the items +that have no description — the ones the `$if$` skips. Emit it +unconditionally. + +**The markers are HTML comments,** so they belong in a raw-HTML block +(```` ```{=html} ````). They are stripped from the finished page whether +or not a substitution happened. + +**Wrap the envelope in a div.** The substituted preview is plain text +with no block structure of its own, so a container gives it a styling +hook and keeps it separated from the surrounding content. The built-ins +use `.listing-description`. + +The built-in layouts gate the *whole* envelope on `$if(description)$`, +so items with no front-matter description get no preview there. A custom +template that emits the envelope unconditionally does better — this is +one of the few places where a hand-written template beats the built-in. + +The truncation limit is the listing's `max-description-length` (default +175). Setting it to `0` disables truncation. If no usable first +paragraph is found the markers are simply removed, leaving whatever the +`$if$` produced, and Quarto emits +[`Q-12-13`](/errors/listing/Q-12-13.qmd). + +## Guard optional values with `$if$` + +Most per-item values are absent rather than empty when an item does not +have them, and reading an absent value directly is a warning, not a +silent blank: + +``` +Warning [Q-12-10]: Listing `listing-1` doctemplate produced +1 diagnostic(s); first: Undefined variable: it.description +``` + +So `$it.description$` on its own is only correct for values that are +always present — `title`, `path` (whenever the item has a link target +at all), and the four placeholder markers. Everything else wants a +guard: + +``` markdown +$if(it.subtitle)$[$it.subtitle$]{.listing-subtitle}$endif$ +``` + +## What the built-in layouts emit + +A custom template is spliced into the same listing container as a +built-in one, so matching the built-in class names is what lets your +template inherit the listing CSS. Each built-in layout is a wrapper +template that applies a per-item partial: + +| Layout | Wrapper element | Per-item partial | +| --- | --- | --- | +| `default` | `::: {.list .quarto-listing-default}` | `item-default` | +| `grid` | `::: {.list .grid .quarto-listing-grid .quarto-listing-cols-N}` | `item-grid` | +| `table` | `::: {.quarto-listing-table-wrapper}` | `item-table` | + +`item-default` emits a `.quarto-post` with `thumbnail`, `body` and +`metadata` regions. `item-grid` emits a Bootstrap card — +`.quarto-grid-item.card` with `.card-img-top`, `.card-body`, +`.card-attribution`. `item-table` is a single line, `$table-row$`, +because the table layout's cells are pre-rendered by Quarto into that +one value. + +You can apply the built-in partials from your own template, which is the +cheapest way to customise a layout — wrap or interleave the built-in item +rendering rather than reproducing it: + +``` markdown +::: {.list .my-listing} +$for(items)$ +$if(it.pinned)$ +::: {.pinned-banner} +Pinned +::: +$endif$ +$it:item-default()$ +$endfor$ +::: +``` + +The full sources are the best reference for idiomatic style, and they +are short: +[`crates/quarto-core/src/project/listing/templates/`](https://github.com/quarto-dev/q2/tree/main/crates/quarto-core/src/project/listing/templates). + +## Porting a Quarto 1 EJS template + +Quarto 1 listing templates were EJS — embedded JavaScript, under a +`.ejs` or `.ejs.md` extension. Quarto 2 does not run EJS: a +carried-over template warns at the extension +([`Q-12-9`](/errors/listing/Q-12-9.qmd)) and at its contents +([`Q-12-24`](/errors/listing/Q-12-24.qmd)), and the listing is left out +of the page rather than pasted in raw. + +Give the ported file a neutral extension such as `.template` so the +extension warning stops firing. + +### The mapping + +| Quarto 1 (EJS) | Quarto 2 (doctemplate) | +| --- | --- | +| `<%= item.title %>` | `$it.title$` | +| `<% for (const item of items) { %> … <% } %>` | `$for(items)$ … $endfor$` | +| `<% if (item.image) { %> … <% } else { %> … <% } %>` | `$if(it.image)$ … $else$ … $endif$` | +| `<%= item.myfield %>` | `$it.myfield$`, or `$it.extra.myfield$` | +| `` | `[$it.title$]($it.path$)` — **not** a raw anchor | +| `` | `![$it.image-alt$]($it.image$)` — **not** a raw `` | +| `<%= metadataAttrs(item) %>` | `` `$it.metadata-attrs$`{=html} `` — see below | +| `
` … `
` | `::: {.card}` … `:::` | +| `<%= items.length %>`, string manipulation, `process.env` | No expressions — see [What doctemplates cannot do](#what-doctemplates-cannot-do) | + +The interpolation, control-flow and custom-field rows are mechanical, +and a half-finished port announces itself. Three rows do not: the anchor +and image rows fail silently, and `metadataAttrs` fails in a way that +looks like a typo in your CSS. Check those three twice. + +`$it.metadata-attrs$` is the direct equivalent of Quarto 1's +`metadataAttrs()` helper, producing `data-index` and — when the item has +categories — `data-categories`. It is the one value in the table that +you **must** put inside a ```` ```{=html} ```` block: interpolated as markdown +it goes through the typographic filter and comes out as +`data-index=“0”` with curly quotes, which is not a valid attribute. +Note that none of Quarto 2's built-in layouts emit it. + +### A worked example + +Quarto's own website has a gallery listing, and it is a fair +representative of a real Quarto 1 template: everything wrapped in one +`{=html}` fence, raw anchors, a raw thumbnail image, a helper call, a +nested loop over a custom field, and an `alt` attribute built by nested +ternaries. + +Here is [`docs/gallery/gallery.ejs`](https://github.com/quarto-dev/quarto-web/blob/main/docs/gallery/gallery.ejs) +from `quarto-dev/quarto-web`, abridged to one card: + +```` markdown +```{=html} +<% for (const item of items) { %> +

<%- item.category %>

+

<%- item.description %>

+
+<% for (const tile of item.tiles) { %> +
> +
+ + <%= tile.title %> + <% if (tile.code) { %> + + + + <% } %> + <%= tile.subtitle %> + +
+ + <% if (tile.alt) { %><%= tile.alt %><% } else { %><%= tile.title %> example<% } %> + +
+<% } %> +
+<% } %> +``` +```` + +And the port: + +```` markdown +$for(items)$ +## $it.title$ + +$if(it.description)$ +$it.description$ +$endif$ + +::: {.list .grid .gallery-grid} +$for(it.tiles)$ +::: {.card .g-col-12 .g-col-sm-6 .g-col-md-4} +::: {.card-header} +[$it.title$]($it.href$){.listing-title}$if(it.code)$ [``{=html}]($it.code$){.source-code title="View source code"}$endif$ +$if(it.subtitle)$[$it.subtitle$]{.text-muted .listing-subtitle}$endif$ +::: + +[![$if(it.alt)$$it.alt$$else$$it.title$ example$endif$]($it.thumbnail$){.card-img-top}]($it.href$) +::: + +$endfor$ +::: + +$endfor$ +```` + +Point by point: + +**The `{=html}` fence is gone.** It wrapped the whole template, which is +what made every path inside it invisible. Dropping it is most of the +port. + +**`
` became `::: {.…}`.** Fenced divs produce the same +`
` with the same classes, and — unlike a raw `
` — their +contents are markdown, so links and images inside them are resolved. + +**The two raw anchors became markdown links,** so `tile.href` is +rewritten from `.qmd` to `.html`. The inner icon anchor keeps its raw +`` as link *text*, which is allowed. + +**The raw `` became a markdown image nested inside a markdown +link** — `[![alt](src)](href)`. That single change both rewrites the +thumbnail path and gets the file copied into the output directory. + +**The `alt` ternaries became `$if$`/`$else$`,** which is a direct +translation because the original only chose between two strings. + +**The nested loop survives.** `$for(it.tiles)$` iterates a list of maps +held in a custom field, and inside it `$it$` rebinds to the current +tile, shadowing the outer item. Custom-field values are used verbatim, +so paths in them must be written relative to the page that declares the +listing. + +**`metadataAttrs(tile)` was dropped rather than translated.** +`$it.metadata-attrs$` is bound per *item*, not per nested map, so the +tiles have no equivalent. Since no built-in layout emits it either, +dropping it changes nothing here — but it is the kind of helper call +that needs a decision rather than a mechanical rewrite. + +**The outer loop restructured.** The original grouped by +`item.category`, emitting a heading per group; the port emits a heading +per item and treats `tiles` as that item's children, which is the shape +the data already had. Quarto 2 has no grouping construct, so a Quarto 1 +template that groups will always need this kind of judgement. + +### What doctemplates cannot do {#what-doctemplates-cannot-do} + +A doctemplate interpolates, branches and loops. It cannot evaluate +expressions, so anything computed in EJS has to move somewhere that runs +before the template does. + +**A JavaScript prologue of constants** becomes `template-params:`, +read as `$listing.template-params.$`: + +``` yaml +listing: + type: custom + template: card.template + template-params: + columns: 2 +``` + +This is also the answer for a per-item field that is really a +listing-level setting. A Quarto 1 template could read `items[0].columns` +to get a value that was identical on every item; a doctemplate cannot +index a list, and shouldn't need to. + +**String manipulation** — slugifying a title, taking a path's directory +with `lastIndexOf('/')`, formatting a number — has to be pre-computed +into an inline record key or the document's `listing-item.extra`, and +read back as `$it.$`. There are text pipes (`$it.title/uppercase$`, +and `lowercase`, `chomp`, `nowrap`, `alpha`, `roman`, `first`, `last`, +`rest`, `allbutlast`, `length`, `pairs`, `reverse`, `left`, `center`, +`right`) but they do not compose into general string surgery. + +**Counts and aggregates** — `items.length`, sums, any "is this the last +one" logic — have no direct form. `$sep$` covers separators between +items, which is the common case; a genuine total has to come from +metadata. + +## Verifying a port + +Neither of the two silent failures produces a diagnostic, and neither +changes the template's text in a way review would catch. So after +porting, check the rendered output rather than the template: + +``` bash +quarto render . +``` + +Then, in the generated HTML for the listing's host page: + +1. **Read the `href` values.** Every link to a project document should + end in `.html`. A surviving `.qmd` means that anchor is still raw + HTML. +2. **Read the `src` values, then check the files exist.** For each image + `src`, confirm the file is actually present at that path under the + output directory. A `src` that looks right but has no file behind it + is a raw ``. +3. **Check an item with no `description:`.** It should show a preview + drawn from its first paragraph. If it shows nothing, the placeholder + envelope is inside an `$if$` instead of around it. +4. **Look for [`Q-12-10`](/errors/listing/Q-12-10.qmd).** An "Undefined + variable" warning means an optional value is being read without an + `$if$` guard. + +Steps 1 and 2 are the ones worth automating if you maintain several +custom listings; both failures are invisible to everything except the +rendered output. diff --git a/docs/guides/projects/listings.qmd b/docs/guides/projects/listings.qmd index 98706e66b..9ddffd978 100644 --- a/docs/guides/projects/listings.qmd +++ b/docs/guides/projects/listings.qmd @@ -100,14 +100,25 @@ branches on them and loops over them. The output is markdown, parsed back into the page, so raw HTML goes in a ```` ```{=html} ```` block just as it would in a `.qmd` file. +That last point has a consequence worth knowing before you write a +template: paths inside raw HTML are **not** resolved. A link or image +written as raw HTML keeps the path you gave it, so links to project +documents ship unrewritten and images are never copied into the output +— with no warning either way. Write links and images as markdown. See +[Listing templates](listing-templates.qmd#links-and-images-must-be-markdown) +for the idiom, and [Paths in websites](paths.qmd#raw-html-is-not-rewritten) +for the same rule as it applies to page content. + ### Syntax | Directive | Meaning | | --- | --- | | `$listing.id$`, `$it.title$` | Interpolate a value; dotted paths walk into maps. | -| `$for(items)$ … $endfor$` | Loop. Inside the body the current item is `$it$` (also `$items$`). | +| `${it.title}` | The same, brace-delimited — use it when the value is followed immediately by text that would otherwise run into the name. | +| `$for(items)$ … $endfor$` | Loop. Inside the body the current item is `$it$` (also `$items$`). Nested loops over a list-valued field work, and the inner `$it$` shadows the outer. | | `$sep$` | Inside a loop: emitted between items, not after the last. | -| `$if(it.image)$ … $else$ … $endif$` | Branch on truthiness (empty strings and missing keys are false). | +| `$if(it.image)$ … $elseif(it.icon)$ … $else$ … $endif$` | Branch on truthiness (empty strings and missing keys are false). | +| `$it.title/uppercase$` | Apply a pipe. Available: `uppercase`, `lowercase`, `chomp`, `nowrap`, `alpha`, `roman`, `first`, `last`, `rest`, `allbutlast`, `length`, `pairs`, `reverse`, `left`, `center`, `right`. | | `$it:item-default()$` | Apply a partial to a value — here the built-in `item-default` partial, or a same-named `.template` file next to your template file. | | `$-- text` | Comment; not rendered. | | `$$` | A literal dollar sign — on its own this is still literal text and does not make a file a template. | @@ -125,16 +136,42 @@ rather than paste the file into the page. `template-params:` in the YAML, passed through for your template's own options). - **`items`** — one entry per item, each with `title`, `subtitle`, - `description`, `author`, `date`, `date-modified`, `image`, + `description`, `author`, `authors`, `date`, `date-modified`, `image`, `image-alt`, `categories`, `path` (the link target, already page-relative), `filename`, `reading-time`, `reading-time-minutes`, - `word-count`, `order`, plus `image-html` and `category-html` - pre-rendered snippets. Custom fields from an inline record or from - a document's `listing-item.extra` are readable directly + `word-count`, `order`, plus `image-html`, `category-html` and + `table-row` pre-rendered snippets. Custom fields from an inline record + or from a document's `listing-item.extra` are readable directly (`$it.icon$`) and under `$it.extra.icon$`. - **`project.*`** — `site-url` and `title` from the website configuration. +Only `title` is guaranteed present on every item. `path` is present +whenever the item has a link target — a document, or a record with +`href:` — and absent for a record with neither. **The rest are absent +when the item lacks them, and reading an absent value warns** with +[`Q-12-10`](/errors/listing/Q-12-10.qmd) — so guard optional reads: +`$if(it.subtitle)$…$endif$`. + +Four more per-item values exist for specific jobs: + +- **`outputHref`** — the item's rendered output path. For feeds and + cross-references, *not* for links: it is already resolved, so it + bypasses the path rewriting that makes `path` work. +- **`description-placeholder-begin`** / **`-end`** and + **`image-placeholder-begin`** / **`-end`** — envelope markers that let + Quarto fill in a missing description or image after both documents + render. See + [Listing templates](listing-templates.qmd#descriptions-and-the-placeholder-envelope). +- **`show.`** — true for each field named in the listing's + `fields:`. Note that `type: custom` has **no** default field set, so + every `show.*` is false unless the listing declares `fields:` + explicitly. +- **`metadata-attrs`** — `data-index` and `data-categories` attributes, + the equivalent of Quarto 1's `metadataAttrs()`. It must be emitted + inside a ```` ```{=html} ```` block; interpolated as markdown its + quotes are curled into invalid HTML. + These are the most useful values; the built-in templates show the rest. The built-in layouts are themselves doctemplates and make the best @@ -164,20 +201,19 @@ $endfor$ Style `.custom-card-grid` and `.custom-card` in your site's CSS. -### Migrating a Quarto 1 template +### Going further -Quarto 1 listing templates were EJS (`.ejs` / `.ejs.md`). Quarto 2 does -not run EJS — a carried-over template warns with -[`Q-12-9`](/errors/listing/Q-12-9.qmd) at the extension and -[`Q-12-24`](/errors/listing/Q-12-24.qmd) at the contents. Rewrite it: +[Listing templates](listing-templates.qmd) covers the rest: the two +rules that fail silently ([links and +images](listing-templates.qmd#links-and-images-must-be-markdown), the +[description +envelope](listing-templates.qmd#descriptions-and-the-placeholder-envelope)), +what each [built-in layout +emits](listing-templates.qmd#what-the-built-in-layouts-emit) so your +template can inherit its styling, and a worked [port of a Quarto 1 EJS +template](listing-templates.qmd#porting-a-quarto-1-ejs-template). -| Quarto 1 (EJS) | Quarto 2 (doctemplate) | -| --- | --- | -| `<%= item.title %>` | `$it.title$` | -| `<% for (const item of items) { %> … <% } %>` | `$for(items)$ … $endfor$` | -| `<% if (item.image) { %> … <% } else { %> … <% } %>` | `$if(it.image)$ … $else$ … $endif$` | -| `<%= item.myfield %>` | `$it.myfield$` | -| `<%= items.length %>`, string manipulation, `process.env` | No expressions: pre-compute the value into a listing field or a record key, or drop the branch. | - -Give the ported file a neutral extension such as `.template` so the -`.ejs` warning stops firing. +If you are carrying a template over from Quarto 1, start there: Quarto 2 +does not run EJS, and a carried-over template warns with +[`Q-12-9`](/errors/listing/Q-12-9.qmd) at the extension and +[`Q-12-24`](/errors/listing/Q-12-24.qmd) at the contents.