Skip to content

Feature: website listings#169

Merged
cscheid merged 57 commits intomainfrom
feature/listings
May 8, 2026
Merged

Feature: website listings#169
cscheid merged 57 commits intomainfrom
feature/listings

Conversation

@cscheid
Copy link
Copy Markdown
Member

@cscheid cscheid commented May 8, 2026

Note: this is very much a MVP. Evidenced by, eg, the 33 outstanding issues that remain on the plan document.

But from here we're closer to having our own q2 website, and we truly need that as soon as it's practical.

Carlos Scheidegger and others added 30 commits May 5, 2026 14:54
Epic bd-61cd brings Q1-feature-parity listings to Q2 on top of the
DocumentProfile + 2-pass architecture. Settled decisions captured
in the design-discussion document:

- C5 named-listing-item profile field (listing_item: ListingItemInfo
  with curated fields plus an extra: BTreeMap escape hatch scoped
  to listings consumers only). DOCUMENT_PROFILE_VERSION 2 → 3.
- quarto-doctemplate (Pandoc-style $var$) for both built-ins and
  custom templates. No JS runtime; hub-client safe.
- Generate/render decomposition: a pre-checkpoint
  ListingItemInfoStage auto-fills holes; author values always win.
- L7 (engine-rendered preview upgrade) is bracketed as CLI-only
  with mandatory L1 fallbacks so hub-client and quarto preview
  show valid listings without sibling engine execution.

Sub-issues bd-n8a4 (L0) through bd-qb4o (L11) filed with
parent-child:bd-61cd plus inter-phase blocks deps mirroring the
dependency graph in the epic plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a Sub-plan column and a Status column to the bd-issue mapping
table. Make explicit that no per-phase sub-plans exist yet; the
L0/L1/L4 open questions live in the epic plan itself under §Open
questions until the per-phase sub-plans are authored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-confirmed decisions 2026-05-05:
1. Custom-template extension is .template (Pandoc kinship without
   compatibility implication; .ejs.md accepted with deprecation).
2. Image-from-first-body-image fallback ships in L1 v1.
3. list.min.js client-side sort/filter is in scope for L3 (with
   artifact-store fallback path documented).
4. HTML parsing in L7 uses scraper (CSS selector API alignment;
   verify WASM transitive deps don't break hub-client).
5. listing: stays a top-level frontmatter key (reconcile with
   bd-n9dr if it lands a namespaced placement).
6. ListingItemInfoStage at crates/quarto-core/src/stage/stages/
   listing_item_info.rs.

Per-phase scope sections updated to reference these decisions
inline. "Open questions" section renamed and rewritten as
"Resolved decisions" with audit-trail entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sub-plan for L0 of the listings epic. Adds DocumentProfile.listing_item:
ListingItemInfo with curated typed sub-fields plus an extra:
BTreeMap<String, ConfigValue> bag. Bumps DOCUMENT_PROFILE_VERSION from
3 → 4 (the epic plan said 2 → 3, but bd-o8pr already took v3 for
the resources field; sub-plan corrects).

Contract doc gets a new §"Scoped feature surfaces" explicitly
forbidding non-listing consumers from reaching into
profile.listing_item (and especially listing_item.extra), with the
discipline written down as code-review-enforced rather than type-
system-enforced.

L0 is a pure type extension. No stage logic; no rendering. The
ListingItemInfoStage that auto-fills holes (description, image,
word-count, reading-time, date-modified) is L1. L0 only reads
author-supplied frontmatter.

Includes 14 tests (13 unit + 1 integration), TDD ordering, full
xtask verify before close-out, end-to-end CLI MD5 verification
on existing fixtures plus one new listings-l0 fixture. Six
in-line decisions captured in §"Decisions log".

Updates epic plan's bd table to mark L0 as Filed (draft).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six clarifications resolved with the user before bd-n8a4 implementation
starts:

- C1: reading-time semantics → integer minutes only; display formatting
  is a render-time concern (L3+).
- C2: kebab in YAML, snake in Rust; manual extraction lookup, no
  serde(rename_all).
- C3: schema work scoped to test-fixture; quarto-yaml-validation
  integration deferred via follow-up.
- C4: cross-path categories merge uses MergedConfig with the existing
  !prefer/!concat tag system — tags ride with values, so feeding
  profile.categories and listing_item.categories into the merge
  algorithm gives tag-aware behavior for free.
- C5: silent-drop type-mismatch handling in L0; strict validation
  belongs at the L2 schema layer.
- C6: extra namespace is distinct from curated fields.

Sub-decision D7 added: L0 must preserve originating ConfigValue
(with tags) alongside the flattened Vec<String> for both
profile.categories and listing_item.categories so the consumer-side
merge in C4 has tagged values to dispatch on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundational data substrate for the listings epic (bd-61cd, the
listings-feature epic). Adds two new DocumentProfile fields and bumps
DOCUMENT_PROFILE_VERSION 3 → 4. No user-visible behavior change yet —
later phases (L1+) read these fields.

DocumentProfile additions:
- listing_item: ListingItemInfo — scoped per-feature surface for
  listings consumers. Curated typed sub-fields (title, subtitle,
  description, image, image_alt, date, date_modified, categories,
  reading_time_minutes, word_count) plus extra: BTreeMap<String,
  ConfigValue> for custom listing templates. Default empty; serializer
  omits empty.
- categories_raw: Option<ConfigValue> (D7 in the L0 sub-plan) —
  preserves the !prefer/!concat merge tags on the top-level
  categories: value so listings consumers can apply tag-aware merging
  via quarto_config::MergedConfig. Mirrored on ListingItemInfo as
  listing_item.categories_raw.

Extraction reads listing-item: at frontmatter top level. Authoring
keys are kebab-case (Quarto YAML convention); Rust fields are
snake_case. Unknown keys at the top level of listing-item: drop
silently (strict validation is L2's job, per C5 in the sub-plan).
Type mismatches at known keys leave the field at its default rather
than panicking. The extra: bag is preserved verbatim.

Contract doc updates: added a new §"Scoped feature surfaces"
documenting the listings-only access discipline; added Guarantees
rows for resources, categories_raw, and listing_item; added v3
(bd-o8pr) and v4 (bd-n8a4) changelog entries (the v3 row was
missing from the prior contract revision).

YAML schema fixture entry under
crates/pampa/test-fixtures/schemas/definitions.yml. Q2 has no
production frontmatter-schema gate today, so this is a fixture-only
artifact; runtime schema integration is deferred per the L0 plan's
§"Schema status reality check".

Tests:
- 16 new unit tests in document_profile.rs covering is_empty
  semantics, serde mechanics, frontmatter extraction (curated
  fields, extra-passthrough, namespace-distinct C6 case, unknown-key
  drop, type-mismatch graceful drop), version-bump, v3 → v4
  rejection, and three D7 categories_raw tests (top-level,
  listing_item, absent-when-no-frontmatter).
- 1 new integration test in document_profile_pipeline.rs
  (pipeline_extracts_listing_item_from_frontmatter) exercising the
  full head pipeline through MetadataMergeStage to confirm the
  extraction works under realistic merged-metadata input.

Net workspace test delta: +18 (8407 → 8425). cargo xtask verify
(full, including hub-client + WASM) passes.

End-to-end CLI verification recorded in the L0 sub-plan: rendering
two otherwise-identical fixtures (one with listing-item:, one
without) produces byte-identical HTML after normalizing the
filename-derived asset paths. L0's new field is rendering-neutral.

Plan: claude-notes/plans/2026-05-05-listings-L0-profile-extension.md
Parent epic: bd-61cd

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ension)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sub-plan for L1 of the listings epic. New pipeline stage between
IncludeExpansionStage and DocumentProfileStage; enriches
meta.listing-item with auto-derived values (description,
image, word_count, reading_time_minutes, date_modified) when
the author hasn't supplied them. Uses pre-checkpoint metadata
enrichment, not a side-channel — single extraction path,
local author-vs-auto check, no new DocumentAst field, cache
invalidation correct for free.

L0 handoff items 1–10 incorporated. Notable: the stage writes
to ast.meta (not the profile), so the contract-doc "Mutability:
profiles are read-only" rule is preserved. Author values always
win; L1 only fills holes; idempotence is required and tested.

Includes 21 tests (16 unit + 2 stage-trait + 3 integration),
TDD ordering, full xtask verify, end-to-end CLI MD5
verification on existing fixtures + new listings-l1 fixture.
Three audit checkpoints flagged (inlines_to_plain_text
duplicates, ConfigValue mutation API, WASM mtime path) that
may surface follow-ups but should not block L1.

Updates epic plan: marks L0 Closed (with commit refs), L1
Filed (draft), and adds settled-decisions item 7 recording
DOCUMENT_PROFILE_VERSION = 4 plus the categories_raw D7
shape so future sub-plans don't re-stale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
File bd-zzke (chore, P3) tracking the consolidation of six
divergent inlines_to_(plain_)text helpers. Audited 2026-05-06:
they are not duplicates — they disagree on Code/Math
contribution, Quoted-character wrapping, Note recursion,
LineBreak handling, and trimming. A consolidating refactor
needs an options-driven helper plus per-site audit and snapshot-
diff investigation, which is out of scope for L1.

L1 sub-plan revised: replace the "audit four duplicates"
checkpoint with a direct instruction to reuse
metadata_normalize::inlines_to_plain_text (same crate, most
complete coverage). The helper needs a visibility bump
(pub(crate)) and a one-line doc-comment pointing at bd-zzke
for any future third consumer.

L1's word-count walker is now spec'd to strip Inline::Note
content before passing inlines to the helper, so footnote
prose doesn't count toward reading time (Q1 parity).
Description-preview output recurses into footnotes the way
authors see them in the rendered document.

Decisions log D10, open sub-questions, risks, and orchestrator-
handoff sections all updated to reflect the new scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
L1 was sketched with std::fs::metadata + a #[cfg(not(target_arch =
"wasm32"))] gate. Better shape: route through the existing
SystemRuntime::path_metadata trait method, which already
abstracts native (std::fs) vs. WASM (Automerge VFS). Native
populates modified; WASM currently returns None. Backend
semantics live where they belong.

bd-a3we (P2 feature) tracks teaching the WASM VFS to populate
PathMetadata.modified from Automerge change-op timestamps,
which is what makes "last modified" meaningful in
hub-client-backed projects. Until it lands, hub-client renders
get None for listing_item.date_modified and listings either
omit the column or fall back to listing_item.date.

L1 sub-plan revisions:
- Helper renamed: filesystem_mtime_iso → mtime_iso(runtime, path).
- autofill function takes the runtime via StageContext (verify
  the access pattern in worktree; flag if plumbing is needed).
- Test 12 uses a test SystemRuntime impl rather than
  filetime::set_file_mtime; backend-agnostic.
- Test 13 covers the runtime-returns-None contract — the
  exact behavior bd-a3we will eventually flip.
- New decision D7b records the runtime-abstraction call.
- Risks, audit checkpoint, implementation step, and
  orchestrator handoff updated to match.

When bd-a3we resolves, L1 needs zero code change — the field
just starts populating in WASM contexts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New pre-checkpoint stage that enriches `meta.listing-item.*` from
the post-include AST and runtime mtime when the author hasn't
supplied them. Wired into both native and WASM HTML pipelines
between IncludeExpansionStage and DocumentProfileStage. Author
values always win — the stage strictly fills holes.

