Skip to content

Commit 7dd3bbf

Browse files
cscheidclaude
andcommitted
feat(pampa): block-level attrs on list items (<li class>) (bd-aeyss6p5)
Extend the Para block-attr capability (bd-itqcfxc3, PR #310) to list items: `- item {.foo}` now renders `<li class="foo">` across all three renderers (q2 render HTML, q2 preview JSON->React incl. revealjs incremental, and JSON round-trips). A Pandoc list item is `Vec<Block>` with no per-item Attr field, so an authored item attr lands as a trailing `Inline::Attr` in the item's last block. It is hoisted onto the `<li>` at write time and folded back at read time; the AST shape is unchanged. - block_attr.rs: `split_list_item_attr` collects the trailing attr from an item's last Para/Plain and returns a stripped clone of that block. The stripped clone is what keeps the class off an inner `<p>` (precedence trap). - html.rs: `write_list_item` / `composed_list_item_attr` hoist onto `<li>`, composing with the incremental `fragment` class. - json.rs writer: parallel `itemAttr` sibling key (the table `rowsS` precedent), alphabetical key order (c, itemAttr, l?, s, t). A fast path keeps ordinary lists byte-for-byte unchanged; Pandoc ignores `itemAttr`. - json.rs reader: `fold_item_attrs` restores the trailing `Inline::Attr`. - preview-renderer: shared `liItemAttrProps` helper applied in both the incremental component path (BulletList/OrderedList) and the editable registry path (dispatch). `itemAttr` is dropped on in-preview edits (v1). Tests: 7 helper + 6 html + 4 json + 4 React vitest. End-to-end verified through the binary (html; json + pandoc graceful degrade; json->html and json->qmd round-trips). Full `cargo xtask verify` green (14/14). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3cae615 commit 7dd3bbf

12 files changed

Lines changed: 1083 additions & 49 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
# Block-level attributes on list items (`<li class>`)
2+
3+
**Strand:** bd-aeyss6p5 (discovered-from bd-itqcfxc3; related bd-38ioql41)
4+
**Date:** 2026-06-18
5+
**Status:** planning
6+
**Parent plan:** [`2026-06-17-block-level-attrs-inline-attr.md`](./2026-06-17-block-level-attrs-inline-attr.md)
7+
(the `Para` capability, merged in PR #310; see its "List-item JSON
8+
representation — study" section, which this plan implements)
9+
10+
## Goal
11+
12+
Let a block attribute land on a list item's `<li>` — e.g. `<li class="…">`
13+
for per-item styling. `- item {.foo}` should render `<li class="foo">item</li>`
14+
in **all three renderers**: `q2 render` (html.rs), `q2 preview` (json.rs →
15+
React, including the `q2-slides`/revealjs incremental path), and AST→JSON→AST
16+
round-trips.
17+
18+
## Confirmed facts (verified 2026-06-18)
19+
20+
- **The parser already emits the carrier.** `- item one {.foo}` parses to a
21+
list item whose **last block** ends with a trailing `Inline::Attr({.foo})`
22+
(a `Plain` for tight items, a `Para` for loose items). No filter/transform is
23+
needed — the capability is reachable by plain authoring, just like `Para`.
24+
- **Current behavior is broken/dropping:**
25+
- `-t native` and `-t json` both **error `Q-3-32`** (standalone attr) on the
26+
item's trailing `Inline::Attr`.
27+
- `-t html` **drops** the attr (the inline writer skips `Inline::Attr`), and
28+
leaves a stray trailing space: `<li>item one </li>`.
29+
- **Why list items differ from `Para`** (recap of the study section): a Pandoc
30+
list item is `Vec<Block>` — a bare `[Block,…]` **array** in JSON with no
31+
object to hang an extra `attr` key on. So the attr must be **hoisted** out of
32+
the item's last block up to the `<li>`, and carried in JSON via a **parallel
33+
sibling key on the enclosing list node** (the `rowsS` table precedent), not an
34+
object key on the item.
35+
36+
## JSON wire form (resolved)
37+
38+
Mirror the table `rowsS` precedent: a sibling key on the list node, an array
39+
**parallel-indexed** to the items, each entry an `Attr` triple or `null`.
40+
41+
- **Key name:** `itemAttr` (decided; `rowsS`-style suffix doesn't fit since this
42+
is an attr, not source info).
43+
- **Emitted only when at least one item carries a non-empty attr** — so ordinary
44+
lists are byte-for-byte unchanged (keeps the ~3900 existing tests inert).
45+
- **Key order:** alphabetical, matching the house convention
46+
(`stream_write_simple_node` emits `c`, then `l?`, `s`, `t`). `itemAttr` sorts
47+
between `c` and `l`.
48+
49+
BulletList:
50+
```json
51+
{"t":"BulletList","c":[[…item0…],[…item1…]],"itemAttr":[["",["foo"],[]],null],"s":}
52+
```
53+
OrderedList (items live in `c[1]`; `itemAttr` is parallel to `c[1]`):
54+
```json
55+
{"t":"OrderedList","c":[[1,{"t":"Decimal"},{"t":"Period"}],[[…i0…],[…i1…]]],"itemAttr":[null,["",["foo"],[]]],"s":}
56+
```
57+
58+
Pandoc ignores the unknown `itemAttr` key, so `-t json | pandoc -f json` still
59+
degrades to a plain list (verified-by-analogy with `Para.attr`; will re-verify
60+
empirically in the end-to-end step).
61+
62+
## AST representation (no type change)
63+
64+
The item attr **stays** a trailing `Inline::Attr` in the item's last block —
65+
the canonical shape the parser already produces and `qmd`/`incremental` already
66+
round-trip. No new field on `BulletList`/`OrderedList`. The hoist happens only
67+
at **write** time (and the inverse fold at **read** time).
68+
69+
## Shared helper (the keystone)
70+
71+
Add to `crates/pampa/src/writers/block_attr.rs`, reusing
72+
`split_trailing_block_attr` for the merge semantics:
73+
74+
```rust
75+
/// For a list item (`&[Block]`), collect the trailing block attr from its LAST
76+
/// block — but only when that block is a `Paragraph`/`Plain` (the blocks that
77+
/// carry inline content). Returns the merged `Attr` and, when the attr is
78+
/// non-empty, a *stripped clone* of the last block (trailing attr run removed)
79+
/// for the caller to render in place of the original. Returns `(empty_attr,
80+
/// None)` when there is no hoistable attr, so the caller renders the item as-is.
81+
pub(crate) fn split_list_item_attr(item: &[Block]) -> (Attr, Option<Block>);
82+
```
83+
84+
- Clones **only** the last block, and **only** when an attr is present (rare).
85+
This is what kills the **precedence trap**: the stripped clone means the inner
86+
`Para`/`Plain` writer never sees the trailing attr, so the class lands on
87+
`<li>`, never on an inner `<p>`.
88+
- **Limitation (decided 2026-06-18: acceptable for v1):** if the item's last
89+
block is not a `Para`/`Plain` (e.g. it ends with a nested list or code block),
90+
no item attr is hoisted — matches where the parser puts authored attrs.
91+
Revisit only if a consumer needs broader scanning.
92+
93+
All three writers and the reader go through this one helper, so the merge rule
94+
stays single-sourced (no Rust/TS duplication — the cross-language divergence the
95+
project fears).
96+
97+
## Implementation plan (TDD — failing tests first)
98+
99+
### Phase 0 — helper + unit tests ✅
100+
- [x] `split_list_item_attr` in `block_attr.rs` with unit tests: tight item
101+
(last block `Plain`) hoists; loose item (last block `Para`) hoists;
102+
multi-block item hoists from the **last** block; item with no trailing
103+
attr → `(empty, None)`; last block not `Para`/`Plain``(empty, None)`;
104+
empty trailing attr → `(empty, None)`; empty item → `(empty, None)`;
105+
stripped clone has the trailing attr-run removed. **7 new tests, all green.**
106+
107+
### Phase 1 — html.rs (`q2 render`) ✅
108+
- [x] **Failing tests** (mirror `mod tests`): `- item {.foo}``<li class="foo">`;
109+
inner `<p>` does **not** also get the class (loose item); composes with the
110+
incremental `fragment` class (`<li class="fragment foo">`); id + kvs
111+
applied; no stray trailing space; ordinary list unchanged. **6 tests, were
112+
red, now green.**
113+
- [x] Replaced `list_item_open` with `composed_list_item_attr` + `write_list_item`
114+
(per-item open merging `classes = ["fragment"?] ++ item_classes` plus id/kvs
115+
via `write_attr`). Bullet + Ordered loops both call `write_list_item`.
116+
- [x] Renders the item via the helper's stripped clone (`item[..last] + stripped`)
117+
so the trailing attr is gone before `write_blocks_inline` runs.
118+
- [x] **End-to-end:** `pampa -i list.qmd -t html``<li class="foo">item one</li>`.
119+
120+
### Phase 2 — json.rs writer (`q2 preview` transport) ✅
121+
- [x] **Failing tests** (3): `BulletList`/`OrderedList` wire form carries
122+
`itemAttr` parallel to items, `null` for plain items; inner block emitted
123+
**without** the trailing attr; ordinary list emits **no** `itemAttr` key.
124+
- [x] Custom inlined emitter for Bullet/Ordered (fast path = byte-identical
125+
`stream_write_simple_node` when no item attr): helpers `list_item_attrs`,
126+
`stream_write_list_items` (emits stripped items), `stream_write_item_attr_key`
127+
(alphabetical position c→itemAttr→l/s/t).
128+
- [x] **End-to-end:** `pampa -t json` emits `itemAttr`; `pandoc -f json` ignores
129+
it and degrades to a plain `<li>` (no error).
130+
131+
### Phase 3 — readers/json.rs (round-trip) ✅
132+
- [x] **Failing test**: AST→JSON→AST round-trip preserves the item attr (item
133+
with attr regains trailing `Inline::Attr`; item without one stays clean).
134+
- [x] `fold_item_attrs` helper threaded into both `BulletList`/`OrderedList`
135+
arms (mirrors the `Para` `attr` fold-back + table `rowsS`).
136+
- [x] **End-to-end:** `pampa -t json | pampa -f json -t html`
137+
`<li class="foo">item one</li>`.
138+
139+
### Phase 4 — React preview-renderer (both code paths) ✅
140+
- [x] **Failing vitest** (4): attributed item → `<li class="foo">` in registry
141+
path + deck path; incremental composes `class="fragment foo"`; id/`data-*`
142+
applied; null item stays plain. Were red, now green.
143+
- [x] `framework/types.ts`: added optional `itemAttr?: (Attr | null)[]` to
144+
`BulletListBlock`/`OrderedListBlock`.
145+
- [x] Shared helper `framework/listItemAttr.ts` `liItemAttrProps(attr, fragment)`
146+
(single-sources the compose rule, mirrors Rust `composed_list_item_attr`),
147+
exported from `framework/index.ts`.
148+
- [x] Incremental path — `blocks/BulletList.tsx` & `blocks/OrderedList.tsx` use
149+
`liItemAttrProps(node.itemAttr?.[i], incremental)`.
150+
- [x] Non-incremental path — `framework/dispatch.tsx`
151+
`renderChildrenRegistry.BulletList`/`OrderedList` use
152+
`liItemAttrProps(node.itemAttr?.[i], false)` (edit-drop documented inline).
153+
- [x] preview-renderer suites green (219 unit + 233 integration); `tsc` clean.
154+
- **Edit edge case (decided 2026-06-18: accept the drop for v1).** These
155+
registry renderers' `setLocalAst` rebuilds `{t:'BulletList', c:newItems}`
156+
**without** `itemAttr`, so an in-preview text edit of an attributed item
157+
drops its class. We apply `itemAttr` on render but do **not** thread it
158+
through edits in v1 (matches how other sidecar data behaves on edit).
159+
Document the limitation; file a follow-up strand if preservation is wanted.
160+
161+
### Phase 5 — end-to-end + full verify
162+
- [x] `pampa -t html` on real fixtures: bullet `<li class="foo">`, ordered
163+
`<li id="one" class="alpha" data-key="val">` — inspected.
164+
- [x] `-t json | pandoc -f json` degrades gracefully (plain `<li>`, no error).
165+
- [x] `-t json | pampa -f json -t html` roundtrips to `<li class="foo">`.
166+
- [x] `-t json | pampa -f json -t qmd` roundtrips to `{#one .alpha key="val"}` /
167+
`{.beta}` on the items (reader fold-back + qmd writer).
168+
- [x] React render path covered by vitest **integration** tests driving the real
169+
`Ast`/registry component tree, both the registry (non-incremental) and the
170+
`mountInDeck` incremental paths.
171+
- [~] **Live browser** `q2 preview` (WASM-served) revealjs session: NOT yet run.
172+
The JSON contract (Rust tests) + React reads (vitest) cover the chain
173+
piecewise; a live check is the final seal — see status note.
174+
- [x] Full `cargo xtask verify`**all 14 steps passed (exit 0):** lints+clippy
175+
clean, fmt clean, workspace build (`-D warnings`) clean, Rust tests, WASM
176+
rebuilt via hub-build, hub-client tests (600+72+111), preview-renderer
177+
(219+233+74), q2-preview-spa built. Step 14 Playwright E2E skipped (no
178+
`--e2e`).
179+
180+
## Out of scope / deferred
181+
182+
- **`native.rs`** keeps erroring `Q-3-32` on the item's trailing attr (consistent
183+
with the `Para` decision; native's faithfulness contract is a separate call).
184+
- **Strict-pandoc-JSON vs Quarto-2-JSON split.** The current "JSON/native must be
185+
Pandoc-compatible, else `Q-3-32`" decision makes AST inspection during this kind
186+
of work awkward (you can't dump an AST that contains a standalone attr without
187+
first teaching a writer to encode it). Not needed for this feature, but a good
188+
candidate **interstitial strand** if AST inspectability keeps biting. Flagged
189+
per the user's 2026-06-18 note. → file as discovered-from bd-aeyss6p5 if we want it.
190+
- `BlockQuote`/other block-attr carriers (parent plan, separate increments).
191+
- Wiring the bd-38ioql41 reveal-figure-caption consumer (parent plan item).
192+
193+
## Bookkeeping
194+
195+
- `braid update bd-aeyss6p5 --status in_progress` when implementation starts.
196+
- Leave a trail with `braid comment` per phase.
197+
- Snapshot changes (if any list `.snap` files move) get reported per CLAUDE.md.

crates/pampa/src/readers/json.rs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1370,6 +1370,42 @@ fn read_blockss(value: &Value, deserializer: &SourceInfoDeserializer) -> Result<
13701370
.collect()
13711371
}
13721372

1373+
/// Fold a parallel `itemAttr` sibling key back into each list item's last block
1374+
/// as a trailing `Inline::Attr` — the inverse of the JSON writer's hoist (the
1375+
/// list-item analog of the `Para` `attr` fold-back and the table `rowsS`
1376+
/// threading). See bd-aeyss6p5. Entries are parallel-indexed to `items`; a `null`
1377+
/// (or empty) entry, or an item whose last block is not a `Para`/`Plain`, is
1378+
/// skipped.
1379+
fn fold_item_attrs(
1380+
items: &mut [Vec<Block>],
1381+
item_attr_val: Option<&Value>,
1382+
source_info: &quarto_source_map::SourceInfo,
1383+
) -> Result<()> {
1384+
let Some(arr) = item_attr_val.and_then(|v| v.as_array()) else {
1385+
return Ok(());
1386+
};
1387+
for (item, entry) in items.iter_mut().zip(arr) {
1388+
if entry.is_null() {
1389+
continue;
1390+
}
1391+
let attr = read_attr(entry)?;
1392+
if crate::pandoc::attr::is_empty_attr(&attr) {
1393+
continue;
1394+
}
1395+
let node = Inline::Attr(InlineAttr::new(
1396+
attr,
1397+
AttrSourceInfo::empty(),
1398+
source_info.clone(),
1399+
));
1400+
match item.last_mut() {
1401+
Some(Block::Paragraph(p)) => p.content.push(node),
1402+
Some(Block::Plain(p)) => p.content.push(node),
1403+
_ => {}
1404+
}
1405+
}
1406+
Ok(())
1407+
}
1408+
13731409
fn read_list_attributes(value: &Value) -> Result<ListAttributes> {
13741410
let arr = value.as_array().ok_or_else(|| {
13751411
JsonReadError::InvalidType("Expected array for ListAttributes".to_string())
@@ -1947,7 +1983,8 @@ fn read_block(value: &Value, deserializer: &SourceInfoDeserializer) -> Result<Bl
19471983
));
19481984
}
19491985
let attr = read_list_attributes(&arr[0])?;
1950-
let content = read_blockss(&arr[1], deserializer)?;
1986+
let mut content = read_blockss(&arr[1], deserializer)?;
1987+
fold_item_attrs(&mut content, obj.get("itemAttr"), &source_info)?;
19511988
Ok(Block::OrderedList(OrderedList {
19521989
attr,
19531990
content,
@@ -1958,7 +1995,8 @@ fn read_block(value: &Value, deserializer: &SourceInfoDeserializer) -> Result<Bl
19581995
let c = obj
19591996
.get("c")
19601997
.ok_or_else(|| JsonReadError::MissingField("c".to_string()))?;
1961-
let content = read_blockss(c, deserializer)?;
1998+
let mut content = read_blockss(c, deserializer)?;
1999+
fold_item_attrs(&mut content, obj.get("itemAttr"), &source_info)?;
19622000
Ok(Block::BulletList(BulletList {
19632001
content,
19642002
source_info,

0 commit comments

Comments
 (0)