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` → + `
  • item one
  • `. + +### Phase 4 — React preview-renderer (both code paths) ✅ +- [x] **Failing vitest** (4): attributed item → `
  • ` 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 `
  • `, ordered + `
  • ` — inspected. +- [x] `-t json | pandoc -f json` degrades gracefully (plain `
  • `, 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 `

    `. See bd-aeyss6p5. +fn write_list_item( + item: &[Block], + ctx: &mut HtmlWriterContext<'_, W>, +) -> std::io::Result<()> { + let (attr, stripped) = super::block_attr::split_list_item_attr(item); + let li_attr = composed_list_item_attr(&attr, ctx); + write!(ctx, "")?; + match stripped { + Some(last) => { + // Render the earlier blocks verbatim, then the stripped last block, + // so the inner `Para`/`Plain` writer never sees the trailing attr. + let mut rendered: Vec = item[..item.len() - 1].to_vec(); + rendered.push(last); + write_blocks_inline(&rendered, ctx)?; + } + None => write_blocks_inline(item, ctx)?, } + writeln!(ctx, "

  • ")?; + Ok(()) } fn write_block(block: &Block, ctx: &mut HtmlWriterContext<'_, W>) -> std::io::Result<()> { @@ -1356,11 +1401,8 @@ fn write_block(block: &Block, ctx: &mut HtmlWriterContext<'_, W>) -> s write!(ctx, " type=\"{}\"", list_type)?; write_block_source_attrs(block, ctx)?; writeln!(ctx, ">")?; - let li_open = list_item_open(ctx); for item in &list.content { - write!(ctx, "{}", li_open)?; - write_blocks_inline(item, ctx)?; - writeln!(ctx, "")?; + write_list_item(item, ctx)?; } writeln!(ctx, "")?; } @@ -1368,11 +1410,8 @@ fn write_block(block: &Block, ctx: &mut HtmlWriterContext<'_, W>) -> s write!(ctx, "")?; - let li_open = list_item_open(ctx); for item in &list.content { - write!(ctx, "{}", li_open)?; - write_blocks_inline(item, ctx)?; - writeln!(ctx, "")?; + write_list_item(item, ctx)?; } writeln!(ctx, "")?; } @@ -2388,6 +2427,207 @@ mod tests { ); } + // --- list-item block attrs (
  • ) — bd-aeyss6p5 ---------------- + + fn render_block_with_config(block: Block, config: HtmlConfig) -> String { + let ctx = ASTContext::anonymous(); + let pandoc = Pandoc { + meta: ConfigValue::default(), + blocks: vec![block], + }; + let mut output = Vec::new(); + write_with_options(&pandoc, &ctx, &mut output, config).unwrap(); + String::from_utf8(output).unwrap() + } + + fn li_str(text: &str) -> Inline { + Inline::Str(Str { + text: text.to_string(), + source_info: dummy_source_info(), + }) + } + + fn li_space() -> Inline { + use crate::pandoc::inline::Space; + Inline::Space(Space { + source_info: dummy_source_info(), + }) + } + + fn li_attr(id: &str, classes: &[&str], kvs: &[(&str, &str)]) -> Inline { + use crate::pandoc::inline::InlineAttr; + use hashlink::LinkedHashMap; + let mut m = LinkedHashMap::new(); + for (k, v) in kvs { + m.insert(k.to_string(), v.to_string()); + } + let attr = ( + id.to_string(), + classes.iter().map(|s| s.to_string()).collect(), + m, + ); + Inline::Attr(InlineAttr::new( + attr, + AttrSourceInfo::empty(), + dummy_source_info(), + )) + } + + fn li_plain(inlines: Vec) -> Block { + use crate::pandoc::block::Plain; + Block::Plain(Plain { + content: inlines, + source_info: dummy_source_info(), + }) + } + + fn li_para(inlines: Vec) -> Block { + use crate::pandoc::block::Paragraph; + Block::Paragraph(Paragraph { + content: inlines, + source_info: dummy_source_info(), + }) + } + + fn bullet_list(items: Vec>) -> Block { + use crate::pandoc::block::BulletList; + Block::BulletList(BulletList { + content: items, + source_info: dummy_source_info(), + }) + } + + #[test] + fn bullet_list_item_trailing_attr_becomes_li_class() { + // Tight item `- item {.foo}` parses to a single `Plain` ending in a + // trailing standalone attr; it should hoist onto the `
  • `, strip the + // preceding space, and leave no inner `

    `. + let list = bullet_list(vec![ + vec![li_plain(vec![ + li_str("item one"), + li_space(), + li_attr("", &["foo"], &[]), + ])], + vec![li_plain(vec![li_str("item two")])], + ]); + let html = render_block_to_html(list); + assert!( + html.contains("

  • item one
  • "), + "expected `
  • item one
  • `, got:\n{html}", + ); + assert!( + html.contains("
  • item two
  • "), + "plain item should stay `
  • item two
  • `, got:\n{html}", + ); + assert!(!html.contains(" 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("

  • "), + "expected `
  • `, got:\n{html}", + ); + assert!( + html.contains("

    first

    "), + "earlier block should render as a plain `

    `, got:\n{html}", + ); + assert!( + html.contains("

    second

    "), + "inner `

    ` must NOT carry the class, got:\n{html}", + ); + assert!( + !html.contains("

    `, got:\n{html}", + ); + } + + #[test] + fn ordered_list_item_trailing_attr_becomes_li_class() { + let list = Block::OrderedList(crate::pandoc::block::OrderedList { + attr: ( + 1, + crate::pandoc::ListNumberStyle::Decimal, + crate::pandoc::ListNumberDelim::Period, + ), + content: vec![vec![li_plain(vec![ + li_str("item"), + li_space(), + li_attr("", &["foo"], &[]), + ])]], + source_info: dummy_source_info(), + }); + let html = render_block_to_html(list); + assert!( + html.contains("

  • item
  • "), + "expected `
  • item
  • `, got:\n{html}", + ); + } + + #[test] + fn list_item_attr_applies_id_and_kvs() { + let list = bullet_list(vec![vec![li_plain(vec![ + li_str("item"), + li_attr("the-id", &["foo"], &[("data-x", "1")]), + ])]]); + let html = render_block_to_html(list); + assert!( + html.contains("
  • item
  • "), + "expected id+class+kv on `
  • `, got:\n{html}", + ); + } + + #[test] + fn list_item_attr_composes_with_fragment_class() { + // Inside an incremental revealjs context each `
  • ` gets + // `class="fragment"`; an item attr must compose: `fragment foo`. + let config = HtmlConfig { + incremental_lists: true, + incremental_default: true, + ..HtmlConfig::default() + }; + let list = bullet_list(vec![ + vec![li_plain(vec![li_str("a"), li_attr("", &["foo"], &[])])], + vec![li_plain(vec![li_str("b")])], + ]); + let html = render_block_with_config(list, config); + assert!( + html.contains("
  • a
  • "), + "expected `
  • `, got:\n{html}", + ); + assert!( + html.contains("
  • b
  • "), + "plain item keeps just `fragment`, got:\n{html}", + ); + } + + #[test] + fn ordinary_list_is_unchanged() { + let list = bullet_list(vec![ + vec![li_plain(vec![li_str("a")])], + vec![li_plain(vec![li_str("b")])], + ]); + let html = render_block_to_html(list); + assert!(html.contains("
  • a
  • "), "got:\n{html}"); + assert!(html.contains("
  • b
  • "), "got:\n{html}"); + assert!( + !html.contains("class="), + "no classes expected, got:\n{html}" + ); + } + #[test] fn capture_name_dots_become_hyphens_in_class() { let html = render_block_to_html(codeblock_with_hl_spans( diff --git a/crates/pampa/src/writers/json.rs b/crates/pampa/src/writers/json.rs index 5d248a0bf..dcd8a2a7e 100644 --- a/crates/pampa/src/writers/json.rs +++ b/crates/pampa/src/writers/json.rs @@ -2321,6 +2321,80 @@ fn stream_write_blockss( Ok(()) } +// --- List-item block attrs (
  • ) — 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 (
      {args.node.c.map((item, i) => ( -
    • +
    • {item.map((block, j) => ( ) => { if (isEditable) props['data-block-pool-id'] = poolId!; return
        {renderChildren(args)}
      ; } - const liClass = incremental ? 'fragment' : undefined; return (
        {args.node.c[1].map((item, i) => ( -
      1. +
      2. {item.map((block, j) => ( { expect(container.querySelectorAll('li.fragment').length).toBe(0); }); + // List-item block attrs (
      3. ) — bd-aeyss6p5. `itemAttr` is a sibling + // key parallel to the items; the preview applies it to the
      4. , composing + // with the incremental `fragment` class, mirroring the Rust html writer. + const BULLETS_WITH_ITEM_ATTR = { + t: 'BulletList', + c: [[PARA(STR('one'))], [PARA(STR('two'))]], + itemAttr: [['li-id', ['foo'], [['data-x', '1']]], null], + }; + + it('applies itemAttr to
      5. in the non-incremental (registry) path', () => { + const { container } = mount([BULLETS_WITH_ITEM_ATTR]); + const items = container.querySelectorAll('li'); + expect(items.length).toBe(2); + expect(items[0].classList.contains('foo')).toBe(true); + expect(items[0].id).toBe('li-id'); + expect(items[0].getAttribute('data-x')).toBe('1'); + // The second item (null) stays plain. + expect(items[1].classList.length).toBe(0); + expect(items[1].id).toBe(''); + }); + + it('applies itemAttr to OrderedList
      6. (registry path)', () => { + const ol = { + t: 'OrderedList', + c: [ + [1, { t: 'Decimal' }, { t: 'Period' }], + [[PARA(STR('first'))], [PARA(STR('second'))]], + ], + itemAttr: [null, ['', ['bar'], []]], + }; + const { container } = mount([ol]); + const items = container.querySelectorAll('li'); + expect(items[0].classList.contains('bar')).toBe(false); + expect(items[1].classList.contains('bar')).toBe(true); + }); + + it('itemAttr composes with the incremental fragment class (deck path)', () => { + const { container } = mountInDeck([incrementalDiv(BULLETS_WITH_ITEM_ATTR)]); + const items = container.querySelectorAll('li'); + // item 0: fragment + foo + expect(items[0].classList.contains('fragment')).toBe(true); + expect(items[0].classList.contains('foo')).toBe(true); + // item 1: fragment only + expect(items[1].classList.contains('fragment')).toBe(true); + expect(items[1].classList.contains('foo')).toBe(false); + }); + + it('itemAttr applies in the deck path with fragment off', () => { + const { container } = mountInDeck([BULLETS_WITH_ITEM_ATTR]); + const items = container.querySelectorAll('li'); + expect(items[0].classList.contains('foo')).toBe(true); + expect(items[0].classList.contains('fragment')).toBe(false); + }); + it('Div with class="aside" renders as