Auto-fills:
- description: full first non-empty paragraph (D11; L3 owns
  truncation).
- image: first inline-image URL in document order, walking into
  Link / Emph / etc.
- word-count: tokenized scan, footnote text excluded for Q1
  parity.
- reading-time-minutes: ceil(word_count / 200).
- date-modified: SystemRuntime::path_metadata().modified
  formatted as YYYY-MM-DD UTC; native populates, WASM stays None
  pending bd-a3we.

Mechanism: pre-checkpoint metadata enrichment via
ConfigValue::insert_path / contains_path. The L0 extractor then
produces a populated ListingItemInfo through its existing
single-extraction path; no side-channels.

Implementation-session decisions:
- D11: store full first-paragraph (no L1 truncation).
- D12: time = "0.3" added to quarto-core (chrono is not a
  workspace dep; time is already present via quarto-hub).
- D13: shortcode-bearing image src (e.g. {{< meta thumb >}}.png)
  is NOT filtered here; tracked under bd-8h9o for dedicated study.
- D14: ?Send async-trait convention for native+WASM; codified
  in .claude/rules/wasm.md.

Tests: 25 new unit tests + 3 new integration tests. Full workspace
tests pass (8448/8448); cargo xtask verify (incl. WASM hub-client
build) green; cargo xtask lint clean.

No user-visible behavior change yet — listing_item is consumed
by L3+. Render output is byte-identical to the pre-L1 baseline.

Plan: claude-notes/plans/2026-05-05-listings-L1-autofill-stage.md
Parent epic: bd-61cd (claude-notes/plans/2026-05-05-listings-epic.md)
Follow-up: bd-8h9o (shortcode-bearing image src filter)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings in the pre-checkpoint stage that auto-fills `meta.listing-item.*`
from post-include AST + runtime mtime, populating the profile field
added in L0 (bd-n8a4). No user-visible behavior change yet — wait for
L3+ to consume.

Plan: claude-notes/plans/2026-05-05-listings-L1-autofill-stage.md
Parent epic: bd-61cd
Follow-up: bd-8h9o (shortcode-bearing image src filter)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plus bd-8h9o filed as a discovered-from follow-up: investigate
filtering shortcode-bearing image src in L1's first_image_src
walker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…5jm)

Foundation work for the L3 listing-resolve-transform sub-plan
(claude-notes/plans/2026-05-06-listings-L3-resolve-transform.md).
This commit lands:

- **L4.1 — pipe evaluator** (`crates/quarto-doctemplate/src/pipes.rs`).
  Implements all 16 pipe names the tree-sitter grammar already
  enumerates (`pairs`, `first`, `last`, `rest`, `allbutlast`,
  `length`, `uppercase`, `lowercase`, `reverse`, `chomp`, `nowrap`,
  `alpha`, `roman`, `left`/`center`/`right` with width + borders).
  Wired into both `evaluator.rs` TODO sites (variable path and
  partial-output path). The plan originally called for `escape` /
  `escape_xml` / `date_format` as pipes — discovery during impl
  showed these would require grammar changes; instead, dates get
  pre-formatted server-side in the listing binding and markdown
  re-parse handles HTML escaping.

- **Two latent parser bug fixes** (in `crates/quarto-doctemplate/src/parser.rs`):
  the outer `pipe` rule arm was building a fresh `Pipe::new` and
  dropping args from `pipe_left/center/right` children; the
  bare-partial extraction was early-returning before collecting
  sibling pipes inside an interpolation node. Both surfaced once
  the evaluator started actually consuming pipes. Fixes both.

- **L4.3 — `project_listing_resolver` helper** in
  `crates/quarto-doctemplate/src/resolver.rs`. Chains
  `FileSystemResolver` (host-page-relative custom partials, for L8)
  primary, `MemoryResolver` (built-ins, for L3) fallback. One-call
  construction site for the listing render transform.

- **L4.2 — discovery, no new code.** Pampa already provides
  `pampa::template::config_merge::config_to_template_value` over
  all `ConfigValueKind` variants. L3's render transform will call
  that directly. The plan is updated to reflect the discovery.

- **Q-12-N catalog entries** (`crates/quarto-error-reporting/error_catalog.json`).
  Ten codes registered with subsystem `"listing"` covering every
  diagnostic the L3 sub-plan promised (custom-template-not-yet,
  inline-contents-not-yet, unknown-sort-field, id-collision,
  field-display-names-not-string, listing-false-rejected,
  template-needs-custom, template-file-missing, ejs-md-deprecated,
  reparse-diagnostics).

- **L3 phase 3 — listing data model** under
  `crates/quarto-core/src/project/listing/`:
  - `config.rs` — `Listing` + supporting enums + the
    `ConfigValue → Vec<Listing>` parser handling every shape from
    the L2 reference doc (`listing: true` / `default` / `{...}` /
    `[...]`). Per-type defaults (default field set + image-align
    + grid-columns + table sort/filter UI). Diagnostic emission
    on shape errors.
  - `item.rs` — `ListingItem` and the `hydrate_item(profile)`
    function; falls through `listing_item.<field>` →
    `profile.<field>` → filename stem for title.
  - `filter.rs` — `apply_filters(items, include, exclude)` with
    the curated → `extra` fallback rule (D12, user-confirmed
    2026-05-06): preserves Q1 parity for blogs filtering on
    `status: published`-style custom fields.
  - `sort.rs` — `apply_sort(items, sort, diagnostics)` with
    multi-key stable sort, missing-value-last semantics,
    integer-numeric comparison for sort keys that parse as ints,
    and a Q-12-3 warning on unknown sort fields.
  - `placeholders.rs` — Q1-verbatim `5A0113B34292` /
    `9CEB782EFEE6` hex tokens shared with the future L7 upgrade.

The plan file in `claude-notes/plans/2026-05-06-listings-L3-resolve-transform.md`
captures every decision (D1–D15), the three follow-up bd issues
filed (bd-0fd0, bd-0jyl, bd-0wyo), and the impl-time discoveries
that revised L4.1 / L4.2 from the original sub-plan.

Test count: workspace 8448 → 8534 (+86 new, all passing).
`cargo xtask verify` (full, including hub-client + WASM build):
clean. `cargo xtask lint`: clean.

