diff --git a/claude-notes/plans/2026-06-18-list-item-block-attrs.md b/claude-notes/plans/2026-06-18-list-item-block-attrs.md
new file mode 100644
index 000000000..ad152be22
--- /dev/null
+++ b/claude-notes/plans/2026-06-18-list-item-block-attrs.md
@@ -0,0 +1,197 @@
+# Block-level attributes on list items (`
`)
+
+**Strand:** bd-aeyss6p5 (discovered-from bd-itqcfxc3; related bd-38ioql41)
+**Date:** 2026-06-18
+**Status:** planning
+**Parent plan:** [`2026-06-17-block-level-attrs-inline-attr.md`](./2026-06-17-block-level-attrs-inline-attr.md)
+(the `Para` capability, merged in PR #310; see its "List-item JSON
+representation — study" section, which this plan implements)
+
+## Goal
+
+Let a block attribute land on a list item's `
` — e.g. `
`
+for per-item styling. `- item {.foo}` should render `
item
`
+in **all three renderers**: `q2 render` (html.rs), `q2 preview` (json.rs →
+React, including the `q2-slides`/revealjs incremental path), and AST→JSON→AST
+round-trips.
+
+## Confirmed facts (verified 2026-06-18)
+
+- **The parser already emits the carrier.** `- item one {.foo}` parses to a
+ list item whose **last block** ends with a trailing `Inline::Attr({.foo})`
+ (a `Plain` for tight items, a `Para` for loose items). No filter/transform is
+ needed — the capability is reachable by plain authoring, just like `Para`.
+- **Current behavior is broken/dropping:**
+ - `-t native` and `-t json` both **error `Q-3-32`** (standalone attr) on the
+ item's trailing `Inline::Attr`.
+ - `-t html` **drops** the attr (the inline writer skips `Inline::Attr`), and
+ leaves a stray trailing space: `
item one
`.
+- **Why list items differ from `Para`** (recap of the study section): a Pandoc
+ list item is `Vec` — a bare `[Block,…]` **array** in JSON with no
+ object to hang an extra `attr` key on. So the attr must be **hoisted** out of
+ the item's last block up to the `
`, and carried in JSON via a **parallel
+ sibling key on the enclosing list node** (the `rowsS` table precedent), not an
+ object key on the item.
+
+## JSON wire form (resolved)
+
+Mirror the table `rowsS` precedent: a sibling key on the list node, an array
+**parallel-indexed** to the items, each entry an `Attr` triple or `null`.
+
+- **Key name:** `itemAttr` (decided; `rowsS`-style suffix doesn't fit since this
+ is an attr, not source info).
+- **Emitted only when at least one item carries a non-empty attr** — so ordinary
+ lists are byte-for-byte unchanged (keeps the ~3900 existing tests inert).
+- **Key order:** alphabetical, matching the house convention
+ (`stream_write_simple_node` emits `c`, then `l?`, `s`, `t`). `itemAttr` sorts
+ between `c` and `l`.
+
+BulletList:
+```json
+{"t":"BulletList","c":[[…item0…],[…item1…]],"itemAttr":[["",["foo"],[]],null],"s":…}
+```
+OrderedList (items live in `c[1]`; `itemAttr` is parallel to `c[1]`):
+```json
+{"t":"OrderedList","c":[[1,{"t":"Decimal"},{"t":"Period"}],[[…i0…],[…i1…]]],"itemAttr":[null,["",["foo"],[]]],"s":…}
+```
+
+Pandoc ignores the unknown `itemAttr` key, so `-t json | pandoc -f json` still
+degrades to a plain list (verified-by-analogy with `Para.attr`; will re-verify
+empirically in the end-to-end step).
+
+## AST representation (no type change)
+
+The item attr **stays** a trailing `Inline::Attr` in the item's last block —
+the canonical shape the parser already produces and `qmd`/`incremental` already
+round-trip. No new field on `BulletList`/`OrderedList`. The hoist happens only
+at **write** time (and the inverse fold at **read** time).
+
+## Shared helper (the keystone)
+
+Add to `crates/pampa/src/writers/block_attr.rs`, reusing
+`split_trailing_block_attr` for the merge semantics:
+
+```rust
+/// For a list item (`&[Block]`), collect the trailing block attr from its LAST
+/// block — but only when that block is a `Paragraph`/`Plain` (the blocks that
+/// carry inline content). Returns the merged `Attr` and, when the attr is
+/// non-empty, a *stripped clone* of the last block (trailing attr run removed)
+/// for the caller to render in place of the original. Returns `(empty_attr,
+/// None)` when there is no hoistable attr, so the caller renders the item as-is.
+pub(crate) fn split_list_item_attr(item: &[Block]) -> (Attr, Option);
+```
+
+- Clones **only** the last block, and **only** when an attr is present (rare).
+ This is what kills the **precedence trap**: the stripped clone means the inner
+ `Para`/`Plain` writer never sees the trailing attr, so the class lands on
+ `
`, never on an inner `
`.
+- **Limitation (decided 2026-06-18: acceptable for v1):** if the item's last
+ block is not a `Para`/`Plain` (e.g. it ends with a nested list or code block),
+ no item attr is hoisted — matches where the parser puts authored attrs.
+ Revisit only if a consumer needs broader scanning.
+
+All three writers and the reader go through this one helper, so the merge rule
+stays single-sourced (no Rust/TS duplication — the cross-language divergence the
+project fears).
+
+## Implementation plan (TDD — failing tests first)
+
+### Phase 0 — helper + unit tests ✅
+- [x] `split_list_item_attr` in `block_attr.rs` with unit tests: tight item
+ (last block `Plain`) hoists; loose item (last block `Para`) hoists;
+ multi-block item hoists from the **last** block; item with no trailing
+ attr → `(empty, None)`; last block not `Para`/`Plain` → `(empty, None)`;
+ empty trailing attr → `(empty, None)`; empty item → `(empty, None)`;
+ stripped clone has the trailing attr-run removed. **7 new tests, all green.**
+
+### Phase 1 — html.rs (`q2 render`) ✅
+- [x] **Failing tests** (mirror `mod tests`): `- item {.foo}` → `
`;
+ inner `
` does **not** also get the class (loose item); composes with the
+ incremental `fragment` class (`
`); id + kvs
+ applied; no stray trailing space; ordinary list unchanged. **6 tests, were
+ red, now green.**
+- [x] Replaced `list_item_open` with `composed_list_item_attr` + `write_list_item`
+ (per-item open merging `classes = ["fragment"?] ++ item_classes` plus id/kvs
+ via `write_attr`). Bullet + Ordered loops both call `write_list_item`.
+- [x] Renders the item via the helper's stripped clone (`item[..last] + stripped`)
+ so the trailing attr is gone before `write_blocks_inline` runs.
+- [x] **End-to-end:** `pampa -i list.qmd -t html` → `
item one
`.
+
+### Phase 2 — json.rs writer (`q2 preview` transport) ✅
+- [x] **Failing tests** (3): `BulletList`/`OrderedList` wire form carries
+ `itemAttr` parallel to items, `null` for plain items; inner block emitted
+ **without** the trailing attr; ordinary list emits **no** `itemAttr` key.
+- [x] Custom inlined emitter for Bullet/Ordered (fast path = byte-identical
+ `stream_write_simple_node` when no item attr): helpers `list_item_attrs`,
+ `stream_write_list_items` (emits stripped items), `stream_write_item_attr_key`
+ (alphabetical position c→itemAttr→l/s/t).
+- [x] **End-to-end:** `pampa -t json` emits `itemAttr`; `pandoc -f json` ignores
+ it and degrades to a plain `
` (no error).
+
+### Phase 3 — readers/json.rs (round-trip) ✅
+- [x] **Failing test**: AST→JSON→AST round-trip preserves the item attr (item
+ with attr regains trailing `Inline::Attr`; item without one stays clean).
+- [x] `fold_item_attrs` helper threaded into both `BulletList`/`OrderedList`
+ arms (mirrors the `Para` `attr` fold-back + table `rowsS`).
+- [x] **End-to-end:** `pampa -t json | pampa -f json -t html` →
+ `
` in registry
+ path + deck path; incremental composes `class="fragment foo"`; id/`data-*`
+ applied; null item stays plain. Were red, now green.
+- [x] `framework/types.ts`: added optional `itemAttr?: (Attr | null)[]` to
+ `BulletListBlock`/`OrderedListBlock`.
+- [x] Shared helper `framework/listItemAttr.ts` `liItemAttrProps(attr, fragment)`
+ (single-sources the compose rule, mirrors Rust `composed_list_item_attr`),
+ exported from `framework/index.ts`.
+- [x] Incremental path — `blocks/BulletList.tsx` & `blocks/OrderedList.tsx` use
+ `liItemAttrProps(node.itemAttr?.[i], incremental)`.
+- [x] Non-incremental path — `framework/dispatch.tsx`
+ `renderChildrenRegistry.BulletList`/`OrderedList` use
+ `liItemAttrProps(node.itemAttr?.[i], false)` (edit-drop documented inline).
+- [x] preview-renderer suites green (219 unit + 233 integration); `tsc` clean.
+ - **Edit edge case (decided 2026-06-18: accept the drop for v1).** These
+ registry renderers' `setLocalAst` rebuilds `{t:'BulletList', c:newItems}`
+ **without** `itemAttr`, so an in-preview text edit of an attributed item
+ drops its class. We apply `itemAttr` on render but do **not** thread it
+ through edits in v1 (matches how other sidecar data behaves on edit).
+ Document the limitation; file a follow-up strand if preservation is wanted.
+
+### Phase 5 — end-to-end + full verify
+- [x] `pampa -t html` on real fixtures: bullet `
`, no error).
+- [x] `-t json | pampa -f json -t html` roundtrips to `
`.
+- [x] `-t json | pampa -f json -t qmd` roundtrips to `{#one .alpha key="val"}` /
+ `{.beta}` on the items (reader fold-back + qmd writer).
+- [x] React render path covered by vitest **integration** tests driving the real
+ `Ast`/registry component tree, both the registry (non-incremental) and the
+ `mountInDeck` incremental paths.
+- [~] **Live browser** `q2 preview` (WASM-served) revealjs session: NOT yet run.
+ The JSON contract (Rust tests) + React reads (vitest) cover the chain
+ piecewise; a live check is the final seal — see status note.
+- [x] Full `cargo xtask verify` — **all 14 steps passed (exit 0):** lints+clippy
+ clean, fmt clean, workspace build (`-D warnings`) clean, Rust tests, WASM
+ rebuilt via hub-build, hub-client tests (600+72+111), preview-renderer
+ (219+233+74), q2-preview-spa built. Step 14 Playwright E2E skipped (no
+ `--e2e`).
+
+## Out of scope / deferred
+
+- **`native.rs`** keeps erroring `Q-3-32` on the item's trailing attr (consistent
+ with the `Para` decision; native's faithfulness contract is a separate call).
+- **Strict-pandoc-JSON vs Quarto-2-JSON split.** The current "JSON/native must be
+ Pandoc-compatible, else `Q-3-32`" decision makes AST inspection during this kind
+ of work awkward (you can't dump an AST that contains a standalone attr without
+ first teaching a writer to encode it). Not needed for this feature, but a good
+ candidate **interstitial strand** if AST inspectability keeps biting. Flagged
+ per the user's 2026-06-18 note. → file as discovered-from bd-aeyss6p5 if we want it.
+- `BlockQuote`/other block-attr carriers (parent plan, separate increments).
+- Wiring the bd-38ioql41 reveal-figure-caption consumer (parent plan item).
+
+## Bookkeeping
+
+- `braid update bd-aeyss6p5 --status in_progress` when implementation starts.
+- Leave a trail with `braid comment` per phase.
+- Snapshot changes (if any list `.snap` files move) get reported per CLAUDE.md.
diff --git a/crates/pampa/src/readers/json.rs b/crates/pampa/src/readers/json.rs
index d505051f1..4ef7bffc4 100644
--- a/crates/pampa/src/readers/json.rs
+++ b/crates/pampa/src/readers/json.rs
@@ -1370,6 +1370,42 @@ fn read_blockss(value: &Value, deserializer: &SourceInfoDeserializer) -> Result<
.collect()
}
+/// Fold a parallel `itemAttr` sibling key back into each list item's last block
+/// as a trailing `Inline::Attr` — the inverse of the JSON writer's hoist (the
+/// list-item analog of the `Para` `attr` fold-back and the table `rowsS`
+/// threading). See bd-aeyss6p5. Entries are parallel-indexed to `items`; a `null`
+/// (or empty) entry, or an item whose last block is not a `Para`/`Plain`, is
+/// skipped.
+fn fold_item_attrs(
+ items: &mut [Vec],
+ item_attr_val: Option<&Value>,
+ source_info: &quarto_source_map::SourceInfo,
+) -> Result<()> {
+ let Some(arr) = item_attr_val.and_then(|v| v.as_array()) else {
+ return Ok(());
+ };
+ for (item, entry) in items.iter_mut().zip(arr) {
+ if entry.is_null() {
+ continue;
+ }
+ let attr = read_attr(entry)?;
+ if crate::pandoc::attr::is_empty_attr(&attr) {
+ continue;
+ }
+ let node = Inline::Attr(InlineAttr::new(
+ attr,
+ AttrSourceInfo::empty(),
+ source_info.clone(),
+ ));
+ match item.last_mut() {
+ Some(Block::Paragraph(p)) => p.content.push(node),
+ Some(Block::Plain(p)) => p.content.push(node),
+ _ => {}
+ }
+ }
+ Ok(())
+}
+
fn read_list_attributes(value: &Value) -> Result {
let arr = value.as_array().ok_or_else(|| {
JsonReadError::InvalidType("Expected array for ListAttributes".to_string())
@@ -1947,7 +1983,8 @@ fn read_block(value: &Value, deserializer: &SourceInfoDeserializer) -> Result Result (&[Inline], Attr)
(&inlines[..run_start], (id, classes, kvs))
}
+/// Collect a block-level attribute for a **list item** (`&[Block]`) so a writer
+/// can hoist it onto the `
`.
+///
+/// A Pandoc list item is `Vec` with no per-item `Attr` field, so an
+/// authored `- item {.foo}` lands as a trailing [`Inline::Attr`] inside the
+/// item's **last block**. This collects that trailing run (via
+/// [`split_trailing_block_attr`]) **only when the last block is a `Paragraph` or
+/// `Plain`** — the blocks that carry inline content.
+///
+/// Returns `(attr, stripped_last)`:
+/// - When a non-empty attr is found, `attr` is the merged block attr and
+/// `stripped_last` is `Some(clone_of_last_block_with_the_trailing_run_removed)`.
+/// The caller renders `item[..last] ++ stripped_last`, so the inner
+/// `Para`/`Plain` writer never sees the trailing attr (the class lands on the
+/// `
`, not an inner `
` — avoiding the precedence trap).
+/// - Otherwise returns `(empty_attr(), None)` and the caller renders the item
+/// unchanged. This covers: no trailing attr; an empty trailing attr; or a last
+/// block that is not a `Paragraph`/`Plain`.
+///
+/// The clone is paid only on the (rare) attributed-item path, and only for the
+/// single last block.
+pub(crate) fn split_list_item_attr(item: &[Block]) -> (Attr, Option) {
+ let Some(last) = item.last() else {
+ return (empty_attr(), None);
+ };
+ let content = match last {
+ Block::Paragraph(p) => &p.content,
+ Block::Plain(p) => &p.content,
+ _ => return (empty_attr(), None),
+ };
+ let (retained, attr) = split_trailing_block_attr(content);
+ if is_empty_attr(&attr) {
+ return (empty_attr(), None);
+ }
+ let retained = retained.to_vec();
+ let stripped = match last {
+ Block::Paragraph(p) => {
+ let mut p = p.clone();
+ p.content = retained;
+ Block::Paragraph(p)
+ }
+ Block::Plain(p) => {
+ let mut p = p.clone();
+ p.content = retained;
+ Block::Plain(p)
+ }
+ _ => unreachable!("last block kind checked above"),
+ };
+ (attr, Some(stripped))
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -184,4 +236,121 @@ mod tests {
assert_eq!(texts(content), vec!["Attr", "Str(x)"]);
assert!(is_empty_attr(&attr));
}
+
+ // --- split_list_item_attr (list-item hoist) ---------------------------
+
+ use quarto_pandoc_types::block::{Paragraph, Plain};
+
+ fn para(inlines: Vec) -> Block {
+ Block::Paragraph(Paragraph {
+ content: inlines,
+ source_info: si(),
+ })
+ }
+
+ fn plain(inlines: Vec) -> Block {
+ Block::Plain(Plain {
+ content: inlines,
+ source_info: si(),
+ })
+ }
+
+ /// Extract the `(kind, inline-texts)` of a block for assertions.
+ fn block_content_texts(block: &Block) -> (&'static str, Vec) {
+ match block {
+ Block::Paragraph(p) => ("Para", texts(&p.content)),
+ Block::Plain(p) => ("Plain", texts(&p.content)),
+ _ => ("?", vec![]),
+ }
+ }
+
+ #[test]
+ fn list_item_tight_plain_hoists_and_strips() {
+ // Tight item: a single `Plain` ending in a trailing attr.
+ let item = vec![plain(vec![
+ str_("item"),
+ space(),
+ attr_node("", &["foo"], &[]),
+ ])];
+ let (attr, stripped) = split_list_item_attr(&item);
+ assert_eq!(attr.1, vec!["foo".to_string()]);
+ let stripped = stripped.expect("attributed item yields a stripped last block");
+ assert_eq!(
+ block_content_texts(&stripped),
+ ("Plain", vec!["Str(item)".to_string()])
+ );
+ }
+
+ #[test]
+ fn list_item_loose_para_hoists_and_strips() {
+ // Loose item: a single `Para` ending in a trailing attr.
+ let item = vec![para(vec![
+ str_("item"),
+ attr_node("the-id", &["foo"], &[("k", "v")]),
+ ])];
+ let (attr, stripped) = split_list_item_attr(&item);
+ assert_eq!(attr.0, "the-id");
+ assert_eq!(attr.1, vec!["foo".to_string()]);
+ assert_eq!(attr.2.get("k"), Some(&"v".to_string()));
+ let stripped = stripped.expect("attributed item yields a stripped last block");
+ assert_eq!(
+ block_content_texts(&stripped),
+ ("Para", vec!["Str(item)".to_string()])
+ );
+ }
+
+ #[test]
+ fn list_item_hoists_from_last_block_only() {
+ // Multi-block item: the attr rides in the LAST block; earlier blocks
+ // are untouched and the stripped clone replaces only the last.
+ let item = vec![
+ para(vec![str_("first")]),
+ para(vec![str_("second"), attr_node("", &["foo"], &[])]),
+ ];
+ let (attr, stripped) = split_list_item_attr(&item);
+ assert_eq!(attr.1, vec!["foo".to_string()]);
+ let stripped = stripped.expect("attributed item yields a stripped last block");
+ assert_eq!(
+ block_content_texts(&stripped),
+ ("Para", vec!["Str(second)".to_string()])
+ );
+ }
+
+ #[test]
+ fn list_item_without_trailing_attr_is_noop() {
+ let item = vec![plain(vec![str_("item")])];
+ let (attr, stripped) = split_list_item_attr(&item);
+ assert!(is_empty_attr(&attr));
+ assert!(stripped.is_none());
+ }
+
+ #[test]
+ fn list_item_with_empty_trailing_attr_is_noop() {
+ let item = vec![plain(vec![str_("item"), attr_node("", &[], &[])])];
+ let (attr, stripped) = split_list_item_attr(&item);
+ assert!(is_empty_attr(&attr));
+ // No hoist: an empty attr means there is nothing to put on the `
`.
+ assert!(stripped.is_none());
+ }
+
+ #[test]
+ fn list_item_non_para_plain_last_block_is_noop() {
+ // Last block is a nested BulletList (no inline content to carry an attr).
+ let inner = Block::BulletList(quarto_pandoc_types::block::BulletList {
+ content: vec![vec![plain(vec![str_("nested")])]],
+ source_info: si(),
+ });
+ let item = vec![para(vec![str_("lead")]), inner];
+ let (attr, stripped) = split_list_item_attr(&item);
+ assert!(is_empty_attr(&attr));
+ assert!(stripped.is_none());
+ }
+
+ #[test]
+ fn list_item_empty_is_noop() {
+ let item: Vec = vec![];
+ let (attr, stripped) = split_list_item_attr(&item);
+ assert!(is_empty_attr(&attr));
+ assert!(stripped.is_none());
+ }
}
diff --git a/crates/pampa/src/writers/html.rs b/crates/pampa/src/writers/html.rs
index 78fe77671..7e31f02dc 100644
--- a/crates/pampa/src/writers/html.rs
+++ b/crates/pampa/src/writers/html.rs
@@ -1266,16 +1266,61 @@ fn write_inlines_as_text(
Ok(())
}
-/// Write block elements
-/// The opening `
` tag for the current incremental state — gains
-/// `class="fragment"` inside an incremental context (revealjs), since Pandoc
-/// list items have no per-item `Attr` to carry it. See Pandoc HTML.hs:493-496.
-fn list_item_open(ctx: &HtmlWriterContext<'_, W>) -> &'static str {
- if ctx.incremental && ctx.config.incremental_lists {
- "
"
- } else {
- "
"
+/// Build the `
`'s composed `Attr` for the current incremental state and any
+/// hoisted item attribute.
+///
+/// Pandoc list items have no per-item `Attr`, so two sources of classes can land
+/// on the `
`:
+/// - `class="fragment"` inside an incremental revealjs context (Pandoc
+/// HTML.hs:493-496), and
+/// - a block attribute hoisted from a trailing `Inline::Attr` in the item's last
+/// block (bd-aeyss6p5).
+///
+/// They compose: `fragment` comes first (so themes keying off `.fragment` keep
+/// working), then the item's own classes; id and key-values come from the item
+/// attr. An empty result (`is_empty_attr`) means a bare `
`.
+fn composed_list_item_attr(item_attr: &Attr, ctx: &HtmlWriterContext<'_, W>) -> Attr {
+ let fragment = ctx.incremental && ctx.config.incremental_lists;
+ let (id, classes, kvs) = item_attr;
+ let mut out_classes: Vec = Vec::with_capacity(classes.len() + 1);
+ if fragment {
+ out_classes.push("fragment".to_string());
+ }
+ for c in classes {
+ if !out_classes.contains(c) {
+ out_classes.push(c.clone());
+ }
+ }
+ (id.clone(), out_classes, kvs.clone())
+}
+
+/// Write one list item as `
…
`. Hoists a trailing block attribute from
+/// the item's last block onto the `
` (composing with the incremental
+/// `fragment` class), and renders the item with that trailing attr stripped so
+/// it never lands on an inner `
expected, got:\n{html}");
+ }
+
+ #[test]
+ fn loose_list_item_attr_lands_on_li_not_inner_p() {
+ // A multi-block (loose) item whose LAST block is a `Para` ending in a
+ // trailing attr: the class must land on `
`, never on the inner
+ // `
` (the precedence trap the stripped clone prevents).
+ let list = bullet_list(vec![vec![
+ li_para(vec![li_str("first")]),
+ li_para(vec![
+ li_str("second"),
+ li_space(),
+ li_attr("", &["foo"], &[]),
+ ]),
+ ]]);
+ let html = render_block_to_html(list);
+ assert!(
+ html.contains("
) — bd-aeyss6p5 --------------------
+//
+// A list item is a bare `[Block,…]` array with no object to hang an `attr` key
+// on (unlike `Para`). So a per-item block attr (an authored `- item {.foo}`,
+// which lands as a trailing `Inline::Attr` in the item's last block) is hoisted
+// into a parallel sibling key `itemAttr` on the list node, mirroring the table
+// `rowsS` precedent. `itemAttr` is parallel-indexed to the items array, each
+// entry an `Attr` triple or `null`, and emitted only when some item has a
+// non-empty attr (so ordinary lists are byte-for-byte unchanged).
+
+/// For each item, collect its hoisted block attr and (when non-empty) a stripped
+/// clone of its last block. See `block_attr::split_list_item_attr`.
+fn list_item_attrs(items: &[Vec]) -> Vec<(Attr, Option)> {
+ items
+ .iter()
+ .map(|item| super::block_attr::split_list_item_attr(item))
+ .collect()
+}
+
+/// Whether any item carries a non-empty hoisted attr.
+fn any_item_attr(strips: &[(Attr, Option)]) -> bool {
+ strips.iter().any(|(attr, _)| !is_empty_attr(attr))
+}
+
+/// Emit the `[[Block]…]` items array, substituting each item's stripped last
+/// block (trailing `Inline::Attr` removed) when one was hoisted — so the inner
+/// `Para`/`Plain` writer never re-emits the attr.
+fn stream_write_list_items(
+ w: &mut JsonStreamWriter,
+ items: &[Vec],
+ strips: &[(Attr, Option)],
+ ctx: &mut JsonWriterContext,
+) -> io::Result<()> {
+ w.begin_array()?;
+ for (item, (_attr, stripped)) in items.iter().zip(strips) {
+ match stripped {
+ Some(last) => {
+ w.begin_array()?;
+ for b in &item[..item.len() - 1] {
+ stream_write_block(w, b, ctx)?;
+ }
+ stream_write_block(w, last, ctx)?;
+ w.end_array()?;
+ }
+ None => stream_write_blocks(w, item, ctx)?,
+ }
+ }
+ w.end_array()?;
+ Ok(())
+}
+
+/// Emit the `itemAttr` sibling key (parallel to the items array) when any item
+/// carries a non-empty attr. Caller must be inside the list node's object and
+/// place this in alphabetical key order (after `c`, before `l`/`s`/`t`).
+fn stream_write_item_attr_key(
+ w: &mut JsonStreamWriter,
+ strips: &[(Attr, Option)],
+) -> io::Result<()> {
+ if !any_item_attr(strips) {
+ return Ok(());
+ }
+ w.key("itemAttr")?;
+ w.begin_array()?;
+ for (attr, _) in strips {
+ if is_empty_attr(attr) {
+ w.null_value()?;
+ } else {
+ stream_write_attr(w, attr)?;
+ }
+ }
+ w.end_array()?;
+ Ok(())
+}
+
/// Emit Caption as `[short?, long]` where long is `[]` if missing.
fn stream_write_caption(
w: &mut JsonStreamWriter,
@@ -3040,19 +3114,47 @@ fn stream_write_block(
Ok(())
},
),
- Block::OrderedList(ol) => stream_write_simple_node(
- w,
- "OrderedList",
- &ol.source_info,
- ctx,
- |w: &mut JsonStreamWriter, ctx: &mut JsonWriterContext| {
+ Block::OrderedList(ol) => {
+ let strips = list_item_attrs(&ol.content);
+ if !any_item_attr(&strips) {
+ // Fast path: ordinary list, byte-for-byte unchanged.
+ stream_write_simple_node(
+ w,
+ "OrderedList",
+ &ol.source_info,
+ ctx,
+ |w: &mut JsonStreamWriter, ctx: &mut JsonWriterContext| {
+ w.begin_array()?;
+ stream_write_list_attributes(w, &ol.attr)?;
+ stream_write_blockss(w, &ol.content, ctx)?;
+ w.end_array()?;
+ Ok(())
+ },
+ )
+ } else {
+ // Inlined for alphabetical wire order (c, itemAttr, l?, s, t).
+ // `itemAttr` is parallel to the items array (`c[1]`).
+ let s_id = ctx.serializer.intern(&ol.source_info);
+ ctx.maybe_record_attribution_for(&ol.source_info, s_id);
+ w.begin_object()?;
+ w.key("c")?;
w.begin_array()?;
stream_write_list_attributes(w, &ol.attr)?;
- stream_write_blockss(w, &ol.content, ctx)?;
+ stream_write_list_items(w, &ol.content, &strips, ctx)?;
w.end_array()?;
+ stream_write_item_attr_key(w, &strips)?;
+ if ctx.serializer.config.include_inline_locations {
+ let ast_context = ctx.serializer.context;
+ stream_write_location_key_if_mapped(w, "l", &ol.source_info, ast_context)?;
+ }
+ w.key("s")?;
+ w.u64_value(s_id as u64)?;
+ w.key("t")?;
+ w.str_value("OrderedList")?;
+ w.end_object()?;
Ok(())
- },
- ),
+ }
+ }
Block::RawBlock(raw) => stream_write_simple_node(
w,
"RawBlock",
@@ -3234,15 +3336,40 @@ fn stream_write_block(
stream_write_inlines(w, &plain.content, ctx)
},
),
- Block::BulletList(bl) => stream_write_simple_node(
- w,
- "BulletList",
- &bl.source_info,
- ctx,
- |w: &mut JsonStreamWriter, ctx: &mut JsonWriterContext| {
- stream_write_blockss(w, &bl.content, ctx)
- },
- ),
+ Block::BulletList(bl) => {
+ let strips = list_item_attrs(&bl.content);
+ if !any_item_attr(&strips) {
+ // Fast path: ordinary list, byte-for-byte unchanged.
+ stream_write_simple_node(
+ w,
+ "BulletList",
+ &bl.source_info,
+ ctx,
+ |w: &mut JsonStreamWriter, ctx: &mut JsonWriterContext| {
+ stream_write_blockss(w, &bl.content, ctx)
+ },
+ )
+ } else {
+ // Inlined so the extra `itemAttr` key lands in alphabetical wire
+ // order (c, itemAttr, l?, s, t).
+ let s_id = ctx.serializer.intern(&bl.source_info);
+ ctx.maybe_record_attribution_for(&bl.source_info, s_id);
+ w.begin_object()?;
+ w.key("c")?;
+ stream_write_list_items(w, &bl.content, &strips, ctx)?;
+ stream_write_item_attr_key(w, &strips)?;
+ if ctx.serializer.config.include_inline_locations {
+ let ast_context = ctx.serializer.context;
+ stream_write_location_key_if_mapped(w, "l", &bl.source_info, ast_context)?;
+ }
+ w.key("s")?;
+ w.u64_value(s_id as u64)?;
+ w.key("t")?;
+ w.str_value("BulletList")?;
+ w.end_object()?;
+ Ok(())
+ }
+ }
Block::BlockMetadata(meta) => stream_write_simple_node(
w,
"BlockMetadata",
@@ -4612,6 +4739,173 @@ mod tests {
}
}
+ // ----------------------------------------------------------------
+ // List-item block attrs (
) — bd-aeyss6p5
+ // ----------------------------------------------------------------
+
+ fn li_attr_plain(text: &str, attr: Option) -> Block {
+ use crate::pandoc::inline::InlineAttr;
+ use crate::pandoc::{Inline, Plain, Space, Str};
+ let mut content = vec![Inline::Str(Str {
+ text: text.to_string(),
+ source_info: SourceInfo::for_test(),
+ })];
+ if let Some(attr) = attr {
+ content.push(Inline::Space(Space {
+ source_info: SourceInfo::for_test(),
+ }));
+ content.push(Inline::Attr(InlineAttr::new(
+ attr,
+ AttrSourceInfo::empty(),
+ SourceInfo::for_test(),
+ )));
+ }
+ Block::Plain(Plain {
+ content,
+ source_info: SourceInfo::for_test(),
+ })
+ }
+
+ fn write_blocks_to_json(blocks: Vec) -> serde_json::Value {
+ let pandoc = crate::pandoc::Pandoc {
+ meta: quarto_pandoc_types::ConfigValue::default(),
+ blocks,
+ };
+ let context = make_test_context();
+ let config = make_test_config();
+ let mut output = Vec::new();
+ write_with_config(&pandoc, &context, &mut output, &config).unwrap();
+ serde_json::from_slice(&output).unwrap()
+ }
+
+ #[test]
+ fn test_bullet_list_item_attr_emits_itemattr_key() {
+ use crate::pandoc::{Block, BulletList};
+
+ let foo: Attr = (String::new(), vec!["foo".to_string()], LinkedHashMap::new());
+ let list = Block::BulletList(BulletList {
+ content: vec![
+ vec![li_attr_plain("item one", Some(foo))],
+ vec![li_attr_plain("item two", None)],
+ ],
+ source_info: SourceInfo::for_test(),
+ });
+ let v = write_blocks_to_json(vec![list]);
+ let node = &v["blocks"][0];
+ assert_eq!(node["t"], "BulletList");
+ // Parallel-indexed sibling key: attr for item0, null for item1.
+ assert_eq!(node["itemAttr"], json!([["", ["foo"], []], null]));
+ // item0's inner Plain has the trailing attr + space stripped from `c`.
+ let item0 = node["c"][0].as_array().expect("item0 is an array");
+ let plain0 = &item0[0];
+ assert_eq!(plain0["t"], "Plain");
+ let plain0_c = plain0["c"].as_array().expect("Plain c is an array");
+ assert_eq!(
+ plain0_c.len(),
+ 1,
+ "trailing attr+space stripped, got {:?}",
+ plain0_c
+ );
+ assert_eq!(plain0_c[0]["t"], "Str");
+ }
+
+ #[test]
+ fn test_ordinary_bullet_list_emits_no_itemattr_key() {
+ use crate::pandoc::{Block, BulletList};
+ let list = Block::BulletList(BulletList {
+ content: vec![
+ vec![li_attr_plain("a", None)],
+ vec![li_attr_plain("b", None)],
+ ],
+ source_info: SourceInfo::for_test(),
+ });
+ let v = write_blocks_to_json(vec![list]);
+ let node = &v["blocks"][0];
+ assert!(
+ node.get("itemAttr").is_none(),
+ "ordinary list must not carry itemAttr, got {:?}",
+ node
+ );
+ }
+
+ #[test]
+ fn test_ordered_list_item_attr_emits_itemattr_key() {
+ use crate::pandoc::{Block, ListNumberDelim, ListNumberStyle, OrderedList};
+ let foo: Attr = (String::new(), vec!["foo".to_string()], LinkedHashMap::new());
+ let list = Block::OrderedList(OrderedList {
+ attr: (1, ListNumberStyle::Decimal, ListNumberDelim::Period),
+ content: vec![
+ vec![li_attr_plain("first", None)],
+ vec![li_attr_plain("second", Some(foo))],
+ ],
+ source_info: SourceInfo::for_test(),
+ });
+ let v = write_blocks_to_json(vec![list]);
+ let node = &v["blocks"][0];
+ assert_eq!(node["t"], "OrderedList");
+ // itemAttr is parallel to the items array (c[1]).
+ assert_eq!(node["itemAttr"], json!([null, ["", ["foo"], []]]));
+ // The items still live at c[1]; list attributes at c[0].
+ let item1 = node["c"][1][1].as_array().expect("item1 is an array");
+ let plain1 = &item1[0];
+ assert_eq!(plain1["c"].as_array().unwrap().len(), 1);
+ }
+
+ #[test]
+ fn test_bullet_list_item_attr_roundtrips() {
+ use crate::pandoc::{Block, BulletList, Inline};
+ use crate::readers::json as json_reader;
+
+ let foo: Attr = (
+ "li-id".to_string(),
+ vec!["foo".to_string()],
+ LinkedHashMap::new(),
+ );
+ let list = Block::BulletList(BulletList {
+ content: vec![
+ vec![li_attr_plain("one", Some(foo))],
+ vec![li_attr_plain("two", None)],
+ ],
+ source_info: SourceInfo::for_test(),
+ });
+ let pandoc = crate::pandoc::Pandoc {
+ meta: quarto_pandoc_types::ConfigValue::default(),
+ blocks: vec![list],
+ };
+ let context = make_test_context();
+ let config = make_test_config();
+ let mut output = Vec::new();
+ write_with_config(&pandoc, &context, &mut output, &config).unwrap();
+ let (read_pandoc, _) = json_reader::read(&mut output.as_slice()).unwrap();
+
+ match &read_pandoc.blocks[0] {
+ Block::BulletList(bl) => {
+ // Item 0's last block regains a trailing Inline::Attr.
+ match bl.content[0].last().unwrap() {
+ Block::Plain(p) => match p.content.last() {
+ Some(Inline::Attr(a)) => {
+ assert_eq!(a.attr.0, "li-id");
+ assert_eq!(a.attr.1, vec!["foo".to_string()]);
+ }
+ other => panic!("expected trailing Inline::Attr, got {:?}", other),
+ },
+ other => panic!("expected Plain, got {:?}", other),
+ }
+ // Item 1 (no attr) is unchanged: just the Str.
+ match bl.content[1].last().unwrap() {
+ Block::Plain(p) => {
+ assert!(
+ !matches!(p.content.last(), Some(Inline::Attr(_))),
+ "item without attr must not gain one"
+ );
+ }
+ other => panic!("expected Plain, got {:?}", other),
+ }
+ }
+ other => panic!("expected BulletList, got {:?}", other),
+ }
+ }
+
// ----------------------------------------------------------------
// Plan 5 Phase 3+4 — writer-side Generated emission
// ----------------------------------------------------------------
diff --git a/ts-packages/preview-renderer/src/framework/dispatch.tsx b/ts-packages/preview-renderer/src/framework/dispatch.tsx
index 9696be7d5..fa5cd94e0 100644
--- a/ts-packages/preview-renderer/src/framework/dispatch.tsx
+++ b/ts-packages/preview-renderer/src/framework/dispatch.tsx
@@ -1,5 +1,6 @@
import React, { useContext } from 'react';
import { RegistryContext } from './RegistryContext';
+import { liItemAttrProps } from './listItemAttr';
import { isAtomicSourceInfo, ATOMIC_KINDS } from '../utils/sourceInfo';
import { isAtomicCustomNode } from '../utils/atomicCustomNodes';
@@ -194,9 +195,14 @@ const renderChildrenRegistry: Record
)),
+ // Non-incremental (editable) list path. A per-item block attr
+ // (`itemAttr[i]`, bd-aeyss6p5) is applied to the
here; the incremental
+ // `fragment` class is never present on this path, so pass `false`. NOTE:
+ // `setLocalAst` rebuilds the list node without `itemAttr`, so an in-preview
+ // text edit drops the item's class — accepted for v1 (see plan).
BulletList: ({ node, setLocalAst, onNavigateToDocument }) =>
(node as BulletListBlock).c.map((item, i) => (
-
{item.map((block, j) => (
+
{item.map((block, j) => (
{
const newItems = [...(node as BulletListBlock).c];
@@ -210,7 +216,7 @@ const renderChildrenRegistry: Record
(node as OrderedListBlock).c[1].map((item, i) => (
-
{item.map((block, j) => (
+
{item.map((block, j) => (
{
const newItems = [...(node as OrderedListBlock).c[1]];
diff --git a/ts-packages/preview-renderer/src/framework/index.ts b/ts-packages/preview-renderer/src/framework/index.ts
index 4b7470ea5..6d2a2d97b 100644
--- a/ts-packages/preview-renderer/src/framework/index.ts
+++ b/ts-packages/preview-renderer/src/framework/index.ts
@@ -17,6 +17,7 @@ export {
useAttributionHover,
} from './attribution';
export { Ast } from './Ast';
+export { liItemAttrProps } from './listItemAttr';
export { Node, renderChildren, renderNode, blockTypes } from './dispatch';
export * from './plainText';
export * from './meta';
diff --git a/ts-packages/preview-renderer/src/framework/listItemAttr.ts b/ts-packages/preview-renderer/src/framework/listItemAttr.ts
new file mode 100644
index 000000000..f0835cdb3
--- /dev/null
+++ b/ts-packages/preview-renderer/src/framework/listItemAttr.ts
@@ -0,0 +1,32 @@
+import type { Attr } from './types';
+
+/**
+ * Compose the DOM props for a list item's `
` from an optional hoisted block
+ * attribute (`itemAttr[i]`, see bd-aeyss6p5) and the incremental `fragment`
+ * class.
+ *
+ * `fragment` is prepended (so theme rules keying off `.fragment` keep working),
+ * then the item's own classes; `id` and `data-*`/`role` come from the attr.
+ * This mirrors the Rust `composed_list_item_attr` HTML writer so `q2 preview`
+ * matches `q2 render`.
+ */
+export function liItemAttrProps(
+ attr: Attr | null | undefined,
+ fragment: boolean,
+): Record {
+ const props: Record = {};
+ const classes: string[] = [];
+ if (fragment) classes.push('fragment');
+ if (attr) {
+ const [id, attrClasses, kvs] = attr;
+ if (id) props.id = id;
+ for (const c of attrClasses) {
+ if (!classes.includes(c)) classes.push(c);
+ }
+ for (const [k, v] of kvs) {
+ if (k.startsWith('data-') || k === 'role') props[k] = v;
+ }
+ }
+ if (classes.length) props.className = classes.join(' ');
+ return props;
+}
diff --git a/ts-packages/preview-renderer/src/framework/types.ts b/ts-packages/preview-renderer/src/framework/types.ts
index 44b3b5ff0..4c9f5c50a 100644
--- a/ts-packages/preview-renderer/src/framework/types.ts
+++ b/ts-packages/preview-renderer/src/framework/types.ts
@@ -23,8 +23,12 @@ export type ParaBlock = { t: 'Para'; c: InlineNode[]; attr?: Attr };
export type PlainBlock = { t: 'Plain'; c: InlineNode[] };
export type HeaderBlock = { t: 'Header'; c: [number, [string, string[], [string, string][]], InlineNode[]] };
export type CodeBlock = { t: 'CodeBlock'; c: [[string, string[], [string, string][]], string] };
-export type BulletListBlock = { t: 'BulletList'; c: BlockNode[][] };
-export type OrderedListBlock = { t: 'OrderedList'; c: [[number, { t: string }, { t: string }], BlockNode[][]] };
+// `itemAttr` is an optional Quarto extension: per-item block attributes hoisted
+// onto the `
`, carried as a sibling key parallel-indexed to the items array
+// (`null` for items without an attr). The Rust JSON writer emits it and Pandoc
+// ignores it. Used for e.g. `
`. See bd-aeyss6p5.
+export type BulletListBlock = { t: 'BulletList'; c: BlockNode[][]; itemAttr?: (Attr | null)[] };
+export type OrderedListBlock = { t: 'OrderedList'; c: [[number, { t: string }, { t: string }], BlockNode[][]]; itemAttr?: (Attr | null)[] };
export type BlockQuoteBlock = { t: 'BlockQuote'; c: BlockNode[] };
export type DivBlock = { t: 'Div'; c: [[string, string[], [string, string][]], BlockNode[]] };
export type HorizontalRuleBlock = { t: 'HorizontalRule' };
diff --git a/ts-packages/preview-renderer/src/q2-preview/blocks/BulletList.tsx b/ts-packages/preview-renderer/src/q2-preview/blocks/BulletList.tsx
index f8a2c0cd6..7fae6d350 100644
--- a/ts-packages/preview-renderer/src/q2-preview/blocks/BulletList.tsx
+++ b/ts-packages/preview-renderer/src/q2-preview/blocks/BulletList.tsx
@@ -1,5 +1,5 @@
import { useContext } from 'react';
-import { Node, renderChildren } from '../../framework';
+import { Node, renderChildren, liItemAttrProps } from '../../framework';
import type { BulletListBlock, NodeArgs } from '../../framework';
import { IncrementalContext } from '../IncrementalContext';
import { PreviewContext } from '../PreviewContext';
@@ -11,17 +11,17 @@ const NOOP = () => {};
* `setLocalAst` for editing). Inside an incremental revealjs context the
* component renders the
s itself so each gets `class="fragment"` — list
* items have no AST attr, so the class is attached here (mirrors the native
- * writer). */
+ * writer). A per-item block attr (`itemAttr[i]`, bd-aeyss6p5) composes with the
+ * `fragment` class. */
export const BulletList = (args: NodeArgs) => {
const { enabled, incremental } = useContext(IncrementalContext);
const ctx = useContext(PreviewContext);
const poolId = (args.node as any).s as string | number | undefined;
if (enabled) {
- const liClass = incremental ? 'fragment' : undefined;
return (