The next L3 sessions implement the generate transform (phase 4),
render transform + built-in templates (phase 5), pipeline wiring +
e2e (phase 6), and vendored client-side assets (phase 7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three discovered-from issues filed during the L3
(bd-ml8z) sub-plan authoring, before impl began. Each is
linked to bd-ml8z via discovered-from. Detailed strawmans
and source-code TODO marker locations live in the L3
sub-plan's "Filing reminder" section.

- bd-0fd0 (task, p2): Lua filter injection slot between
  generate and render transforms. Today there is no slot
  inside AstTransformsStage between *_generate and *_render
  transforms; UserFiltersStage::pre/post bracket the whole
  AstTransformsStage. The L3 plan stores listing data on
  meta.listings.<id> as forward-compat for when this slot
  exists.

- bd-0jyl (task, p2): Source-info threading through
  listing markdown re-parse. L3 collapses re-parse
  diagnostics into a single Q-12-10 host-page warning;
  proper threading (host YAML key → template substitution
  → markdown span) is a separate design session cutting
  across quarto-source-map, quarto-doctemplate, and the
  diagnostic builder.

- bd-0wyo (task, p3): Server-precomputed
  other_metadata_html for the default listing. Q1's
  item-default.ejs.md iterates over fields *not* in a
  curated set and emits a div per field; this is dynamic
  in EJS and not expressible in doctemplate without
  server-side precomputation. v1 default listing renders
  curated-fields-only; this issue picks up the gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the Pass-2 generate transform that resolves a host
page's `listing:` frontmatter into one or more `ResolvedListing`
records (config plus a hydrated, filtered, sorted, truncated
item set). Result lands on `RenderContext::resolved_listings`,
where `ListingRenderTransform` (next session) reads it.

Decisions baked in:

- **D2 revised in-flight.** The original D2 stored resolved
  listings under `meta.listings.<id>` for Lua-mutation
  forward-compat. Per D13 there is no Lua filter slot
  between generate and render today, and the
  `Listing`/`ListingItem` ConfigValue round-trip would be
  ~200 lines of boilerplate that drifts. We follow the
  `crossref_index` precedent: a typed
  `pub resolved_listings: Vec<ResolvedListing>` field on
  `RenderContext`. When `bd-0fd0` (Lua injection slot)
  lands, the natural integration point is a meta
  serialize/deserialize bridge at the injection boundary;
  this typed in-memory shape stays unchanged.

- **D10 (item discovery via `ProjectIndex.profiles()`)**.
  No filesystem walk. Each listing's `contents:` glob is
  matched against the enumerated project profiles, trying
  both host-dir-relative (Q1 default `*.qmd` finds
  siblings) and project-relative (explicit
  `posts/**/*.qmd`) views. Identical behavior on native
  and WASM, naturally excludes non-`.qmd` files Q2 doesn't
  parse, naturally excludes the host page itself.

Code changes:

- `crates/quarto-core/src/project/discovery.rs` — expose
  `glob_match_path(pattern, path) -> bool` and
  `path_to_forward_slashes(&Path) -> String` so the
  listing module reuses the existing matcher (no new
  walker, no Phase-8 churn).

- `crates/quarto-core/src/project/listing/mod.rs` — add
  `ResolvedListing { listing, items }` and re-export.

- `crates/quarto-core/src/render.rs` — add
  `pub resolved_listings: Vec<ResolvedListing>` to
  `RenderContext`. Initialized empty.

- `crates/quarto-core/src/transforms/listing_generate.rs` —
  `ListingGenerateTransform` (`?Send` async). Reads
  `meta.listing` (skips when absent or `false`), parses
  via `parse_listings`, walks `ProjectIndex.profiles()`
  filtering by glob, hydrates items, applies
  include/exclude filters, applies explicit-or-default
  sort (default = date desc except for `table` type
  which preserves insertion order), truncates to
  `max-items`. The pre-populated-resolved-listings case
  is treated as an override (mirrors navbar's
  skip-when-already-set rule).

- Make `transforms::navigation_active` `pub(crate)` so
  the listing transform can reuse `page_relative_source`.

- Plan file updated to reflect D2 revision and phase-4
  completion.

Tests: 10 new unit tests in `listing_generate::tests` covering
absent-key, listing-false, write-resolved, include-filter,
explicit sort, host-dir-relative glob, project-relative glob in
subdir, max-items, default-sort-is-date-desc, and the
override-via-pre-populated path.

Test count: workspace 8534 → 8544 (+10 new, all passing).
`cargo xtask verify` (full, including hub-client + WASM build):
clean.

Next session: L3 phase 5 (render transform + built-in templates +
binding builder + helpers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the Pass-2 render transform that consumes
`RenderContext::resolved_listings` (populated by phase 4) and
splices rendered listing markup into the host page's AST.

End-to-end shape:

1. `ListingRenderTransform` runs after `ListingGenerateTransform`
   inside `AstTransformsStage`. It is a no-op when
   `resolved_listings` is empty or when the host page disabled
   listings via `listing: false`.

2. For each `ResolvedListing`:

   a. **Binding build** (`project/listing/binding.rs`):
      construct a `TemplateContext` matching L2 §"Per-item
      template binding" — a top-level `listing` map (id, type,
      fields, image-align, …), an `items` list (curated typed
      fields + pre-rendered helpers + free-form `extra`), and a
      `project` map (site-url, title from `meta.website.*`).
      `extra` values pass through pampa's authoritative
      `config_to_template_value` bridge so PandocInlines/Blocks
      render correctly.

   b. **Helper pre-rendering** (`project/listing/helpers.rs`):
      each item carries an `image-html` string (an already-built
      `<img>` tag or empty), a `metadata-attrs` string for
      `list.min.js` (data-index + data-categories), and a
      `description-placeholder` HTML comment in Q1's verbatim
      hex-token format that the future L7 post-render upgrade
      will substitute.

   c. **Template apply** (`project/listing/templates.rs` +
      `templates/*.template`): six embedded doctemplate sources
      (`listing-default`/`-grid`/`-table` plus their
      `item-*` partials), served by a `MemoryResolver` chained
      with `FileSystemResolver` via L4.3's
      `project_listing_resolver`. The render transform compiles
      with the chain so L8 custom partials can shadow built-ins
      by placing same-named files next to the host page.

   d. **Markdown re-parse** via `pampa::readers::qmd::read`. The
      fresh `SourceContext` is discarded; any re-parse
      diagnostics collapse into a single `Q-12-10` warning on
      the host page (per D3 + bd-0jyl).

   e. **Splice** into the host AST. If a top-level
      `Div #<listing.id>` exists, replace its content and mark
      it with `data-listing-rendered="1"`. Otherwise append a
      fresh `Div` with that id and class `quarto-listing`. The
      data-* marker keeps the transform idempotent across
      replays.

3. `type: custom` with no L8 implementation falls back to the
   `default` built-in template and emits `Q-12-1`.

v1 simplifications recorded in the plan:

- The Q1 `otherFields` loop (per-item `metadata-value` divs for
  any field outside the curated set) is deferred to bd-0wyo.
- The Q1 multi-paragraph link wrappers around metadata sections
  (e.g. wrapping date + author + reading-time inside a single
  `[…](path)`) are dropped — markdown can't represent them.
  Each metadata field gets its own block with its own
  `.listing-<field>` class. The Q1 click-the-whole-card
  affordance comes back when phase 7 wires `quarto-listing.js`.
- `data-index`/`data-categories` on the wrapper Div land in
  phase 7 alongside `list.min.js`.

Tests: 19 new unit tests across the listing module
(`binding` × 4, `helpers` × 5, `templates` × 1) plus 7 in
`listing_render::tests` (slot fill, append, idempotent,
custom→default fallback, description placeholder visibility,
skip-when-disabled, grid class). All pass.

Test count: workspace 8544 → 8563 (+19 new). `cargo xtask verify`
(full, including hub-client + WASM build): clean.

Next session: L3 phase 6 (pipeline wiring + e2e CLI fixture +
hub-client smoke).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires `ListingGenerateTransform` + `ListingRenderTransform` into
`build_transform_pipeline` between the navigation Generate and
Render sub-phases. Both the native CLI and WASM pipelines route
through this builder, so one insertion covers both targets.

Also lands two impl-time fixes that the integration tests
exposed:

- **`parse_listings` now handles `PandocInlines`.** Previously
  it only matched `Scalar(Yaml::String)`, but YAML strings in
  document frontmatter are commonly parsed as `PandocInlines`
  (the inline interpretation path). The string / scalar / inline
  cases now route through `ConfigValue::as_plain_text()` which
  covers Scalar / Path / Glob / Expr / PandocInlines uniformly.
  Without this, the literal `listing: default` from a host page's
  frontmatter emitted Q-12-1 ("must be a boolean, type name,
  object, or array") instead of building a default listing.

- **`try_replace_explicit_slot` now recurses into Div content.**
  `SectionizeTransform` runs in the Normalization phase
  (well before Navigation), so by the time
  `ListingRenderTransform` looks for a user's `::: {#my-blog}`
  slot, that Div is wrapped inside a `Div .section` from the
  sectionize pass. The earlier top-level-only walker missed the
  slot and appended a fresh wrapper Div instead, leaving the
  user's empty slot stranded. The recursive walker handles
  nested sections + nested user Divs alike. Q1 also recurses.
  The slot's `data-listing-rendered="1"` marker keeps the
  recursion idempotent.

Adds `crates/quarto-core/tests/listing_pipeline.rs` with five
integration tests that drive a fixture project through the full
`ProjectPipeline` (Pass-1 + Pass-2 across multiple files):

- `default_listing_renders_three_posts_in_date_desc_order` —
  the canonical four-file blog. Asserts the wrapper Div + id +
  data marker, all three post titles + hrefs + dates + authors,
  the L7 description placeholder, and the date-desc ordering.
- `grid_type_emits_grid_classes` — `type: grid` ships
  `quarto-listing-grid` + `quarto-grid-item` +
  `quarto-listing-cols-3` (the latter sourced from the binding's
  `listing.grid-columns`).
- `table_type_emits_listing_table_wrapper` — `type: table` ships
  the `quarto-listing-table-wrapper` Div with the title/date/
  author rows.
- `explicit_slot_div_id_is_filled` — exercises the recursion
  fix above by placing `::: {#my-blog}` *inside* a section
  (between two headings).
- `include_filter_drops_non_matching_items` — Alice survives,
  Bob doesn't.

End-to-end CLI verification recorded in the L3 plan
(§"End-to-end CLI verification record"). Render of the four-file
blog fixture produced the expected `_site/posts/index.html`
with three items in date-desc order, each linking to the
rendered sibling's output href and carrying the
`<!-- desc(...) -->` placeholder comment.

Test count: workspace 8563 → 8568 (+5 integration). `cargo xtask
verify` (full incl. hub-client + WASM): clean.
`cargo xtask lint`: clean.

Next session: phase 7 (`list.min.js` / `quarto-listing.js` /
`quarto-listing.scss` vendored client-side assets) and close-out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ships the two listing client-side JS assets to the artifact
store as Project-scoped `js:`-prefixed artifacts. Phase-5's
auto-emission in `ApplyTemplateStage` picks them up by key
prefix and emits `<script>` tags into the rendered HTML;
`flush_site_libs` writes the bytes to
`_site/site_libs/listing/<name>.js`.

The artifacts register from inside `ListingRenderTransform`
(rather than as a dedicated transform) so they only ship when
at least one listing is rendered — avoids bloating non-listing
pages with unused JS.

Vendored files:

- `resources/listing/list.min.js` (~19KB, third-party MIT) —
  backs the sort/filter UI markup the templates emit.
- `resources/listing/quarto-listing.js` (~7KB, Q1-owned glue) —
  provides `window.quartoListingCategory(...)` for category
  click handlers.

Both copied verbatim from
`external-sources/quarto-cli/src/resources/projects/website/listing/`
per CLAUDE.md §"External Sources Policy".

**SCSS deferred to bd-57y4.** Per L3 D5's spike-fallback
contract, `quarto-listing.scss` (~13KB) requires Bootstrap-
variable wiring + media-breakpoint mixins from the existing
theme-CSS pipeline (`CompileThemeCssStage` /
`quarto_sass::SassLayer`). That's a larger design exercise
than the JS bundling. The follow-up issue captures the work;
listings ship today with default browser styling — markup +
JS are intact, layout less polished than Q1. Q1 parity for
the SCSS lands when `bd-57y4` does.

End-to-end verification (re-running the four-file fixture):

```
$ /tmp/listings-fixture/_site/posts/index.html grep:
  <script src="../site_libs/quarto/bootstrap.bundle.min.js"></script>
  <script src="../site_libs/listing/list.min.js"></script>
  <script src="../site_libs/listing/quarto-listing.js"></script>
$ find _site -name "*.js":
  _site/site_libs/listing/list.min.js
  _site/site_libs/listing/quarto-listing.js
  _site/site_libs/quarto/bootstrap.bundle.min.js
```

Tests: 1 new integration test
(`vendored_js_artifacts_emit_script_tags_and_land_under_site_libs`)
asserts both `<script>` tags appear in the host HTML and both
files land on disk under `_site/site_libs/listing/`. Verifies
the depth-relative path resolution (host at `posts/index.html`,
`<script src="../site_libs/...">`) so the integration covers
the resolver edge case.

Test count: workspace 8568 → 8569 (+1 integration). `cargo
xtask verify` (full incl. hub-client + WASM): clean.
`cargo xtask lint`: clean.

Phase 7 closes out the L3 + L4 implementation. Next step: L3
verification + close-out (cargo xtask verify final pass; bd
issue close; user approval before push).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Filed during L3 phase 7 (bd-ml8z) when the SCSS deferral path
under D5 triggered. The L3 phase 7 commit shipped the JS pair
(list.min.js + quarto-listing.js); the SCSS needs Bootstrap-
variable wiring + media-breakpoint mixins integrated with
the theme-CSS pipeline (CompileThemeCssStage /
quarto_sass::SassLayer) — its own design task.

Discovered-from bd-ml8z; tracked in claude-notes/plans/
2026-05-06-listings-L3-resolve-transform.md §"Conditional
follow-up issues".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updates the L3 sub-plan §"Verification and close-out" to reflect
all checkboxes that have completed:

- cargo build --workspace clean
- cargo nextest run --workspace: 8569 / 8569 passing (+121 from
  baseline 8448, broken down by phase)
- cargo xtask lint: 692 files checked, all pass
- cargo xtask verify (full incl. hub-client + WASM): green
- end-to-end CLI verification recorded earlier in the plan

Two items remain deferred to user-driven steps:

- Hub-client browser smoke against a fixture (pre-push smoke,
  not blocking verify).
- User approval before push, then bd-ml8z + bd-b5jm close-out
  and beads-sync commit from main repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a Node CLI script that takes a directory containing a Quarto 2
project and uploads it as a brand-new Automerge project on
wss://sync.automerge.org (or any sync server you point at via
--server). Reports the resulting IndexDocument id so you can open
the project in hub-client.

Goes through `@quarto/quarto-sync-client::createNewProject`, which
is the same code path the browser uses when hub-client creates a
project from the UI. The schema (IndexDocument with files map +
per-file TextDocumentContent / BinaryDocumentContent) stays
authoritative; this script doesn't reimplement it.

Plumbing notes:

- `fake-indexeddb/auto` is added as a devDependency and shimmed in
  before the sync-client loads — `IndexedDBStorageAdapter` is
  hardcoded inside createNewProject, and the Node runtime has no
  native IndexedDB. The shim's process-scoped store is fine for a
  one-shot upload.

- The script auto-builds the workspace ts-packages
  (pandoc-types, quarto-automerge-schema, quarto-sync-client) on
  demand by running `npx tsc` in each crate when its
  `dist/index.js` is missing. quarto-sync-client has unrelated
  test-file type errors that don't block JS emission, so we
  swallow tsc's exit code there. (Once those tests are fixed
  the swallow becomes redundant.)

- The createNewProject flow uses a 1-second peer-wait timeout
  internally; against sync.automerge.org that often expires and
  drops into "offline mode", then reconnects in the background.
  The script waits on the `onConnectionChange(true)` callback
  before disconnecting, then sleeps 5s for sync flush. The Node
  TimeoutNegativeWarning that the offline-mode path produces is
  filtered from stderr.

Walk rules:

  - Recurses into subdirs.
  - Skips: _site/, node_modules/, dist/, target/, and any
    component starting with `.` (covers .git, .quarto, .beads,
    .DS_Store, etc.).
  - File classification by extension: known text extensions
    upload as TextDocumentContent; everything else uploads as
    BinaryDocumentContent (base64 + mimeType).

Optional `--verify` flag reconnects with a fresh sync client and
confirms every uploaded file path is reachable from the IndexDocument
on the server. Verified end-to-end against sync.automerge.org with
the listings fixture: 5 files round-tripped through a separate
process with no shared in-memory state, all reachable.

Usage:

  node scripts/upload-project.mjs /path/to/quarto-project
  node scripts/upload-project.mjs /path/to/project --server wss://your.server
  node scripts/upload-project.mjs /path/to/project --verify

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…les them (bd-ml8z)

User-reported bug: listing items in hub-client preview don't
navigate to the .qmd source on click, while in-body markdown
links (`[label](other.qmd)`) do. Cause: my listing render
transform was emitting `output_href` (the project-relative
.html path) directly, bypassing `LinkRewriteTransform`. Native
CLI renders ended up with a project-relative `posts/a.html`
href instead of the page-relative `a.html` form, and hub-client's
`iframePostProcessor.ts` had nothing to intercept (no `.qmd`
suffix; no `/.quarto/project-artifacts/` prefix).

Fix: the binding's `path` field now carries a host-dir-relative
`.qmd` source-path string — the body-link convention. Downstream
`LinkRewriteTransform` (which already runs after listing render
in the AstTransformsStage Finalization phase, and which I
recently confirmed recurses into Divs) walks each link and
rewrites:

  - native CLI: page-relative `.html` form (e.g. `a.html` for a
    sibling), so the browser navigates correctly.
  - hub-client / VFS-root resolver: artifact-rooted URL like
    `/.quarto/project-artifacts/a.html`, which
    `iframePostProcessor.ts`'s case-3 anchor handler reverse-maps
    to `.qmd` for in-app navigation.

`outputHref` retains the project-relative `.html` path —
templates that genuinely need the post-render output URL (e.g.
the L7 description-placeholder, RSS feed item URLs in L9) read
that field instead of `path`.

Implementation:

- `binding.rs::build_listing_context` takes a new `host_dir`
  argument: the host page's project-relative directory string
  (forward-slash, no trailing slash; "" when the host is at the
  project root). Used to compute host-relative item paths.
- `binding.rs::host_relative_qmd` strips the host-dir prefix
  when the item is inside the host's directory; items outside
  fall through to the project-relative form.
- `listing_render.rs::transform` derives `host_dir` from the
  RenderContext via `page_relative_source(ctx)` and threads it
  to `render_one` → `build_listing_context`.

Tests:

- New integration test
  `listing_item_links_are_page_relative_after_link_rewrite`
  asserts the rewritten `href="a.html"` form for siblings AND
  the absence of the `posts/a.html` (unrewritten) form. Failed
  before this commit; passes after.
- Updated existing binding-builder unit tests to pass `host_dir`.
- All other listing tests continue to pass; the existing
  `default_listing_renders_three_posts_in_date_desc_order`
  permissive assertion (`href="a.html"` OR `href="posts/a.html"`)
  was already accepting the rewritten form, and the new test
  pins it strictly.

End-to-end CLI verification: re-rendered the listings-demo
fixture and confirmed `<a href="a-second-listing.html" ...>`
(page-relative, post-rewrite) instead of
`<a href="posts/a-second-listing.html" ...>` (pre-rewrite).

Test count: workspace 8569 → 8570 (+1). `cargo xtask verify`
(full incl. hub-client + WASM): clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the state of the branch, commit timeline, the five
discovered-from bd issues filed during this work
(bd-0fd0, bd-0jyl, bd-0wyo, bd-57y4, bd-xs2u), and five things
the next session should know:

- D2 was revised in-flight: resolved listings travel via a
  typed `RenderContext` field, not via `meta.listings.<id>`.
  The bd-0fd0 follow-up will add the meta serialize/deserialize
  bridge when a Lua-injection slot lands between Generate and
  Render transforms.
- Listing item hrefs are `.qmd` source paths, not `.html`
  outputs — `LinkRewriteTransform` rewrites them downstream.
  Documents the resolver-direction split (native CLI →
  page-relative `.html`; hub-client / VFS → artifact-rooted
  URL caught by iframePostProcessor's case-3 handler).
- `scripts/upload-project.mjs` is sticky tooling for sync-server
  testing in subsequent sessions, with quirks documented
  (auto-build, fake-indexeddb shim, peer-wait timeout).
- Hub-client uses a UUID-based project route distinct from
  the Automerge IndexDocument id.
- Worktree convention: implementation commits on the worktree
  branch; bd state on `feature/listings` in the main repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-reported during L3 listings testing on 2026-05-06:
uploading a project containing em-dash characters (U+2014) in
document titles caused some hub-client misbehavior the user
worked around by replacing them with single dashes.

I (Claude) did not reproduce the bug myself — I only observed
that the workaround made hub-client behave correctly afterwards.
Could be in pampa parser, hub-client display, or sync round-trip.
Filed for the next session to investigate; orthogonal to L3.

Discovered-from bd-ml8z; tracked in
claude-notes/plans/2026-05-06-listings-L3-resolve-transform.md
§"Hand-off summary".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reference document only — no runtime code in L2 itself. The L3
session (bd-ml8z) implements the listing types described here.
…ms + doctemplate enhancements

Implements the listing data model from L2's reference doc:
typed Listing config + ListingItem hydration in
crates/quarto-core/src/project/listing/.

L3 (ListingResolveTransform): two-transform decomposition
ListingGenerateTransform + ListingRenderTransform inside
AstTransformsStage. Resolves listing.contents globs against
ProjectIndex.profiles(), hydrates items, builds the L2 binding,
applies the chosen built-in doctemplate (default | grid | table),
re-parses the markdown output, splices into the host AST.
Description-preview and preview-image placeholder comments
emitted Q1-verbatim for L7's post-render upgrade. Listings are
correct without L7 — every item carries the L1 fallback inline.

L4 (doctemplate enhancements): pipe evaluator wired to the
existing tree-sitter grammar pipe set (pairs, first, last, rest,
allbutlast, length, uppercase, lowercase, reverse, chomp, nowrap,
alpha, roman, left/center/right). Latent parser bug fixed:
sibling pipes were being dropped. Built-ins use pampa's existing
config_to_template_value bridge (no new ConfigValue → TemplateValue
code in quarto-core).

Built-in templates ship as include_str!-embedded MemoryResolver
partials. Vendored client-side assets list.min.js +
quarto-listing.js routed through the Phase-5 artifact store as
Project-scoped artifacts.

Q-12-N catalog entries (Q-12-1 through Q-12-10) added for
listing-related diagnostics.

End-to-end CLI verification recorded in the L3 sub-plan; cargo
xtask verify (full incl. hub-client + WASM) was green at branch
tip. Test-count delta: +122 (8448 → 8570 passing).

Follow-up bd issues filed during impl: bd-0fd0 (Lua filter slot
between generate and render), bd-0jyl (source-info threading),
bd-0wyo (other_metadata_html helper), bd-57y4 (vendor-and-
integrate quarto-listing.scss; deferred under D5), bd-xs2u
(em-dash in titles breaks hub-client).

See claude-notes/plans/2026-05-06-listings-L3-resolve-transform.md
§"Hand-off summary" for the full impl-time decisions log and
the source-of-truth state of the work.
Listings phases L2 (data model reference doc), L3 (resolve
transforms + built-in templates), and L4 (doctemplate pipes +
resolver) merged into feature/listings via b4f2238. Updates
the epic table to mark all three closed.
Carlos Scheidegger and others added 27 commits May 7, 2026 08:38
Sub-plan for the listings categories sidebar phase. Adds per-item
category chips to item-default/item-grid templates and a new
CategoriesSidebarTransform that emits the right-margin sidebar via
rendered.navigation.margin_categories. Markup is byte-for-byte
Q1-compatible; SCSS bundling is deferred to bd-57y4.

Encoding aligns with Q1's b64EncodeUnicode (btoa(encodeURIComponent
(s))) so the vendored quarto-listing.js decoder works for non-ASCII
categories. A follow-up bd is filed at impl start to revisit the
encode/decode pair as a Q2-native review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Land Q1-feature-parity categories markup for listing host pages:
per-item category chips on default + grid item templates and a
new right-margin sidebar.

What landed:

- `helpers::category_html` builds the per-item chips. Encoding
  matches Q1's `b64EncodeUnicode` (`btoa(encodeURIComponent(s))`)
  so the vendored `quarto-listing.js` decoder
  (`decodeURIComponent(atob(...))`) round-trips for non-ASCII
  category names. The `b64_encode_unicode` + JS-compatible
  `encode_uri_component` helpers are inlined locally; bd-754f
  files a future review of the encoding scheme.
- New `category-html` key on the per-item template binding;
  `item-default.template` and `item-grid.template` splice it in
  inside `$if(show.categories)$ $if(category-html)$ … $endif$`
  so the field-disable path (drop `categories` from
  `listing.fields`) suppresses chips. Table template unchanged.
- New `CategoriesSidebarTransform` aggregates categories across
  every resolved listing on the host page (skipping listings
  with `categories: Disabled`), renders the heading + container
  + pills HTML, and writes the result to
  `meta.rendered.navigation.margin_categories`. Three modes
  with Q1 parity: `category-default` (with "All" pill + counts),
  `category-unnumbered`, `category-cloud`
  (`ceil(count/total*10)` clamped to 1..=10). Pills sort
  case-insensitive.
- `Listing.categories_source: SourceInfo` captures the YAML span
  on `categories:` for the Q-12-12 diagnostic. Parser captures
  `entry.key_source`; existing call sites get `SourceInfo::default()`.
- New diagnostic codes: `Q-12-11` (mixed category modes on one
  page; first non-disabled mode in declaration order wins) and
  `Q-12-12` (categories enabled but no item has any; sidebar
  suppressed). Both emit at warning severity from the transform.
- Pipeline wiring: `CategoriesSidebarTransform` runs between
  `ListingRenderTransform` (which restores `resolved_listings`)
  and `TocRenderTransform` so both `rendered.navigation.*` keys
  land before `ApplyTemplate` reads them. The same builder feeds
  the WASM pipeline through `AstTransformsStage::run`, so
  hub-client picks up L5 too.
- `FULL_HTML_TEMPLATE` extended: the `#quarto-margin-sidebar`
  region opens whenever either `rendered.navigation.toc` or
  `rendered.navigation.margin_categories` is set, with both
  rendered inside (TOC first) when both are present.
  `MINIMAL_HTML_TEMPLATE` untouched.
- `crates/quarto-core/Cargo.toml`: moved `base64.workspace = true`
  out of the native-only `[target.'cfg(not(target_arch="wasm32"))']`
  block so the new categories code compiles under the WASM
  build path. (`wasm-quarto-hub-client` already depended on
  `base64 = "0.22"` directly.)

Tests: 8570 → 8615 (+45). Phase coverage:

- helpers: +8 (category_html, encode_uri_component, b64 round-trip).
- binding: +2 (`category-html` present/empty on item map).
- listing_render templates: +4 (chips on default + grid; off when
  field disabled; table unchanged).
- categories_sidebar unit: +19 (aggregation + emission + 3 modes
  + b64 + escape + sort).
- categories_sidebar transform: +7 (no-ops + writes-html +
  preserves-resolved-listings + diagnostic emission).
- template: +4 (#quarto-margin-sidebar with toc / categories /
  both / neither).
- listing_pipeline integration: +1 (`listing_with_categories_*_e2e`).

Followups filed at hand-off: bd-99ru (localize Categories/All),
bd-754f (review encoding scheme), bd-ra5j (hub-client browser
smoke; deferred per L3 precedent). Existing bd-57y4 description
updated to reference L5.

End-to-end CLI verification recorded in the plan
(claude-notes/plans/2026-05-06-listings-L5-categories-sidebar.md
§"End-to-end CLI verification record"): rendered a 4-file
website fixture via `cargo run --bin q2 -- render`, inspected
`_site/posts/index.html`, confirmed 4 chips + 4 sidebar pills
+ `#quarto-margin-sidebar` wrapper + `quarto-listing.js`
reference all present.

`cargo xtask verify` clean (Rust + hub-client build incl. WASM
+ all test suites). `cargo xtask lint` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the four insta snapshot tests the L5 plan called for under
§"Tests" #30–33. quarto-core didn't have insta in its dev-deps —
adding it (insta.workspace = true) matches the convention used in
sibling crates (pampa, quarto-highlight). Snapshots cover the
three categories modes (default / unnumbered / cloud) on a
3-post fixture and the two-listing-aggregation case on a single
host. Each test snapshots only the L5-owned slice of the rendered
HTML — chip blocks + sidebar block extracted via depth-counted
substring helpers — to keep snapshots small, readable, and
robust against unrelated theme/markup changes.

Bug surfaced and fixed during snapshot test #33: when the host
declares `contents: "posts/*.qmd"` (or any glob with markdown-
special characters), Quarto's YAML parser tags the value as
`PandocInlines` (a `Span` with class `yaml-markdown-syntax-error`,
because `*` triggers the markdown sublexer). `parse_contents` was
matching only `Scalar(Yaml::String)` / `Glob` / `Array`,
silently dropping the explicit value and letting
`apply_type_defaults` overwrite with the sibling-only `*.qmd`
default. The result: cross-directory listing globs were invisible
at runtime (host page rendered but listing was empty) even though
unit tests passed (the unit tests construct `Yaml::String`
directly, never `PandocInlines`).

Fix: route `parse_contents` through `as_plain_text` first (mirrors
`parse_listings`'s top-level shorthand handling). Two new unit
tests lock the behavior:
  - contents_pandoc_inlines_string_parses_as_glob
  - contents_array_with_pandoc_inlines_items_parses

bd-nwyp is filed for the broader audit: other parser branches in
listing/config.rs (parse_filter_list, parse_string_list,
parse_sort, parse_field_types, etc.) likely share the same
vulnerability and need the same as_plain_text-first treatment.

Test count: 8615 → 8621 (+6: 4 snapshot tests + 2 parser tests).

`cargo xtask verify` clean, `cargo xtask lint` clean.

Snapshot files added (4 new .snap files under
crates/quarto-core/tests/snapshots/):
- listing_pipeline__snapshot_builtin_default_with_categories_default_mode.snap
- listing_pipeline__snapshot_builtin_default_with_categories_cloud_mode.snap
- listing_pipeline__snapshot_builtin_default_with_categories_unnumbered_mode.snap
- listing_pipeline__snapshot_page_with_two_listings_aggregates_sidebar.snap

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Categories sidebar markup for listing host pages:
- Per-item category chips on default + grid item templates.
- New CategoriesSidebarTransform aggregates categories across
  resolved listings on the host page; emits the right-margin
  sidebar HTML with three modes (default/unnumbered/cloud) and
  matching Q1 markup so the vendored quarto-listing.js click
  handlers work unchanged.
- FULL_HTML_TEMPLATE extended so #quarto-margin-sidebar opens
  for either rendered.navigation.toc or
  rendered.navigation.margin_categories.
- New diagnostics Q-12-11 (mixed category modes on one page)
  and Q-12-12 (categories enabled but no item has any).
- Encoding matches Q1's b64EncodeUnicode (btoa(encodeURIComponent
  (s))) so the JS decoder round-trips for non-ASCII categories.

Bug fix shipped alongside (surfaced by snapshot test #33):
parse_contents (crates/quarto-core/src/project/listing/config.rs)
now routes through as_plain_text first so explicit globs like
`contents: "posts/*.qmd"` survive Quarto YAML's PandocInlines
wrapping (yaml-markdown-syntax-error span). bd-nwyp tracks the
broader audit of sibling parser branches.

Tests: 8570 → 8621 (+51). cargo xtask verify clean (incl.
hub-client + WASM build); cargo xtask lint clean.

Followups filed at hand-off:
- bd-99ru (localize Categories/All labels)
- bd-754f (review b64+percent-encoding scheme)
- bd-ra5j (hub-client browser smoke deferred)
- bd-nwyp (PandocInlines parser audit)

Plan: claude-notes/plans/2026-05-06-listings-L5-categories-sidebar.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
L5 (categories sidebar) merged into feature/listings via 9e8afa0.

Beads operations captured in this commit:
- bd-5vsr closed (L5 — categories sidebar; close reason references
  the merge hash and the test-count delta).
- bd-99ru filed: localize listing category sidebar labels
  (Categories, All).
- bd-754f filed: review category click-handler encoding scheme
  (b64 + percent-encoding); the L5 markup matches Q1's
  b64EncodeUnicode for the JS round-trip but a Q2-native scheme
  may be simpler.
- bd-ra5j filed: hub-client browser smoke for L5; deferred from
  the L5 session per the L3 precedent. Visual smoke is partial
  until bd-57y4 (SCSS) lands.
- bd-nwyp filed: audit listing config parsing for the
  PandocInlines / yaml-markdown-syntax-error fallthrough that
  parse_contents was missing pre-L5 (fixed in commit 67a985f
  alongside the snapshot tests).
- bd-57y4 description extended with an L5 cross-reference.

Listings epic table updated to mark L5 closed with the impl +
merge commit hashes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Listing hosts now advertise their `listing.*.contents:` glob strings
on `DocumentProfile.listing_content_globs`, and the dep-graph
builder expands them at graph-build time to add forward edges
`host → content` and put the host in `force_render`. Mode B
(`quarto render posts/foo.qmd`) now automatically pulls in listing
hosts when any of their content files is targeted, so listing pages
no longer go stale on partial renders.

Changes:

- `DocumentProfile`: new `listing_content_globs: Vec<String>` field;
  `DOCUMENT_PROFILE_VERSION` bumped 4 → 5. Default empty +
  `skip_serializing_if` keeps non-listing-host profiles compact.
- `project::listing::config::extract_content_globs`: new pub fn,
  routes through `parse_listings` and discards diagnostics for a
  single source of truth on listing-shape handling. Followup
  `bd-bqf2` tracks a future shared-shape-walker refactor.
- `project::dependency_graph::ProjectDependencyGraph::build`: new
  per-profile block adds listing-content forward edges and
  inserts each listing host into `force_render`. Reuses existing
  `glob_match_path` / `path_to_forward_slashes`. The orchestrator's
  `compute_augmented_render_set` is unchanged — the existing
  `augment_targets_with_always_render` primitive does the right
  thing once force_render carries listing hosts.
- `project::discovery::relative_to_dir`: promoted from a private
  helper in `transforms::listing_generate` to a `pub(crate)`
  helper so the L3 generate transform and the L6 graph builder
  share one host-relative-to-project-relative coercion.
- `claude-notes/designs/document-profile-contract.md`: new field
  row + change-log entry for the v5 bump.

Tests (TDD, +26 over 8621 baseline → 8647 total):

- Unit (`extract_content_globs`, 8): cover listing-shape walks
  including `listing: true`, map without contents, array of
  listings, inline records dropped, `listing: false`, missing key,
  and string-shorthand `contents:`.
- Profile (5): default-empty field, extract from frontmatter, v4
  rejection on read, round-trip preserves field, default profile
  omits the field from JSON.
- Graph build (9): host-relative defaults, project-relative
  globs, no-match preserves force_render, force_render gating,
  empty-globs not in force_render, subdir self-edge avoidance,
  body-link/listing edge dedup, always_render+listing dedup, and
  multi-glob hosts.
- Augmentation (3): listing host pulled in when content targeted,
  not pulled in when target unrelated, force_render gating still
  discriminates body-link-only pages.
- Integration (1, `tests/incremental_rebuild.rs`): full ProjectPipeline
  Mode A → edit content → Mode B; asserts via mtime that
  `_site/index.html` refreshes and `_site/posts/bar.html` does not.

End-to-end CLI verification recorded in the L6 plan
(`claude-notes/plans/2026-05-07-listings-L6-dep-graph.md`
§"End-to-end CLI verification record"): a real `quarto render`
of a fixture showed `_site/index.html` re-rendered with updated
sibling title after Mode B targeted only the edited post.

Verification: `cargo xtask verify` — all 9 steps green
(workspace build + workspace tests + lint + hub-client build +
hub-client tests + WASM build + trace-viewer build + trace-viewer
tests). The v5 profile field flows cleanly through
wasm-quarto-hub-client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bd-xbnf (L6) closed; merge commit 8b5efb9. The dep-graph
integration shipped with `listing_content_globs: Vec<String>` on
DocumentProfile (renamed from the original `listing_content_targets:
Vec<PathBuf>` in the epic plan, since the field stores unresolved
glob strings rather than resolved paths — a per-doc profile cache
cannot safely cache resolution against the full project source set).
Profile version bumped 4 → 5. Listings epic plan updated to match.

Also files bd-bqf2 (open, P3) — followup task to refactor
parse_listings + extract_content_globs to share a "walk_listing_shape"
helper. Today extract_content_globs delegates to parse_listings and
discards diagnostics; one source of truth on shape, but parses more
than it needs. Worth tackling if the redundant work ever shows up
in profiling, or if a third narrow extractor lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Match Q1's listing behavior on `quarto render` for the two
features that require sibling rendered output: per-item
description previews drawn from the engine-rendered first
paragraph, and per-item preview images discovered in
engine-rendered HTML (e.g. ggplot output from a code cell).

L3 emits envelope-marker placeholders around the L1 fallback
content; L7's post_render step regex-finds them, reads each
referenced sibling output, and substitutes the engine-rendered
preview when available — or strips the markers and keeps the
L1 fallback intact (with `Q-12-13`) when not. Bracketed feature
per the listings epic plan §L7 §"Bracketing rules":

- single home: `crates/quarto-core/src/project/listing/post_render_upgrade/`
- CLI-only by construction: module gated `#![cfg(not(target_arch = "wasm32"))]`,
  `scraper` dep gated to non-wasm targets in Cargo.toml; verified
  no leakage into the wasm32 dep tree.
- header comment carries the load-bearing rule-2 wording verbatim.
- L1 fallback contract preserved: removing L7 from `post_render`
  produces correct (if less rich) listings; hub-client preview
  shows the L1 fallbacks unchanged.

Changes:

- `project::listing::placeholders`: new `description_placeholder_begin/end`
  + `image_placeholder_begin/end` builders + regex sources.
  Token (`5A0113B34292` / `9CEB782EFEE6`) preserved verbatim from
  Q1; `-begin` / `-end` suffix is Q2-specific (Q1 has no L1
  fallback contract, so it doesn't need to delimit a region).
  Old single-comment helpers removed (D11 / D20).
- `project::listing::helpers`: four new helpers for the
  begin/end pairs; image marker carries the listing's
  `image-placeholder:` URL base64-encoded (URL_SAFE_NO_PAD) so
  L7 doesn't have to walk source profiles at substitution time.
- `project::listing::binding`: replaces single `description-placeholder`
  key with four new keys; image envelope keys always populated
  (the template's `$if(image-html)$ ... $else$` branch decides
  visibility, not the binding).
- `project::listing::templates::item-default.template` /
  `item-grid.template`: description envelope wrapped in raw
  `{=html}` blocks (D15); image envelope's `$else$` branch wraps
  the empty placeholder div in `[``...``{=html}]($path$){.no-external}`
  so the inline `<div>` is explicitly tagged raw HTML and the
  link wrapper makes the substituted thumbnail clickable (D14).
  The `$if(image-html)$` branch's `$image-html$` is wrapped the
  same way to silence a pre-existing Q-2-9 / Q-12-10 warning
  surfaced by the hub-client demo (the bare `<img>` inside link
  brackets auto-converted to RawInline and warned).
  `item-table.template` is unchanged.
- `project::listing::post_render_upgrade::reader`: listings-only
  subset of Q1's `readRenderedContents`. `extract_first_para`
  walks `<p>` descendants of `main.content`, skipping any whose
  ancestry includes structural scaffolding (`<header>`, `<nav>`,
  `<aside>`, `<footer>`) so Quarto's title block is correctly
  ignored. v1 returns plain text (matching Q1's observable
  behavior); HTML-aware extraction is filed as bd-bpdz (L9
  reader-extension surface). `extract_preview_image` walks Q1's
  selector chain (explicit `.preview-image`, `cell-output-display`
  wrapper, named-pattern src match, `#quarto-document-content img`).
- `project::listing::post_render_upgrade::substitute`: regex-finds
  the description / image envelopes in each output file, reads
  referenced siblings (per-call HashMap cache keyed on absolute
  path), substitutes appropriately. `Q-12-13` (single canonical
  message per D17) emits when description sibling is missing or
  produces no preview; image path is silent (Q1 parity, D4).
- `project::orchestrator::WebsiteProjectType::post_render`: one
  new call inside the existing `#[cfg(not(target_arch = "wasm32"))]`
  block, after `write_robots_txt`.
- `quarto-core/Cargo.toml`: adds `scraper` as a target-gated
  native-only dependency.
- `quarto-error-reporting/error_catalog.json`: new `Q-12-13`
  diagnostic.
- `tests/website_post_render.rs`: three new integration tests
  (description success, image substitution via raw-HTML img to
  bypass L1's auto-fill, default-project-no-substitution).
- `tests/listing_pipeline.rs`: existing assertion updated — L7
  now strips envelope markers in post_render, so the test asserts
  their absence + presence of engine first paragraphs instead.

Tests (TDD, +50 over 8647 baseline → 8697 total):

- Phase 1 placeholders (8): builder shapes, regex round-trip
  (incl. two-envelope same-page case), drift guard between token
  constants and regex sources.
- Phase 2 binding + render-transform (9): new binding keys
  present, image envelope conditional on `image-html` branch,
  static-image branch produces no Q-12-10 (regression for the
  hub-client-discovered bug), b64 default URL flows through.
- Phase 3 reader (15): direct-child `<p>`, section-wrapped `<p>`,
  truncation at word boundary, image selector chain
  (.preview-image / cell-output / named-pattern / data: URI /
  #quarto-document-content), regression for title-block-header
  skip and heading-only-post-returns-None.
- Phase 4 substitute + cache (15): description success / L1
  retention / Q-12-13 / max truncation / multi-envelope, image
  success / listing-default cascade / empty-placeholder retention
  / host-relative URL resolution / no-Q-12-13-on-image-path,
  per-call cache hits + no static cross-call cache.
- Phase 5 orchestrator (3): website pipeline substitutes, image
  pipeline substitutes via raw-HTML img, default project type
  is unaffected.

End-to-end CLI verification recorded in the L7 plan
(`claude-notes/plans/2026-05-07-listings-L7-postrender-upgrade.md`
§"End-to-end CLI verification record"): three fixtures rendered
via real `cargo run --bin q2 -- render` and inspected by hand —
description-substitution success path, L1-fallback path with
Q-12-13, and image-substitution path via raw-HTML img.

`cargo xtask verify` (full, including hub-client + WASM) green.
`cargo tree --target wasm32-unknown-unknown -p wasm-quarto-hub-client | grep -ci scraper` → 0.

Hub-client demo uploaded to wss://sync.automerge.org for visual
verification; surfaced the `Q-2-9` / `Q-12-10` warning on the
static-image branch (pre-existing template issue, fixed in this
commit + locked with the regression test above).

Follow-ups filed: bd-rvpd (source-span threading for Q-12-13 /
L9), bd-bpdz (reader extension surface for L9 RSS),
bd-399t (docs callout when the user-facing docs site comes
online; D13), bd-fx23 (defensive percent-encoding of listing.id
in the L7 image marker; D19, conditional).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Engine-rendered description previews + preview images flow into
listing entries on `quarto render`. Hub-client preview shows
the L1 fallbacks (per the L7 bracketing rules); native render
substitutes engine content via post_render. See the merged
feature commit and the L7 plan for the full architecture and
end-to-end verification record.
bd-qf7r (L7) closed; merge commit dc3a0f7. Engine-rendered
description previews + preview images now substitute into
listing entries on `quarto render` via a bracketed post_render
step, with the L1 fallback contract preserved for hub-client /
non-CLI environments.

Also files four follow-ups discovered-from bd-qf7r:

- bd-rvpd (P3, task): source-span threading for L7's Q-12-13
  diagnostic + future L9 RSS diagnostics. post_render works on
  rendered HTML, so spans need to thread through the L3
  placeholder comment.
- bd-bpdz (P3, task): reader extension surface for L9 RSS
  feeds. v1 ships listings-only `extract_first_para` /
  `extract_preview_image`; L9 will add math handling, syntax-
  highlight class maps, urls-to-absolute, anchor stripping.
- bd-399t (P4, task): user-facing docs callout for L7's
  CLI-only behavior. Deferred until the `docs/` site becomes a
  real published site (D13). Wording locked in the L7 plan.
- bd-fx23 (P4, task): defensive percent-encoding of
  `listing.id` in the L7 image-marker payload. Conditional —
  only fires if the schema gains permissive id syntax (D19).

Listings epic plan table updated to mark L7 closed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire `template:` config through to the listing render path so users
can supply their own doctemplate file at `<host-dir>/<template>` and
have the same data binding the built-ins receive (`listing.*`,
`items[*].*`, `project.*`, including `item.extra.<key>` and
`listing.template-params.<key>`).

Behavior:

- Custom listings load via `std::fs::read_to_string` rooted at the
  host page's directory (Q1-parity); absolute paths are accepted
  as-is.
- The chained resolver `project_listing_resolver(builtins_resolver())`
  lets a custom template call any built-in partial (`item-default`,
  `item-grid`, `item-table`, `listing-default`, …) and shadow it
  with a same-named neighboring file.
- Failure modes are graceful (Q2 convention: warn + fall back, not
  throw):
    * Q-12-14 (new) — `type: custom` set but no `template:` path.
    * Q-12-8 (newly emitted) — `template:` path could not be read.
    * Q-12-10 (existing) — compile/render failure on the custom
      source. Skips the listing rather than falling back, mirroring
      the built-in path's behavior on a corrupt template.
- The Q-12-1 "L8 deferral" diagnostic is removed; the corresponding
  test (`render_emits_q_12_1_for_custom_type`) is replaced by
  `custom_listing_q_12_1_no_longer_emitted`.

WASM behavior: `std::fs::read_to_string` returns NotFound under
wasm32, so hub-client previews of `type: custom` listings fall back
to the default built-in with Q-12-8. Acceptable v1 behavior;
follow-up bd-tmka tracks plumbing the runtime through so VFS reads
work in WASM.

Tests: +14 in `transforms::listing_render::tests` and +1 in
`quarto-error-reporting`. `cargo nextest run --workspace` 8697 →
8712. `cargo xtask verify` (all 9 steps including hub-client + WASM)
green.

End-to-end CLI verification recorded in the sub-plan.

Plan: claude-notes/plans/2026-05-07-listings-L8-custom-templates.md

Follow-ups filed:
  bd-tmka — WASM/VFS-aware custom listing template loading
  bd-ubjo — broader path resolution for YAML-declared paths
  bd-u4ow — docs/ page for custom listing templates
  bd-fvuy — Q-12-10 catalog title/message inconsistency

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes:
  bd-rqgx — L8 custom listing templates (impl 92ca4c5 on
            beads/bd-rqgx-listings-custom-templates).

Filed as L8-discovered follow-ups:
  bd-tmka — WASM/VFS-aware custom listing template loading
            (D1 deferred runtime/VFS plumbing; today custom
            templates fall back to default with Q-12-8 in
            hub-client previews).
  bd-ubjo — broader path resolution for YAML-declared paths
            (D2 — `!path` YAML tag + Q2 metadata-merging
            design will eventually unify path resolution).
  bd-u4ow — docs/ page for custom listing templates (D11 —
            picks up when the user-facing website tree under
            docs/ becomes a real public site).
  bd-fvuy — Q-12-10 catalog title/message inconsistency
            (pre-existing; surfaced again during L8 review).

Epic plan updated to mark L8 row as closed with impl hash
92ca4c5; the merge hash will be filled when the L8 branch
merges to feature/listings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Custom listing templates (`type: custom`, `template: <path>`) load
from the host page's directory and render through the same binding
the built-ins use, with `item.extra.<key>` and
`listing.template-params.<key>` available verbatim. The chained
FileSystemResolver → MemoryResolver lets custom templates call any
built-in partial and shadow them by name. Failure modes are
graceful (Q-12-14, Q-12-8) and the legacy Q-12-1 deferral
diagnostic is removed.

WASM/hub-client falls back to the default built-in with Q-12-8
under wasm32 (D1); follow-up bd-tmka tracks the runtime/VFS
plumbing. See the L8 plan and feature commit for the full
architecture, decisions log, and end-to-end CLI verification
record.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
L8 (bd-rqgx) merged at cd2410f.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sub-plan for the L9 phase of the listings epic (bd-61cd):
RSS feed generation per Q1 parity, gated on website.site-url.

The plan documents the staged-file architecture (Pass-2 emits
<host-stem>.feed-{full,partial,metadata}-staged; post_render
substitutes placeholders against sibling rendered HTML), the
new feed/ submodule layout, the Q-12-15 / Q-12-16 diagnostic
codes, the imagesize dep gating (target-gated to native
alongside scraper), and the strict L7 reader bracketing
(L9 ships its own reader_ext.rs sibling rather than
extending L7's reader).

Plan-time clarifications resolved before impl:

- Transform names use ListingGenerateTransform /
  ListingRenderTransform throughout (not the older
  ListingResolveTransform name from L3's plan).
- date_format doctemplate pipe deferred to a follow-up bd.
  L9's binding pre-computes pub_date_rfc822 server-side via
  the time crate; adding the pipe would require a
  tree-sitter grammar change (the pipe set is grammar-fixed)
  with no L9-mandatory caller.
- ListingFeedLinkTransform appends to rendered.includes.header
  (the favicon-precedent slot).
- feed/link_inject.rs sits outside the cfg(not(wasm)) gate;
  the rest of feed/ is native-only.
- L7 reader is strictly duplicated in feed/reader_ext.rs per
  the bracketing rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- error_catalog.json: add Q-12-15 (Listing Feed Skipped:
  Missing site-url) and Q-12-16 (Listing Feed: Sibling
  Output Unreadable). Both subsystem=listing.
- catalog.rs: add error_catalog_has_q_12_15_and_q_12_16
  test mirroring the L8 Q-12-14 pattern.
- quarto-core/Cargo.toml: add imagesize = "0.13" under
  [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
  for the L9 RSS feed <media:content> emission. Sibling
  to scraper (L7); same target gating.

Verified WASM-cleanliness: `cargo tree --target
wasm32-unknown-unknown -p wasm-quarto-hub-client | grep
imagesize` is empty, and the WASM build succeeds.

Phase 1 of L9. The feed/ submodule scaffold is intentionally
deferred — empty-file stubs would be half-finished work; each
subsequent phase creates its own feed/<file>.rs when the test
that drives it is written.

Plan-doc updates inline:
- Phase 2 (date_format pipe) is now marked deferred.
- D7 acknowledges Q1 divergence: Q1 has one feed per host
  page (kFeed lives on ListingSharedOptions). Q2 has feed
  per-Listing (already shipped in L2). Plan documents the
  qualified-filename rule for multi-listing pages.
- ListingResolveTransform → ListingGenerateTransform
  /ListingRenderTransform throughout (matches actual code
  rather than the older L3-plan name).
- Link-injection slot specified as rendered.includes.header
  (favicon precedent at website_favicon.rs:74).
- Module-gate granularity clarified: feed/link_inject.rs
  outside the cfg(not(wasm)) gate; rest of feed/ native-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the typed feed-binding layer that the upcoming
ListingFeedStageTransform will consume to render RSS 2.0
preamble + per-item + postamble templates.

Module layout:
- crates/quarto-core/src/project/listing/feed/mod.rs
  Native-only `pub mod binding;` + a forward-looking header
  comment describing the module-gate granularity (link-inject
  will sit outside the cfg gate; everything else native-only).
- crates/quarto-core/src/project/listing/feed/binding.rs
  Typed FeedChannel / FeedChannelImage / FeedItem /
  FeedItemImage shapes. Server-side XML escaping (text +
  attr forms) at construction time so the templates emit
  values verbatim. RFC 2822 / RFC 3339 / ISO date-only
  parsing for `<pubDate>`. Image dimension lookup via
  `imagesize::size`, with Q1's feedImageSize scaler
  (max 400h × 144w, smaller-axis-ratio bottleneck).
  Q1-verbatim placeholder token "B4F502887207".
- crates/quarto-core/src/project/listing/feed/templates/
  Three doctemplate files (preamble.template, item.template,
  postamble.template) — drafted now, wired in via
  include_str! during phase 3.

Project wiring:
- listing/mod.rs: declare `pub mod feed;` (unconditional;
  feed itself applies its own cfg gates).
- quarto-core/Cargo.toml: add `parsing` feature to the
  `time` crate (already had formatting + macros). Needed
  for OffsetDateTime::parse and Date::parse.

Tests (32 new):
- xml_escape_text / xml_escape_attr semantics.
- absolute_url joins with base+path normalization (handles
  trailing/leading slashes, external URLs, data URIs).
- scale_to_feed_dimensions branches: under-limits passthrough,
  height-bottlenecked, width-bottlenecked.
- mime_for_path for png/jpg/gif/webp/svg/apng/avif.
- format_pub_date_rfc822 for RFC3339 / RFC2822 / date-only / unparseable.
- build_feed_channel: full-metadata, feed.title-overrides-website,
  website-fallback when feed unset, last_build_date from supplied
  ISO, xml-stylesheet plumbing.
- build_feed_item: pub_date_rfc822 for date-only inputs, XML
  escaping for title + categories, metadata-feed CDATA inlining,
  partial / full placeholder envelope shape.
- build_item_image: 100x100 PNG → no scaling; 4000x3000 PNG →
  scaled to (144, 108); absolute URL → empty attrs; data URI →
  empty attrs; unreadable file → empty attrs.

cargo nextest run -p quarto-core 'project::listing::feed::binding::tests'
→ 32 passed, 0 failed.

cargo xtask verify --skip-hub-build --skip-hub-tests → clean.
cargo xtask lint → clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Pass-2 transform that emits one staged feed file per
feed-configured listing on the host page during native render.

What's in the diff:
- crates/quarto-core/src/project/listing/feed/stage.rs (new)
  - ListingFeedStageTransform impls AstTransform.
  - Embeds preamble.template / item.template / postamble.template
    via include_str! and compiles them once per call.
  - Lifts FeedChannel / FeedItem (typed structs from phase 2) into
    TemplateContext maps with the keys the templates reference
    (channel.title, channel.feed-link, item.description-element,
    item.image.attrs, etc.).
  - Skips when no listing has feed:; emits Q-12-15 once when
    website.site-url is missing.
  - Per-category sub-feeds (one extra staged file per declared
    feed.categories entry, items pre-filtered to that category,
    filename `<stem>-<lowercased-category>.feed-<type>-staged`).
  - Multi-feed-per-host qualifier: bare `<stem>.feed-...-staged`
    when one listing has feed:; `<stem>-<listing-id>.feed-...-staged`
    when multiple do (D7).
  - feed.items default 20; feed.items: 0 treated as missing
    (matches Q1).
  - prepareItems-equivalent: items missing both title and
    output_href are skipped.
  - lastBuildDate computed from the most-recent item.date
    (parsed via the binding's RFC 2822/3339/ISO date helper);
    falls back to "now" via OffsetDateTime::now_utc.
  - Native-only via the parent module's cfg gate.
- crates/quarto-core/src/project/listing/feed/mod.rs
  - `pub mod stage;` + re-export of the transform.
- crates/quarto-core/src/pipeline.rs
  - Registers ListingFeedStageTransform after
    CategoriesSidebarTransform in
    build_html_pipeline_stages_with_apply_config. Push site
    gated with `#[cfg(not(target_arch = "wasm32"))]` because the
    type is native-only; the WASM pipeline doesn't compile this
    arm.
- claude-notes/plans/2026-05-08-listings-L9-rss-feeds.md
  - Phase-3 checklist marked complete.

Tests (10 new):
- stage_writes_metadata_feed_with_inline_descriptions: CDATA
  inlining for type: metadata.
- stage_writes_partial_feed_with_placeholders: B4F502887207
  envelope for type: partial.
- stage_writes_full_feed_with_placeholders: same envelope for
  type: full.
- stage_emits_q_12_15_when_no_site_url: exactly one Q-12-15
  diagnostic; no staged file.
- stage_writes_per_category_subfeeds: filtered items per
  category file; main file still emits all items.
- stage_truncates_to_feed_items_count: feed.items: 3 → 3 items.
- stage_uses_default_20_items: 30 items → 20 emitted.
- stage_skips_when_no_listing_feed: no staged file written.
- stage_xml_stylesheet_pi_emitted_when_set: <?xml-stylesheet ?>
  PI present in preamble.
- stage_qualifies_filename_for_multi_feed_hosts: per-listing
  qualifier kicks in when >1 listing has feed:.

Verified:
- cargo nextest run -p quarto-core 'project::listing::feed'
  → 42 passed (32 binding + 10 stage), 0 failed.
- cargo nextest run --workspace → 8755 passed, 0 failed.
- cargo xtask lint → 699 files checked, all clean.
- npm run build:wasm (hub-client) → success. The cfg-gated
  registration keeps the native-only types out of the WASM
  build, which was caught by the first run and fixed before
  this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Pass-2 transform that injects
<link rel="alternate" type="application/rss+xml" title="..." href="...">
into the host page's head metadata for every feed-configured
listing. **Runs on both native and WASM** so the rendered HTML
matches between the CLI render and the hub-client preview.

What's in the diff:
- crates/quarto-core/src/project/listing/feed/link_inject.rs
  (new) — Pass-2 transform. Appends to the canonical
  rendered.includes.header slot (favicon precedent at
  website_favicon.rs:74). Includes a small inline copy of
  `append_to_rendered_header` and `escape_html_attr` —
  follow-up bd will hoist them to a shared util once a third
  caller exists. Sits OUTSIDE the cfg(not(wasm32)) gate;
  the file uses no native-only APIs.
- crates/quarto-core/src/project/listing/feed/mod.rs —
  unconditional `pub mod link_inject;` + re-export. Doc
  comment now describes module-gate granularity in concrete
  terms.
- crates/quarto-core/src/pipeline.rs — register the transform
  via `pipeline.push(...)` in `build_transform_pipeline`
  (the function the AST-transforms stage builds JIT). Both
  native and WASM pipelines pick it up; the
  ListingFeedStageTransform registration just above remains
  cfg(not(wasm32))-gated.

Tests (6 new):
- link_inject_adds_alternate_for_main_feed: feed:true with
  website.title produces the expected link tag with
  href="<host-stem>.xml" and title fallback.
- link_inject_skips_when_no_feed: no feed: → no alternate
  link.
- link_inject_skips_when_no_site_url: feed:true with no
  website.site-url → no alternate link (Q-12-15 emission
  still belongs to the stage transform; this skips silently).
- link_inject_uses_feed_title_when_set: feed.title=My Feed
  wins over website.title.
- link_inject_multi_listing_emits_qualified_hrefs: two
  feed-configured listings → two alternate links with
  qualified hrefs (D7).
- escape_html_attr_handles_special_characters: helper unit
  test for & < > ".

Verified:
- cargo nextest run -p quarto-core 'project::listing::feed::link_inject'
  → 6 passed, 0 failed.
- cargo nextest run -p quarto-core → 1869 passed, 33 skipped.
- npm run build:wasm (hub-client) → success. The transform
  is reachable via `AstTransformsStage::new()` on both
  native and WASM pipelines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the listings-RSS subset of Q1's `readRenderedContents`
that the upcoming `complete_staged_feeds` post-render step
will consume to substitute placeholder envelopes with
engine-rendered preview content from sibling outputs.

What's in the diff:
- crates/quarto-core/src/project/listing/feed/reader_ext.rs
  (new) — two public extractors:
  - `extract_first_para_html(html, max_length)`: inner HTML
    of the first non-empty <p> in `main.content`, with <a>
    tags unwrapped (Q1 partial-feed behavior). Truncation
    falls back to plain-text + word-boundary cut when needed.
  - `extract_full_contents(html, site_url, sibling_href)`:
    inner HTML of `main.content` with the L9 full-feed
    transforms — title-block-header removed, relative
    href/src rewritten to absolute (resolved against
    site_url + sibling's parent dir), `<a href="#anchor">`
    unwrapped. External URLs (http/https//mailto/data/etc.)
    and scheme-relative URLs pass through.
  Native-only via the parent module's cfg gate (depends on
  `scraper` and `regex`, both already in `quarto-core`'s
  native dep set).
- crates/quarto-core/src/project/listing/feed/mod.rs —
  declare `pub mod reader_ext;` (cfg-gated to native).

Sibling, not extension: per the L7 bracketing rule and L9
plan D11, this file is independent of
`post_render_upgrade/reader.rs`. L7's reader stays scoped
to listings-display extraction; L9's RSS reader lives here.
Shared helpers may emerge later but aren't introduced
speculatively in v1.

v1 limitations (filed as L9 close-out follow-ups):
- Truncation under `max_length` produces plain text rather
  than tag-balanced HTML. `scraper` is read-only so DOM
  mutation isn't available; Q1's tree-walking truncator
  has no direct analogue. Subscribers see truncated plain
  text; the linked-to HTML page has the full version.
- Math (`<span class="math">…`) and syntax-highlight class
  maps pass through verbatim.

Tests (18 new):
- extract_first_para_html: returns inner HTML, returns None
  when no main.content, skips empty paragraphs, unwraps
  anchors (with and without inline children), truncates at
  word boundary, no truncation when max=0.
- extract_full_contents: rewrites relative href and src to
  absolute, passes external/mailto/data URLs through,
  resolves site-rooted (`/about.html`) paths, strips local
  anchor links (`href="#…"`) while keeping external anchors,
  removes `<header id="title-block-header">`, returns None
  when no main.content.
- Helper units: `collapse_relative_strips_dotdot`,
  `parent_href_string_handles_root_and_nested`,
  `visible_text_drops_tags_and_decodes_entities`.

Verified:
- cargo nextest run -p quarto-core
  'project::listing::feed::reader_ext' → 18 passed.
- cargo nextest run -p quarto-core → 1887 passed
  (was 1869 before phase 5).
- Native build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the post-render step that finalizes each staged feed
file the stage transform emitted: walks output_dir for
*.feed-{full|partial|metadata}-staged files, substitutes the
description-element placeholder envelopes against engine-
rendered sibling HTML using the L9 reader extractors, and
writes the final .xml (then removes the staged original).

What's in the diff:
- crates/quarto-core/src/project/listing/feed/complete.rs
  (new) — `complete_staged_feeds(project, runtime, diagnostics)`.
  - Recursive walk under project.output_dir; strict suffix
    filter on the three staged extensions.
  - StagedType discriminator (Full/Partial/Metadata) lifted
    from the staged filename.
  - Per-call HashMap<PathBuf, Option<String>> cache so a
    sibling shared across multiple feeds (e.g. main +
    per-category sub-feeds) is read at most once per
    post_render invocation.
  - Q-12-16 emitted at most once per missing sibling
    (tracked in a separate HashSet so cache misses don't
    duplicate the warning).
  - Body wrapped in <![CDATA[…]]> after substitution. v1
    doesn't escape the rare `]]>` case in body content
    (filed as a follow-up bd at L9 close-out).
  - File-write goes through `runtime.file_write` for the
    final .xml; staged removal uses std::fs (best-effort).
  - Errors during one feed are reported as diagnostics
    and don't abort the whole step.
- crates/quarto-core/src/project/listing/feed/mod.rs —
  declare `pub mod complete;` + re-export
  `complete_staged_feeds`.
- crates/quarto-core/src/project/orchestrator.rs —
  call `feed::complete_staged_feeds(project, runtime,
  diagnostics)` from `WebsiteProjectType::post_render`,
  after the L7 substitute step (so any host-page HTML L7
  rewrote is on disk before L9's reader extractors read
  it). Inside the existing `cfg(not(target_arch = "wasm32"))`
  block; native-only end-to-end.

Tests (9 new):
- complete_renames_metadata_staged_to_xml: metadata feed
  copied verbatim; staged removed.
- complete_substitutes_partial_descriptions: first-para
  HTML wrapped in CDATA replaces the placeholder.
- complete_substitutes_full_descriptions_with_absolute_urls:
  full-content HTML with `<a href>` rewritten to absolute
  appears inside CDATA.
- complete_emits_q_12_16_when_sibling_missing: exactly
  one Q-12-16; description left empty.
- complete_caches_sibling_reads_per_call: two staged files
  reference the same sibling; both substitute correctly.
- complete_skips_when_no_site_url: staged files remain;
  no .xml produced.
- complete_handles_concurrent_per_category_files: main +
  two per-category staged files all finalize correctly;
  shared sibling read once (verified implicitly by the
  cache structure).
- complete_walks_nested_directories: nested
  `_site/posts/index.feed-…` is found and finalized.
- staged_type_from_filename: unit test for the
  StagedType discriminator.

Verified:
- cargo nextest run -p quarto-core 'project::listing::feed::complete'
  → 9 passed, 0 failed.
- cargo nextest run -p quarto-core → 1896 passed
  (was 1887 before phase 6; +9 new).
- cargo xtask lint → 702 files checked, all clean.
- npm run build:wasm (hub-client) → success. The
  feed::complete_staged_feeds call lives inside the
  existing `cfg(not(target_arch = "wasm32"))` block in
  WebsiteProjectType::post_render, so the WASM target
  doesn't need to compile it.

This completes the L9 implementation surface (phases 1–6).
Phase 7 is end-to-end CLI verification — building real-binary
fixtures and inspecting the rendered .xml output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes out the L9 implementation by exercising the feature
through the real `q2 render` binary against four fixtures —
metadata / partial / full+categories / no-site-url — with
the inspected output snippets captured in the plan doc per
CLAUDE.md §"End-to-end verification before declaring success".

What's in the diff:
- claude-notes/plans/2026-05-08-listings-L9-rss-feeds.md
  - §"End-to-end CLI verification record" filled in with
    fixture layouts, exact invocations, and verbatim
    snippets of the produced .xml (or its absence, for the
    no-url case).
  - Phase-8 TDD checklist marked complete.
- crates/quarto-core/src/project/listing/feed/mod.rs
  - Replace the "(forthcoming)" markers in the module-doc
    bullet list now that all five subordinate modules are
    in place.

Verification fixtures (all under /tmp; not part of the
repo):
- /tmp/l9-fixture-metadata: metadata feed inlines
  descriptions verbatim from the post's frontmatter
  description; CDATA-wrapped; pubDate in RFC 2822;
  dc:creator per author; channel cascade
  (feed.title → website.title) verified.
- /tmp/l9-fixture-partial: partial feed extracts the first
  <p> from the rendered HTML and preserves inline formatting
  (<strong> survived; subsequent paragraphs absent).
- /tmp/l9-fixture-full-categories: full feed includes the
  whole <main class="content"> with relative URLs rewritten
  to absolute (../about.html → https://example.com/about.html),
  local-anchor href="#summary" unwrapped, and section markup
  preserved. Per-category sub-feeds correctly filtered.
- /tmp/l9-fixture-no-url: Q-12-15 emitted exactly once;
  no .xml files produced; host page renders normally.

After all four renders, `find _site -name "*.feed-*-staged"`
is empty — the post-render `complete_staged_feeds` step
deletes every staged file once the .xml is written.

Verified:
- Full `cargo xtask verify` (Rust build + nextest +
  hub-client build:all + hub-client test:ci) → All
  verification steps passed.
- Worked through each fixture's output by hand; the
  highlights captured in the plan doc are the actual
  observed behavior, not a transcribed expectation.

L9 implementation surface is now complete. Remaining
work is the close-out commit (mark bd-o90m closed, file
follow-up bds for the deferred items in §"Out of scope
for L9", update the listings epic table).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
L9 (RSS feeds) implementation complete on
beads/bd-o90m-listings-rss-feeds; awaiting user merge
approval. See the L9 plan (committed in `b8c9b8b5`) and
the seven phase commits (`8b7a9286` … `0bdd219e`) for the
implementation; closing bd-o90m here.

Filed 13 follow-up bds with discovered-from links to
bd-o90m, covering deferred behaviors (math handling,
highlight class maps, Atom 1.0, custom feed templates,
date_format pipe, etc.) and v1 limitations (HTML-aware
truncation, CDATA ]]> escape, Q-12-15 dedup, generator
version, hoisting shared header-include helpers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RSS 2.0 feed generation per host page that opts in via
`feed: true` or `feed: { … }`. One staged file per
feed-configured listing during Pass-2; the post-render step
substitutes engine-rendered preview content from sibling
HTML and finalizes each into a real `<stem>.xml` next to the
host page. `<link rel="alternate" type="application/rss+xml">`
is injected into the host's head metadata on both native and
WASM (hub-client preview matches the CLI render byte-for-byte
even though the linked feed file is only written by
`quarto render`).

Three feed types ship: `metadata` (descriptions inlined from
post frontmatter), `partial` (first-paragraph HTML extracted
from the rendered sibling), and `full` (entire `main.content`
with relative URLs rewritten to absolute and local-anchor
`<a href="#…">` tags unwrapped). Per-category sub-feeds via
`feed.categories: [...]` produce extra `<stem>-<category>.xml`
files filtered to items carrying that category.

Architecture:

- Most of `crates/quarto-core/src/project/listing/feed/` is
  native-only via cfg(not(target_arch = "wasm32"))
  (depends on `imagesize` for `<media:content>` dimensions
  and `scraper` + file I/O for the reader extractors).
- `feed/link_inject.rs` sits outside that gate so the
  alternate-link tag flows through both pipelines.
- L9's reader is a strict sibling of L7's, not an extension —
  preserves the L7 bracketing rule. Shared helpers may
  emerge later but aren't introduced speculatively in v1.

Q1 divergence (verified at impl-start): Q1 stores `feed:` on
`ListingSharedOptions` (one feed per host, items merged
across listings on the page). Q2's data model from L2 stores
`feed:` per-Listing, so the common single-listing case
produces the same `<stem>.xml` Q1 emits, but multi-listing
pages get one feed per listing with filenames qualified by
the listing id (`<stem>-<listing-id>.xml`). See the L9 plan
§"Decisions log" D7 for the rationale.

Two new diagnostic codes:
- Q-12-15: `feed:` configured but `website.site-url` is
  missing. Feeds skipped; host page renders normally.
- Q-12-16: a sibling output couldn't be read during
  substitution. Description left empty for that item;
  emitted at most once per missing sibling per
  post_render invocation.

Tests: +76 in quarto-core (1820 → 1896). Full
`cargo xtask verify` (Rust + nextest + WASM hub-client +
hub-client tests) clean. End-to-end CLI verification done
against four fixtures (metadata / partial / full+categories
/ no-url); output snippets recorded in the L9 plan doc.

13 follow-up bds filed for deferred behaviors and v1
limitations (math handling, highlight class maps, Atom 1.0,
custom feed templates, `date_format` pipe, HTML-aware
truncation, Q-12-15 dedup, etc.). See the L9 plan and the
seven phase commits (`8b7a9286`…`0bdd219e`) for the full
implementation, decisions log, and end-to-end record.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
L9 (RSS feeds, bd-o90m) merged into feature/listings at
f5475bb via the seven phase commits 8b7a928…0bdd219e.
Mirrors the L7 / L8 close-out row pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drafts the close-out plan: rolls up 33 open listings follow-ups
into a single table (per-issue ID, priority, type, source phase,
quick-win flag), flags 4 candidate quick wins (bd-xhvs, bd-fvuy,
bd-varx, bd-2vl0), and surfaces 4 decisions for the user to
confirm before any close-out action lands. Paperwork-only L11
recommended; verification + bd close steps are listed as work
items pending those decisions.

L10 (bd-hzsi, migration docs + LLM skill) intentionally stays
open — to be picked up alongside the future Q2 docs site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cscheid cscheid merged commit ccb2200 into main May 8, 2026
4 checks passed
@cscheid cscheid deleted the feature/listings branch May 8, 2026 19:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant