Skip to main content

satteri_plugin_api/
js_commands.rs

1//! Binary command buffer parser and mutation applicator.
2//!
3//! Reads a command buffer produced by the JS `CommandBuffer` class, converts
4//! commands into arena mutations, and returns the rebuilt arena.
5//!
6//! ## Wire format
7//!
8//! All multi-byte integers are **little-endian**.
9//!
10//! Commands (first byte):
11//!   0x01  REMOVE           [nodeId: u32]
12//!   0x05  INSERT_BEFORE    [nodeId: u32][payloadType: u8][payload...]
13//!   0x06  INSERT_AFTER     [nodeId: u32][payloadType: u8][payload...]
14//!   0x07  PREPEND_CHILD    [nodeId: u32][payloadType: u8][payload...]
15//!   0x08  APPEND_CHILD     [nodeId: u32][payloadType: u8][payload...]
16//!   0x09  WRAP             [nodeId: u32][payloadType: u8][payload...]
17//!   0x0B  REPLACE          [nodeId: u32][payloadType: u8][payload...]
18//!   0x0C  SET_PROPERTY     [nodeId: u32][valueType: u8][nameLen: u32][name...][valueLen: u32][value...]
19//!   0x0D  SET_CHILDREN     [nodeId: u32][payloadType: u8][payload...] (payload is a Root-wrapped child list)
20//!
21//! Value types for SET_PROPERTY:
22//!   0  STRING     : UTF-8 value
23//!   1  BOOL_TRUE  : no value bytes
24//!   2  BOOL_FALSE : no value bytes
25//!   3  SPACE_SEP  : space-separated list (UTF-8)
26//!   4  INT        : value is decimal string, parsed to i64
27//!   5  NULL       : no value bytes
28//!
29//! Payload types:
30//!   0x10  RAW_MARKDOWN     [len: u32][utf8...]
31//!   0x11  RAW_HTML         [len: u32][utf8...]
32//!   0x12  SERDE_JSON       [len: u32][utf8...]
33//!
34//! The MDAST and HAST command paths are deliberately separate functions
35//! (`apply_mdast_commands`, `apply_hast_commands`). Numeric `node_type`
36//! values overlap between the two arenas (e.g. mdast Paragraph=1 collides
37//! with HastNodeType::Element=1), so a single dispatcher trying to handle
38//! both kinds would silently misroute nodes. The phantom-typed `Arena<K>`
39//! signature on each entry point makes a cross-kind call a compile error.
40
41use satteri_arena::{Arena, ArenaBuilder, ArenaKind, Hast, Mdast, StringRef};
42use satteri_ast::commands::{CommandError, JsNode};
43use satteri_ast::hast::HastNodeType;
44use satteri_ast::mdast::codec::*;
45use satteri_ast::mdast::MdastNodeType;
46use satteri_ast::rebuild::{Patch, REF_NODE_TYPE};
47#[cfg(feature = "mdx")]
48use satteri_ast::shared::encode_js_jsx_attrs;
49use satteri_ast::shared::{
50    PROP_BOOL_FALSE, PROP_BOOL_TRUE, PROP_INT, PROP_NULL, PROP_SPACE_SEP, PROP_STRING,
51};
52
53// Must match packages/satteri/src/command-buffer.ts
54const CMD_REMOVE: u8 = 0x01;
55const CMD_INSERT_BEFORE: u8 = 0x05;
56const CMD_INSERT_AFTER: u8 = 0x06;
57const CMD_PREPEND_CHILD: u8 = 0x07;
58const CMD_APPEND_CHILD: u8 = 0x08;
59const CMD_WRAP: u8 = 0x09;
60const CMD_REPLACE: u8 = 0x0B;
61const CMD_SET_PROPERTY: u8 = 0x0C;
62const CMD_SET_CHILDREN: u8 = 0x0D;
63
64const PAYLOAD_RAW_MARKDOWN: u8 = 0x10;
65const PAYLOAD_RAW_HTML: u8 = 0x11;
66const PAYLOAD_SERDE_JSON: u8 = 0x12;
67
68// MDAST field IDs: internal to the set_string_ref / resolve_mdast_field dispatch
69const FIELD_DEPTH: u16 = 0x0001;
70const FIELD_URL: u16 = 0x0010;
71const FIELD_TITLE: u16 = 0x0011;
72const FIELD_LANG: u16 = 0x0020;
73const FIELD_META: u16 = 0x0021;
74const FIELD_VALUE: u16 = 0x0022;
75const FIELD_ALT: u16 = 0x0030;
76const FIELD_ORDERED: u16 = 0x0040;
77const FIELD_START: u16 = 0x0041;
78const FIELD_SPREAD: u16 = 0x0042;
79const FIELD_CHECKED: u16 = 0x0050;
80const FIELD_IDENTIFIER: u16 = 0x0060;
81const FIELD_LABEL: u16 = 0x0061;
82const FIELD_REFERENCE_TYPE: u16 = 0x0062;
83const FIELD_NAME: u16 = 0x0070;
84
85struct BufReader<'a> {
86    data: &'a [u8],
87    pos: usize,
88}
89
90impl<'a> BufReader<'a> {
91    fn new(data: &'a [u8]) -> Self {
92        Self { data, pos: 0 }
93    }
94
95    fn remaining(&self) -> usize {
96        self.data.len() - self.pos
97    }
98
99    fn read_u8(&mut self) -> Result<u8, CommandError> {
100        if self.remaining() < 1 {
101            return Err(CommandError::UnexpectedEof);
102        }
103        let v = self.data[self.pos];
104        self.pos += 1;
105        Ok(v)
106    }
107
108    fn read_u32(&mut self) -> Result<u32, CommandError> {
109        if self.remaining() < 4 {
110            return Err(CommandError::UnexpectedEof);
111        }
112        let v = u32::from_le_bytes([
113            self.data[self.pos],
114            self.data[self.pos + 1],
115            self.data[self.pos + 2],
116            self.data[self.pos + 3],
117        ]);
118        self.pos += 4;
119        Ok(v)
120    }
121
122    fn read_bytes(&mut self, len: usize) -> Result<&'a [u8], CommandError> {
123        if self.remaining() < len {
124            return Err(CommandError::UnexpectedEof);
125        }
126        let slice = &self.data[self.pos..self.pos + len];
127        self.pos += len;
128        Ok(slice)
129    }
130
131    fn read_str(&mut self, len: usize) -> Result<&'a str, CommandError> {
132        let bytes = self.read_bytes(len)?;
133        std::str::from_utf8(bytes).map_err(|_| CommandError::InvalidUtf8)
134    }
135}
136
137/// Whether a plugin-supplied `data` blob carries the `_mdxExplicitJsx: true`
138/// marker — used to set the matching fast-path bit in `MdxJsxElementData`.
139#[cfg(feature = "mdx")]
140fn js_data_is_mdx_explicit(data: &Option<serde_json::Map<String, serde_json::Value>>) -> bool {
141    data.as_ref()
142        .and_then(|m| m.get("_mdxExplicitJsx"))
143        .and_then(serde_json::Value::as_bool)
144        .unwrap_or(false)
145}
146
147/// `data` JSON blob is stored in the per-node `node_data` map; it doesn't
148/// dispatch on node-type bytes, so it's safe under any kind.
149fn apply_data_property<K: ArenaKind>(
150    arena: &mut Arena<K>,
151    node_id: u32,
152    value_type: u8,
153    value_str: &str,
154) {
155    if value_type == PROP_NULL {
156        arena.set_node_data(node_id, Vec::new());
157    } else {
158        arena.set_node_data(node_id, value_str.as_bytes().to_vec());
159    }
160}
161
162/// Resolve an MDAST property name to its field ID for a given node type.
163fn resolve_mdast_field(node_type: u8, name: &str) -> Option<u16> {
164    match (node_type, name) {
165        (2, "depth") => Some(FIELD_DEPTH),
166        (8, "lang") => Some(FIELD_LANG),
167        (8, "meta") => Some(FIELD_META),
168        (8, "value") => Some(FIELD_VALUE),
169        (15, "url") => Some(FIELD_URL),
170        (15, "title") => Some(FIELD_TITLE),
171        (16, "url") => Some(FIELD_URL),
172        (16, "alt") => Some(FIELD_ALT),
173        (16, "title") => Some(FIELD_TITLE),
174        (10 | 13 | 7 | 25 | 26 | 28, "value") => Some(FIELD_VALUE),
175        (27, "meta") => Some(FIELD_META),
176        (27, "value") => Some(FIELD_VALUE),
177        (102..=104, "value") => Some(FIELD_VALUE),
178        (9, "url") => Some(FIELD_URL),
179        (9, "title") => Some(FIELD_TITLE),
180        (5, "ordered") => Some(FIELD_ORDERED),
181        (5, "start") => Some(FIELD_START),
182        (5 | 6, "spread") => Some(FIELD_SPREAD),
183        (6, "checked") => Some(FIELD_CHECKED),
184        (9 | 17 | 18 | 19 | 20, "identifier") => Some(FIELD_IDENTIFIER),
185        (9 | 17 | 18 | 19 | 20, "label") => Some(FIELD_LABEL),
186        (17 | 18 | 20, "referenceType") => Some(FIELD_REFERENCE_TYPE),
187        (100 | 101, "name") => Some(FIELD_NAME),
188        _ => None,
189    }
190}
191
192/// The canonical MDAST type name for a node-type byte, for error messages.
193fn mdast_type_name(node_type: u8) -> String {
194    match MdastNodeType::from_u8(node_type) {
195        Some(t) => t.name().to_string(),
196        None => format!("unknown({node_type})"),
197    }
198}
199
200/// MDAST set-property: writes a typed field (or `data` JSON) onto an MDAST
201/// node. Kind-tight to `Arena<Mdast>` — the HAST element-properties writer
202/// can no longer be reached from here.
203fn apply_mdast_set_property(
204    arena: &mut Arena<Mdast>,
205    node_id: u32,
206    prop_name: &str,
207    value_type: u8,
208    value_str: &str,
209) -> Result<(), CommandError> {
210    if prop_name == "data" {
211        apply_data_property(arena, node_id, value_type, value_str);
212        return Ok(());
213    }
214
215    let node_type = arena.get_node(node_id).node_type;
216
217    // The field name doesn't resolve for this node type at all.
218    let field_id =
219        resolve_mdast_field(node_type, prop_name).ok_or_else(|| CommandError::UnknownField {
220            node_type: mdast_type_name(node_type),
221            name: prop_name.to_string(),
222        })?;
223
224    // The field resolved, so a `None` from the inner writers means the value's
225    // type is one the field can't hold — report that rather than "unknown".
226    let written: Option<()> = match value_type {
227        PROP_STRING | PROP_SPACE_SEP => {
228            let sref = arena.alloc_string(value_str);
229            set_mdast_string_ref(arena, node_id, field_id, sref)
230        }
231        PROP_BOOL_TRUE => apply_mdast_bool(arena, node_id, node_type, field_id, true),
232        PROP_BOOL_FALSE => apply_mdast_bool(arena, node_id, node_type, field_id, false),
233        PROP_INT => {
234            let value: i64 = value_str.parse().unwrap_or(0);
235            apply_mdast_int(arena, node_id, node_type, field_id, value)
236        }
237        PROP_NULL => apply_mdast_null(arena, node_id, node_type, field_id),
238        _ => return Err(CommandError::UnknownCommand(value_type)),
239    };
240    written.ok_or_else(|| CommandError::InvalidPropertyValue {
241        node_type: mdast_type_name(node_type),
242        name: prop_name.to_string(),
243    })
244}
245
246fn apply_mdast_int(
247    arena: &mut Arena<Mdast>,
248    node_id: u32,
249    node_type: u8,
250    field_id: u16,
251    value: i64,
252) -> Option<()> {
253    let data_offset = arena.get_node(node_id).data_offset as usize;
254    let data_len = arena.get_node(node_id).data_len as usize;
255    match (node_type, field_id) {
256        (2, FIELD_DEPTH) => {
257            if data_len >= 1 {
258                arena.type_data[data_offset] = value as u8;
259            }
260        }
261        (5, FIELD_START) => {
262            if data_len >= 4 {
263                arena.type_data[data_offset..data_offset + 4]
264                    .copy_from_slice(&(value as u32).to_ne_bytes());
265            }
266        }
267        (6, FIELD_CHECKED) => {
268            if data_len >= 1 {
269                arena.type_data[data_offset] = value as u8;
270            }
271        }
272        _ => return None,
273    }
274    Some(())
275}
276
277fn apply_mdast_bool(
278    arena: &mut Arena<Mdast>,
279    node_id: u32,
280    node_type: u8,
281    field_id: u16,
282    value: bool,
283) -> Option<()> {
284    let data_offset = arena.get_node(node_id).data_offset as usize;
285    let data_len = arena.get_node(node_id).data_len as usize;
286    match (node_type, field_id) {
287        (5, FIELD_ORDERED) => {
288            if data_len >= 5 {
289                arena.type_data[data_offset + 4] = value as u8;
290            }
291        }
292        (5, FIELD_SPREAD) => {
293            if data_len >= 6 {
294                arena.type_data[data_offset + 5] = value as u8;
295            }
296        }
297        (6, FIELD_SPREAD) => {
298            if data_len >= 2 {
299                arena.type_data[data_offset + 1] = value as u8;
300            }
301        }
302        _ => return None,
303    }
304    Some(())
305}
306
307fn apply_mdast_null(
308    arena: &mut Arena<Mdast>,
309    node_id: u32,
310    node_type: u8,
311    field_id: u16,
312) -> Option<()> {
313    match (node_type, field_id) {
314        (6, FIELD_CHECKED) => {
315            let data_offset = arena.get_node(node_id).data_offset as usize;
316            let data_len = arena.get_node(node_id).data_len as usize;
317            if data_len >= 1 {
318                arena.type_data[data_offset] = 2;
319            }
320            Some(())
321        }
322        _ => set_mdast_string_ref(arena, node_id, field_id, StringRef::empty()),
323    }
324}
325
326fn set_mdast_string_ref(
327    arena: &mut Arena<Mdast>,
328    node_id: u32,
329    field_id: u16,
330    sref: StringRef,
331) -> Option<()> {
332    let node = arena.get_node(node_id);
333    let node_type = node.node_type;
334    let data_offset = node.data_offset as usize;
335
336    let ref_offset = match (node_type, field_id) {
337        // Text/InlineCode/Html/Yaml/Toml/InlineMath: StringRef at 0
338        (10 | 13 | 7 | 25 | 26 | 28, FIELD_VALUE) => 0,
339        // Link: LinkData { url: 0, title: 8 }
340        (15, FIELD_URL) => 0,
341        (15, FIELD_TITLE) => 8,
342        // Image: ImageData { url: 0, alt: 8, title: 16 }
343        (16, FIELD_URL) => 0,
344        (16, FIELD_ALT) => 8,
345        (16, FIELD_TITLE) => 16,
346        // Code: CodeData { lang: 0, meta: 8, value: 16 }
347        (8, FIELD_LANG) => 0,
348        (8, FIELD_META) => 8,
349        (8, FIELD_VALUE) => 16,
350        // Math: MathData { meta: 0, value: 8 }
351        (27, FIELD_META) => 0,
352        (27, FIELD_VALUE) => 8,
353        // Definition: DefinitionData { url: 0, title: 8, identifier: 16, label: 24 }
354        (9, FIELD_URL) => 0,
355        (9, FIELD_TITLE) => 8,
356        (9, FIELD_IDENTIFIER) => 16,
357        (9, FIELD_LABEL) => 24,
358        // LinkReference/ImageReference/FootnoteReference: ReferenceData { identifier: 0, label: 8 }
359        (17 | 18 | 20, FIELD_IDENTIFIER) => 0,
360        (17 | 18 | 20, FIELD_LABEL) => 8,
361        // FootnoteDefinition: FootnoteDefinitionData { identifier: 0, label: 8 }
362        (19, FIELD_IDENTIFIER) => 0,
363        (19, FIELD_LABEL) => 8,
364        // MdxJsxElement: MdxJsxElementData { name: 0 }
365        (100 | 101, FIELD_NAME) => 0,
366        // MdxExpression/MdxjsEsm: ExpressionData { value: 0 }
367        (102..=104, FIELD_VALUE) => 0,
368        _ => return None,
369    };
370
371    let abs_offset = data_offset + ref_offset;
372    let bytes_offset = sref.offset.to_ne_bytes();
373    let bytes_len = sref.len.to_ne_bytes();
374    arena.type_data[abs_offset..abs_offset + 4].copy_from_slice(&bytes_offset);
375    arena.type_data[abs_offset + 4..abs_offset + 8].copy_from_slice(&bytes_len);
376
377    Some(())
378}
379
380fn parse_raw_markdown(
381    markdown: &str,
382    parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
383) -> Arena<Mdast> {
384    parse_markdown(markdown)
385}
386
387/// Escape `{` and `}` in HTML text content so they are not interpreted as MDX
388/// expressions when the HTML is re-parsed through the MDX parser.
389///
390/// Only braces in **text content** (outside of HTML tags) are escaped; braces
391/// inside quoted attribute values are left untouched. The escape form `{'{'}` /
392/// `{'}'}` produces a valid MDX expression that evaluates to the literal brace
393/// character.
394fn escape_braces_in_html_text(html: &str) -> String {
395    let mut result = String::with_capacity(html.len());
396    let mut in_tag = false;
397    let mut in_quote: Option<char> = None;
398
399    for ch in html.chars() {
400        if in_tag {
401            match ch {
402                '"' | '\'' if in_quote == Some(ch) => {
403                    in_quote = None;
404                    result.push(ch);
405                }
406                '"' | '\'' if in_quote.is_none() => {
407                    in_quote = Some(ch);
408                    result.push(ch);
409                }
410                '>' if in_quote.is_none() => {
411                    in_tag = false;
412                    result.push(ch);
413                }
414                _ => result.push(ch),
415            }
416        } else {
417            match ch {
418                '<' => {
419                    in_tag = true;
420                    result.push(ch);
421                }
422                '{' => result.push_str("{'{'}"),
423                '}' => result.push_str("{'}'}"),
424                _ => result.push(ch),
425            }
426        }
427    }
428    result
429}
430
431fn js_node_to_mdast_arena(js_node: &JsNode) -> Result<(Arena<Mdast>, bool), CommandError> {
432    if js_node.is_hast {
433        return Err(CommandError::UnknownNodeType(format!(
434            "expected mdast node, got hast-flagged `{}`",
435            js_node.node_type
436        )));
437    }
438    let mut builder = ArenaBuilder::<Mdast>::new(String::new());
439    emit_mdast_js_node(js_node, &mut builder)?;
440    Ok((builder.finish(), js_node.keep_children))
441}
442
443fn js_node_to_hast_arena(js_node: &JsNode) -> Result<(Arena<Hast>, bool), CommandError> {
444    if !js_node.is_hast {
445        return Err(CommandError::UnknownNodeType(format!(
446            "expected hast node, got mdast-flagged `{}`",
447            js_node.node_type
448        )));
449    }
450    let mut builder = ArenaBuilder::<Hast>::new(String::new());
451    emit_hast_js_node(js_node, &mut builder)?;
452    Ok((builder.finish(), js_node.keep_children))
453}
454
455/// Emit a reference placeholder: a `REF_NODE_TYPE` node carrying the target
456/// original id (u32 LE) in its type_data. The rebuild resolves it by splicing
457/// that original subtree and applying any pending patch on it.
458fn emit_ref_node<K: ArenaKind>(ref_id: u32, builder: &mut ArenaBuilder<K>) {
459    builder.open_node_raw(REF_NODE_TYPE);
460    builder.set_data_current(&ref_id.to_le_bytes());
461    builder.close_node();
462}
463
464fn emit_mdast_js_node(
465    js_node: &JsNode,
466    builder: &mut ArenaBuilder<Mdast>,
467) -> Result<(), CommandError> {
468    if let Some(ref_id) = js_node.ref_id {
469        emit_ref_node(ref_id, builder);
470        return Ok(());
471    }
472
473    if js_node.is_hast {
474        return Err(CommandError::UnknownNodeType(format!(
475            "expected mdast node, got hast-flagged `{}`",
476            js_node.node_type
477        )));
478    }
479
480    let node_type = name_to_node_type(&js_node.node_type)?;
481    builder.open_node(node_type as u8);
482
483    let type_data = encode_js_node_data(js_node, node_type, builder);
484    if !type_data.is_empty() {
485        builder.set_data_current(&type_data);
486    }
487
488    write_js_node_data(js_node, builder)?;
489
490    if let Some(children) = &js_node.children {
491        for child in children {
492            emit_mdast_js_node(child, builder)?;
493        }
494    }
495
496    builder.close_node();
497    Ok(())
498}
499
500fn write_js_node_data<K: ArenaKind>(
501    js_node: &JsNode,
502    builder: &mut ArenaBuilder<K>,
503) -> Result<(), CommandError> {
504    let Some(data) = &js_node.data else {
505        return Ok(());
506    };
507    let id = builder.current_node_id();
508    let json = serde_json::to_vec(data).map_err(|e| CommandError::InvalidJson(e.to_string()))?;
509    builder.arena_mut().set_node_data(id, json);
510    Ok(())
511}
512
513fn encode_js_node_data(
514    js_node: &JsNode,
515    node_type: MdastNodeType,
516    builder: &mut ArenaBuilder<Mdast>,
517) -> Vec<u8> {
518    match node_type {
519        MdastNodeType::Heading => {
520            let depth = js_node.depth.unwrap_or(1);
521            encode_heading_data(depth)
522        }
523        MdastNodeType::Text
524        | MdastNodeType::InlineCode
525        | MdastNodeType::Html
526        | MdastNodeType::Yaml
527        | MdastNodeType::Toml => {
528            let value = js_node.value.as_deref().unwrap_or("");
529            let sref = builder.alloc_string(value);
530            encode_string_ref_data(sref)
531        }
532        MdastNodeType::Code => {
533            let lang_ref = alloc_opt_str(builder, js_node.lang.as_deref());
534            let meta_ref = alloc_opt_str(builder, js_node.meta.as_deref());
535            let value_ref = alloc_opt_str(builder, js_node.value.as_deref());
536            encode_code_data(lang_ref, meta_ref, value_ref, b'`')
537        }
538        // InlineMath shares Math's `MathData` layout (the parser stores both
539        // that way); it has no `meta`, so that ref stays empty.
540        MdastNodeType::Math | MdastNodeType::InlineMath => {
541            let meta_ref = alloc_opt_str(builder, js_node.meta.as_deref());
542            let value_ref = alloc_opt_str(builder, js_node.value.as_deref());
543            encode_math_data(meta_ref, value_ref)
544        }
545        MdastNodeType::Link => {
546            let url_ref = alloc_opt_str(builder, js_node.url.as_deref());
547            let title_ref = alloc_opt_str(builder, js_node.title.as_deref());
548            encode_link_data(url_ref, title_ref)
549        }
550        MdastNodeType::Image => {
551            let url_ref = alloc_opt_str(builder, js_node.url.as_deref());
552            let alt_ref = alloc_opt_str(builder, js_node.alt.as_deref());
553            let title_ref = alloc_opt_str(builder, js_node.title.as_deref());
554            encode_image_data(url_ref, alt_ref, title_ref)
555        }
556        MdastNodeType::Definition => {
557            let url_ref = alloc_opt_str(builder, js_node.url.as_deref());
558            let title_ref = alloc_opt_str(builder, js_node.title.as_deref());
559            let id_ref = alloc_opt_str(builder, js_node.identifier.as_deref());
560            let label_ref = alloc_opt_str(builder, js_node.label.as_deref());
561            encode_definition_data(url_ref, title_ref, id_ref, label_ref)
562        }
563        MdastNodeType::Table => {
564            let alignments = js_table_alignments(js_node.align.as_deref());
565            encode_table_data(&alignments)
566        }
567        MdastNodeType::List => {
568            let ordered = js_node.ordered.unwrap_or(false);
569            let start = js_node.start.unwrap_or(1);
570            let spread = js_node.spread.unwrap_or(false);
571            encode_list_data(ordered, start, spread)
572        }
573        MdastNodeType::ListItem => {
574            let checked = match js_node.checked {
575                Some(true) => 1u8,
576                Some(false) => 0u8,
577                None => 2u8, // not a task item
578            };
579            let spread = js_node.spread.unwrap_or(false);
580            encode_list_item_data(checked, spread)
581        }
582        MdastNodeType::LinkReference | MdastNodeType::FootnoteReference => {
583            let id_ref = alloc_opt_str(builder, js_node.identifier.as_deref());
584            let label_ref = alloc_opt_str(builder, js_node.label.as_deref());
585            encode_reference_data(id_ref, label_ref, js_reference_kind(js_node))
586        }
587        // ImageReference carries an extra `alt` after the reference header.
588        MdastNodeType::ImageReference => {
589            let id_ref = alloc_opt_str(builder, js_node.identifier.as_deref());
590            let label_ref = alloc_opt_str(builder, js_node.label.as_deref());
591            let alt_ref = alloc_opt_str(builder, js_node.alt.as_deref());
592            encode_image_reference_data(id_ref, label_ref, js_reference_kind(js_node), alt_ref)
593        }
594        MdastNodeType::FootnoteDefinition => {
595            let id_ref = alloc_opt_str(builder, js_node.identifier.as_deref());
596            let label_ref = alloc_opt_str(builder, js_node.label.as_deref());
597            encode_footnote_definition_data(id_ref, label_ref)
598        }
599        #[cfg(feature = "mdx")]
600        MdastNodeType::MdxJsxFlowElement | MdastNodeType::MdxJsxTextElement => {
601            let name_ref = alloc_opt_str(builder, js_node.name.as_deref());
602            let attr_tuples = encode_js_jsx_attrs(
603                builder,
604                js_node.attributes.as_ref().and_then(|a| a.as_jsx()),
605            );
606            let explicit = js_data_is_mdx_explicit(&js_node.data);
607            encode_mdx_jsx_element_data(name_ref, &attr_tuples, explicit)
608        }
609        MdastNodeType::ContainerDirective
610        | MdastNodeType::LeafDirective
611        | MdastNodeType::TextDirective => {
612            let name = js_node.name.as_deref().unwrap_or("");
613            let name_ref = builder.alloc_string(name);
614            let attr_pairs = encode_js_directive_attrs(builder, js_node.attributes.as_ref());
615            encode_directive_data(name_ref, &attr_pairs)
616        }
617        #[cfg(feature = "mdx")]
618        MdastNodeType::MdxFlowExpression
619        | MdastNodeType::MdxTextExpression
620        | MdastNodeType::MdxjsEsm => {
621            let value_ref = alloc_opt_str(builder, js_node.value.as_deref());
622            encode_expression_data(value_ref)
623        }
624        // Nodes with no type-specific data
625        _ => Vec::new(),
626    }
627}
628
629fn js_table_alignments(align: Option<&[Option<String>]>) -> Vec<ColumnAlign> {
630    let Some(align) = align else {
631        return Vec::new();
632    };
633    align
634        .iter()
635        .map(|v| match v.as_deref() {
636            Some("left") => ColumnAlign::Left,
637            Some("right") => ColumnAlign::Right,
638            Some("center") => ColumnAlign::Center,
639            _ => ColumnAlign::None,
640        })
641        .collect()
642}
643
644fn js_reference_kind(js_node: &JsNode) -> u8 {
645    match js_node.reference_type.as_deref() {
646        Some("collapsed") => 1,
647        Some("full") => 2,
648        _ => 0,
649    }
650}
651
652fn encode_js_directive_attrs(
653    builder: &mut ArenaBuilder<Mdast>,
654    attrs: Option<&satteri_ast::commands::JsNodeAttributes>,
655) -> Vec<(StringRef, StringRef)> {
656    let Some(map) = attrs.and_then(|a| a.as_directive()) else {
657        return Vec::new();
658    };
659    map.iter()
660        .filter_map(|(k, v)| {
661            let val = v.as_str()?;
662            Some((builder.alloc_string(k), builder.alloc_string(val)))
663        })
664        .collect()
665}
666
667fn alloc_opt_str<K: ArenaKind>(builder: &mut ArenaBuilder<K>, s: Option<&str>) -> StringRef {
668    match s {
669        Some(v) if !v.is_empty() => builder.alloc_string(v),
670        _ => StringRef::empty(),
671    }
672}
673
674fn name_to_node_type(name: &str) -> Result<MdastNodeType, CommandError> {
675    match name {
676        "root" => Ok(MdastNodeType::Root),
677        "paragraph" => Ok(MdastNodeType::Paragraph),
678        "heading" => Ok(MdastNodeType::Heading),
679        "thematicBreak" => Ok(MdastNodeType::ThematicBreak),
680        "blockquote" => Ok(MdastNodeType::Blockquote),
681        "list" => Ok(MdastNodeType::List),
682        "listItem" => Ok(MdastNodeType::ListItem),
683        "html" => Ok(MdastNodeType::Html),
684        "code" => Ok(MdastNodeType::Code),
685        "definition" => Ok(MdastNodeType::Definition),
686        "text" => Ok(MdastNodeType::Text),
687        "emphasis" => Ok(MdastNodeType::Emphasis),
688        "strong" => Ok(MdastNodeType::Strong),
689        "inlineCode" => Ok(MdastNodeType::InlineCode),
690        "break" => Ok(MdastNodeType::Break),
691        "link" => Ok(MdastNodeType::Link),
692        "image" => Ok(MdastNodeType::Image),
693        "linkReference" => Ok(MdastNodeType::LinkReference),
694        "imageReference" => Ok(MdastNodeType::ImageReference),
695        "footnoteDefinition" => Ok(MdastNodeType::FootnoteDefinition),
696        "footnoteReference" => Ok(MdastNodeType::FootnoteReference),
697        "table" => Ok(MdastNodeType::Table),
698        "tableRow" => Ok(MdastNodeType::TableRow),
699        "tableCell" => Ok(MdastNodeType::TableCell),
700        "delete" => Ok(MdastNodeType::Delete),
701        "yaml" => Ok(MdastNodeType::Yaml),
702        "toml" => Ok(MdastNodeType::Toml),
703        "math" => Ok(MdastNodeType::Math),
704        "inlineMath" => Ok(MdastNodeType::InlineMath),
705        "containerDirective" => Ok(MdastNodeType::ContainerDirective),
706        "leafDirective" => Ok(MdastNodeType::LeafDirective),
707        "textDirective" => Ok(MdastNodeType::TextDirective),
708        #[cfg(feature = "mdx")]
709        "mdxJsxFlowElement" => Ok(MdastNodeType::MdxJsxFlowElement),
710        #[cfg(feature = "mdx")]
711        "mdxJsxTextElement" => Ok(MdastNodeType::MdxJsxTextElement),
712        #[cfg(feature = "mdx")]
713        "mdxFlowExpression" => Ok(MdastNodeType::MdxFlowExpression),
714        #[cfg(feature = "mdx")]
715        "mdxTextExpression" => Ok(MdastNodeType::MdxTextExpression),
716        #[cfg(feature = "mdx")]
717        "mdxjsEsm" => Ok(MdastNodeType::MdxjsEsm),
718        other => Err(CommandError::UnknownNodeType(other.to_string())),
719    }
720}
721
722// HAST command handlers
723
724/// HAST set-property: dispatches by `HastNodeType` to the matching writer.
725/// Kind-tight to `Arena<Hast>` — the MDAST field-resolver can no longer be
726/// reached from here.
727fn apply_hast_set_property(
728    arena: &mut Arena<Hast>,
729    node_id: u32,
730    prop_name: &str,
731    value_type: u8,
732    value_str: &str,
733) -> Result<(), CommandError> {
734    if prop_name == "data" {
735        apply_data_property(arena, node_id, value_type, value_str);
736        return Ok(());
737    }
738
739    let raw_type = arena.get_node(node_id).node_type;
740    let node_type = HastNodeType::from_u8(raw_type)
741        .ok_or_else(|| CommandError::UnknownNodeType(format!("hast type 0x{raw_type:02x}")))?;
742
743    match node_type {
744        HastNodeType::Element => {
745            apply_hast_element_property(arena, node_id, prop_name, value_type, value_str)
746        }
747
748        HastNodeType::Text
749        | HastNodeType::Comment
750        | HastNodeType::Raw
751        | HastNodeType::MdxFlowExpression
752        | HastNodeType::MdxTextExpression
753        | HastNodeType::MdxEsm
754            if prop_name == "value" =>
755        {
756            let sref = arena.alloc_string(value_str);
757            let data = arena.get_type_data(node_id);
758            if data.len() >= 8 {
759                let data_offset = arena.get_node(node_id).data_offset as usize;
760                arena.type_data[data_offset..data_offset + 4]
761                    .copy_from_slice(&sref.offset.to_le_bytes());
762                arena.type_data[data_offset + 4..data_offset + 8]
763                    .copy_from_slice(&sref.len.to_le_bytes());
764                Ok(())
765            } else {
766                Err(CommandError::UnexpectedEof)
767            }
768        }
769
770        _ => Err(CommandError::UnknownField {
771            node_type: node_type.name().to_string(),
772            name: prop_name.to_string(),
773        }),
774    }
775}
776
777/// Set or add a single property on a HAST element node.
778fn apply_hast_element_property(
779    arena: &mut Arena<Hast>,
780    node_id: u32,
781    prop_name: &str,
782    value_type: u8,
783    value_str: &str,
784) -> Result<(), CommandError> {
785    let old_data = arena.get_type_data(node_id).to_vec();
786    if old_data.len() < 16 {
787        return Err(CommandError::UnexpectedEof);
788    }
789
790    let old_prop_count = u32::from_le_bytes(old_data[8..12].try_into().unwrap()) as usize;
791
792    let mut found_index: Option<usize> = None;
793    for i in 0..old_prop_count {
794        let base = 16 + i * 20;
795        let name_off = u32::from_le_bytes(old_data[base..base + 4].try_into().unwrap());
796        let name_len = u32::from_le_bytes(old_data[base + 4..base + 8].try_into().unwrap());
797        let existing_name = arena.get_str(StringRef::new(name_off, name_len));
798        if existing_name == prop_name {
799            found_index = Some(i);
800            break;
801        }
802    }
803
804    let name_ref = arena.alloc_string(prop_name);
805    let val_ref = if value_str.is_empty() {
806        StringRef::empty()
807    } else {
808        arena.alloc_string(value_str)
809    };
810
811    if let Some(idx) = found_index {
812        let mut new_data = old_data;
813        let base = 16 + idx * 20;
814        new_data[base..base + 4].copy_from_slice(&name_ref.offset.to_le_bytes());
815        new_data[base + 4..base + 8].copy_from_slice(&name_ref.len.to_le_bytes());
816        new_data[base + 8] = value_type;
817        new_data[base + 9..base + 12].copy_from_slice(&[0u8; 3]);
818        new_data[base + 12..base + 16].copy_from_slice(&val_ref.offset.to_le_bytes());
819        new_data[base + 16..base + 20].copy_from_slice(&val_ref.len.to_le_bytes());
820        arena.set_type_data(node_id, &new_data);
821    } else {
822        let new_prop_count = (old_prop_count + 1) as u32;
823        let mut new_data = Vec::with_capacity(16 + new_prop_count as usize * 20);
824        new_data.extend_from_slice(&old_data[0..8]);
825        new_data.extend_from_slice(&new_prop_count.to_le_bytes());
826        new_data.extend_from_slice(&0u32.to_le_bytes());
827        if old_prop_count > 0 {
828            new_data.extend_from_slice(&old_data[16..16 + old_prop_count * 20]);
829        }
830        new_data.extend_from_slice(&name_ref.offset.to_le_bytes());
831        new_data.extend_from_slice(&name_ref.len.to_le_bytes());
832        new_data.push(value_type);
833        new_data.extend_from_slice(&[0u8; 3]);
834        new_data.extend_from_slice(&val_ref.offset.to_le_bytes());
835        new_data.extend_from_slice(&val_ref.len.to_le_bytes());
836        arena.set_type_data(node_id, &new_data);
837    }
838
839    Ok(())
840}
841
842/// Emit a HAST JS node (from plugin JSON) into an ArenaBuilder.
843fn emit_hast_js_node(
844    js_node: &JsNode,
845    builder: &mut ArenaBuilder<Hast>,
846) -> Result<(), CommandError> {
847    if let Some(ref_id) = js_node.ref_id {
848        emit_ref_node(ref_id, builder);
849        return Ok(());
850    }
851
852    let raw_type = name_to_hast_type(&js_node.node_type)
853        .ok_or_else(|| CommandError::UnknownNodeType(js_node.node_type.clone()))?;
854    builder.open_node_raw(raw_type as u8);
855
856    let type_data = encode_hast_js_node_data(js_node, raw_type, builder);
857    if !type_data.is_empty() {
858        builder.set_data_current(&type_data);
859    }
860
861    write_js_node_data(js_node, builder)?;
862
863    if let Some(children) = &js_node.children {
864        for child in children {
865            emit_hast_js_node(child, builder)?;
866        }
867    }
868
869    builder.close_node();
870    Ok(())
871}
872
873fn name_to_hast_type(name: &str) -> Option<HastNodeType> {
874    match name {
875        "root" => Some(HastNodeType::Root),
876        "element" => Some(HastNodeType::Element),
877        "text" => Some(HastNodeType::Text),
878        "comment" => Some(HastNodeType::Comment),
879        "doctype" => Some(HastNodeType::Doctype),
880        "raw" => Some(HastNodeType::Raw),
881        #[cfg(feature = "mdx")]
882        "mdxJsxFlowElement" => Some(HastNodeType::MdxJsxElement),
883        #[cfg(feature = "mdx")]
884        "mdxJsxTextElement" => Some(HastNodeType::MdxJsxTextElement),
885        #[cfg(feature = "mdx")]
886        "mdxFlowExpression" => Some(HastNodeType::MdxFlowExpression),
887        #[cfg(feature = "mdx")]
888        "mdxTextExpression" => Some(HastNodeType::MdxTextExpression),
889        #[cfg(feature = "mdx")]
890        "mdxjsEsm" => Some(HastNodeType::MdxEsm),
891        _ => None,
892    }
893}
894
895fn encode_hast_js_node_data(
896    js_node: &JsNode,
897    node_type: HastNodeType,
898    builder: &mut ArenaBuilder<Hast>,
899) -> Vec<u8> {
900    match node_type {
901        HastNodeType::Element => {
902            let tag = js_node.tag_name.as_deref().unwrap_or("div");
903            let tag_ref = builder.alloc_string(tag);
904
905            let mut props: Vec<(StringRef, u8, StringRef)> = Vec::new();
906            if let Some(properties) = &js_node.properties {
907                for (key, value) in properties {
908                    let name_ref = builder.alloc_string(key);
909                    match value {
910                        serde_json::Value::Bool(true) => {
911                            props.push((name_ref, PROP_BOOL_TRUE, StringRef::empty()));
912                        }
913                        serde_json::Value::Bool(false) => {
914                            props.push((name_ref, PROP_BOOL_FALSE, StringRef::empty()));
915                        }
916                        serde_json::Value::String(s) => {
917                            let val_ref = builder.alloc_string(s);
918                            props.push((name_ref, PROP_STRING, val_ref));
919                        }
920                        serde_json::Value::Number(n) => {
921                            let val_ref = builder.alloc_string(&n.to_string());
922                            props.push((name_ref, PROP_INT, val_ref));
923                        }
924                        serde_json::Value::Array(arr) => {
925                            let joined: String = arr
926                                .iter()
927                                .filter_map(|v| v.as_str())
928                                .collect::<Vec<_>>()
929                                .join(" ");
930                            let val_ref = builder.alloc_string(&joined);
931                            props.push((name_ref, PROP_SPACE_SEP, val_ref));
932                        }
933                        _ => {}
934                    }
935                }
936            }
937
938            let mut out = Vec::with_capacity(16 + props.len() * 20);
939            out.extend_from_slice(&tag_ref.offset.to_le_bytes());
940            out.extend_from_slice(&tag_ref.len.to_le_bytes());
941            out.extend_from_slice(&(props.len() as u32).to_le_bytes());
942            out.extend_from_slice(&0u32.to_le_bytes());
943            for (name_ref, kind, val_ref) in &props {
944                out.extend_from_slice(&name_ref.offset.to_le_bytes());
945                out.extend_from_slice(&name_ref.len.to_le_bytes());
946                out.push(*kind);
947                out.extend_from_slice(&[0u8; 3]);
948                out.extend_from_slice(&val_ref.offset.to_le_bytes());
949                out.extend_from_slice(&val_ref.len.to_le_bytes());
950            }
951            out
952        }
953
954        HastNodeType::Text | HastNodeType::Comment | HastNodeType::Raw => {
955            let value = js_node.value.as_deref().unwrap_or("");
956            let sref = builder.alloc_string(value);
957            let mut out = [0u8; 8];
958            out[0..4].copy_from_slice(&sref.offset.to_le_bytes());
959            out[4..8].copy_from_slice(&sref.len.to_le_bytes());
960            out.to_vec()
961        }
962
963        #[cfg(feature = "mdx")]
964        HastNodeType::MdxJsxElement | HastNodeType::MdxJsxTextElement => {
965            let name = js_node
966                .name
967                .as_deref()
968                .or(js_node.tag_name.as_deref())
969                .unwrap_or("");
970            let name_ref = builder.alloc_string(name);
971            let attr_tuples = encode_js_jsx_attrs(
972                builder,
973                js_node.attributes.as_ref().and_then(|a| a.as_jsx()),
974            );
975            let explicit = js_data_is_mdx_explicit(&js_node.data);
976            encode_mdx_jsx_element_data(name_ref, &attr_tuples, explicit)
977        }
978
979        #[cfg(feature = "mdx")]
980        HastNodeType::MdxFlowExpression
981        | HastNodeType::MdxTextExpression
982        | HastNodeType::MdxEsm => {
983            let value = js_node.value.as_deref().unwrap_or("");
984            let sref = builder.alloc_string(value);
985            let mut out = [0u8; 8];
986            out[0..4].copy_from_slice(&sref.offset.to_le_bytes());
987            out[4..8].copy_from_slice(&sref.len.to_le_bytes());
988            out.to_vec()
989        }
990
991        _ => Vec::new(),
992    }
993}
994
995/// Returns (arena, keep_children) for an MDAST sub-tree payload.
996fn read_mdast_payload(
997    reader: &mut BufReader<'_>,
998    parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
999) -> Result<(Arena<Mdast>, bool), CommandError> {
1000    let payload_type = reader.read_u8()?;
1001    let len = reader.read_u32()? as usize;
1002
1003    match payload_type {
1004        PAYLOAD_RAW_MARKDOWN => {
1005            let md = reader.read_str(len)?;
1006            Ok((parse_raw_markdown(md, parse_markdown), false))
1007        }
1008        PAYLOAD_RAW_HTML => {
1009            let html = reader.read_str(len)?;
1010            let escaped = escape_braces_in_html_text(html);
1011            Ok((parse_raw_markdown(&escaped, parse_markdown), false))
1012        }
1013        PAYLOAD_SERDE_JSON => {
1014            let json_str = reader.read_str(len)?;
1015            let js_node: JsNode = serde_json::from_str(json_str)
1016                .map_err(|e| CommandError::InvalidJson(e.to_string()))?;
1017            js_node_to_mdast_arena(&js_node)
1018        }
1019        other => Err(CommandError::UnknownPayloadType(other)),
1020    }
1021}
1022
1023/// Returns (arena, keep_children) for a HAST sub-tree payload. Only
1024/// `PAYLOAD_SERDE_JSON` is accepted — HAST plugins emit JSON node trees,
1025/// not raw markdown or raw HTML.
1026fn read_hast_payload(reader: &mut BufReader<'_>) -> Result<(Arena<Hast>, bool), CommandError> {
1027    let payload_type = reader.read_u8()?;
1028    let len = reader.read_u32()? as usize;
1029
1030    match payload_type {
1031        PAYLOAD_SERDE_JSON => {
1032            let json_str = reader.read_str(len)?;
1033            let js_node: JsNode = serde_json::from_str(json_str)
1034                .map_err(|e| CommandError::InvalidJson(e.to_string()))?;
1035            js_node_to_hast_arena(&js_node)
1036        }
1037        other => Err(CommandError::UnknownPayloadType(other)),
1038    }
1039}
1040
1041/// Apply a command buffer to an MDAST arena. Set-property mutations are
1042/// applied in-place; structural mutations are collected as `Patch<Mdast>`
1043/// objects and applied via `rebuild()`.
1044///
1045/// `parse_markdown` avoids a circular dependency on the parser crate; it
1046/// is invoked for `RAW_MARKDOWN` and `RAW_HTML` payloads.
1047///
1048/// Passing a HAST arena is a compile error — the prior single-dispatch
1049/// `apply_commands` would silently misroute MDAST nodes into the HAST
1050/// element-properties writer (numeric `node_type` values overlap between
1051/// the two arenas):
1052///
1053/// ```compile_fail
1054/// use satteri_arena::{Arena, Hast};
1055/// use satteri_plugin_api::apply_mdast_commands;
1056///
1057/// let arena: Arena<Hast> = Arena::new(String::new());
1058/// let parse_markdown = |_: &str| -> Arena<satteri_arena::Mdast> {
1059///     Arena::new(String::new())
1060/// };
1061/// let _ = apply_mdast_commands(arena, &[], &parse_markdown);
1062/// ```
1063pub fn apply_mdast_commands(
1064    arena: Arena<Mdast>,
1065    command_buf: &[u8],
1066    parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
1067) -> Result<Arena<Mdast>, CommandError> {
1068    let (arena, dropped) = apply_mdast_commands_lenient(arena, command_buf, parse_markdown)?;
1069    if let Some(anchor) = dropped.first() {
1070        return Err(CommandError::PatchOnRemovedSubtree(*anchor));
1071    }
1072    Ok(arena)
1073}
1074
1075/// Like [`apply_mdast_commands`], but rather than erroring when a patch targets
1076/// a node inside a removed/replaced subtree, drops it and returns the dropped
1077/// anchors. Such a patch is moot — the plugin discarded that subtree. A
1078/// *passed-through* child is not dropped: it rides a `_ref` placeholder that
1079/// splices it back with its id intact, so a transform queued on a nested node
1080/// (e.g. a `:::tip` inside a `:::note`) still applies, in the same pass.
1081pub fn apply_mdast_commands_lenient(
1082    mut arena: Arena<Mdast>,
1083    command_buf: &[u8],
1084    parse_markdown: &dyn Fn(&str) -> Arena<Mdast>,
1085) -> Result<(Arena<Mdast>, Vec<u32>), CommandError> {
1086    if command_buf.is_empty() {
1087        return Ok((arena, Vec::new()));
1088    }
1089
1090    let mut patches: Vec<Patch<Mdast>> = Vec::new();
1091    let mut reader = BufReader::new(command_buf);
1092
1093    while reader.remaining() > 0 {
1094        let cmd = reader.read_u8()?;
1095
1096        match cmd {
1097            CMD_REMOVE => {
1098                let node_id = reader.read_u32()?;
1099                patches.push(Patch::Remove { node_id });
1100            }
1101
1102            CMD_SET_PROPERTY => {
1103                let node_id = reader.read_u32()?;
1104                let value_type = reader.read_u8()?;
1105                let name_len = reader.read_u32()? as usize;
1106                let name = reader.read_str(name_len)?;
1107                let value_len = reader.read_u32()? as usize;
1108                let value = reader.read_str(value_len)?;
1109                apply_mdast_set_property(&mut arena, node_id, name, value_type, value)?;
1110            }
1111
1112            CMD_INSERT_BEFORE => {
1113                let node_id = reader.read_u32()?;
1114                let (new_tree, _) = read_mdast_payload(&mut reader, parse_markdown)?;
1115                patches.push(Patch::InsertBefore { node_id, new_tree });
1116            }
1117
1118            CMD_INSERT_AFTER => {
1119                let node_id = reader.read_u32()?;
1120                let (new_tree, _) = read_mdast_payload(&mut reader, parse_markdown)?;
1121                patches.push(Patch::InsertAfter { node_id, new_tree });
1122            }
1123
1124            CMD_PREPEND_CHILD => {
1125                let node_id = reader.read_u32()?;
1126                let (child_tree, _) = read_mdast_payload(&mut reader, parse_markdown)?;
1127                patches.push(Patch::PrependChild {
1128                    node_id,
1129                    child_tree,
1130                });
1131            }
1132
1133            CMD_APPEND_CHILD => {
1134                let node_id = reader.read_u32()?;
1135                let (child_tree, _) = read_mdast_payload(&mut reader, parse_markdown)?;
1136                patches.push(Patch::AppendChild {
1137                    node_id,
1138                    child_tree,
1139                });
1140            }
1141
1142            CMD_WRAP => {
1143                let node_id = reader.read_u32()?;
1144                let (parent_tree, _) = read_mdast_payload(&mut reader, parse_markdown)?;
1145                patches.push(Patch::Wrap {
1146                    node_id,
1147                    parent_tree,
1148                });
1149            }
1150
1151            CMD_REPLACE => {
1152                let node_id = reader.read_u32()?;
1153                let (new_tree, keep_children) = read_mdast_payload(&mut reader, parse_markdown)?;
1154                patches.push(Patch::Replace {
1155                    node_id,
1156                    new_tree,
1157                    keep_children,
1158                });
1159            }
1160
1161            CMD_SET_CHILDREN => {
1162                let node_id = reader.read_u32()?;
1163                let (new_children, _) = read_mdast_payload(&mut reader, parse_markdown)?;
1164                patches.push(Patch::SetChildren {
1165                    node_id,
1166                    new_children,
1167                });
1168            }
1169
1170            other => return Err(CommandError::UnknownCommand(other)),
1171        }
1172    }
1173
1174    if patches.is_empty() {
1175        Ok((arena, Vec::new()))
1176    } else {
1177        let result = satteri_ast::rebuild::rebuild_lenient(&arena, &patches)?;
1178        Ok((result.arena, result.dropped))
1179    }
1180}
1181
1182/// Apply a command buffer to a HAST arena. Set-property mutations are
1183/// applied in-place; structural mutations are collected as `Patch<Hast>`
1184/// objects and applied via `rebuild()`. Errors if a patch is stranded inside a
1185/// removed/replaced subtree; [`apply_hast_commands_lenient`] drops it instead.
1186///
1187/// HAST plugins inject sub-trees via `PAYLOAD_SERDE_JSON` only — there is
1188/// no `parse_markdown` callback because HAST has no source-level grammar.
1189///
1190/// Passing an MDAST arena is a compile error:
1191///
1192/// ```compile_fail
1193/// use satteri_arena::{Arena, Mdast};
1194/// use satteri_plugin_api::apply_hast_commands;
1195///
1196/// let arena: Arena<Mdast> = Arena::new(String::new());
1197/// let _ = apply_hast_commands(arena, &[]);
1198/// ```
1199pub fn apply_hast_commands(
1200    arena: Arena<Hast>,
1201    command_buf: &[u8],
1202) -> Result<Arena<Hast>, CommandError> {
1203    let (arena, dropped) = apply_hast_commands_lenient(arena, command_buf)?;
1204    if let Some(anchor) = dropped.first() {
1205        return Err(CommandError::PatchOnRemovedSubtree(*anchor));
1206    }
1207    Ok(arena)
1208}
1209
1210/// Like [`apply_hast_commands`], but rather than erroring when a patch targets a
1211/// node inside a removed/replaced subtree, drops it and returns the dropped
1212/// anchors — mirroring [`apply_mdast_commands_lenient`]. Such a patch is moot:
1213/// the plugin discarded that subtree. A passed-through child keeps its identity
1214/// (via `_ref`) and so is never stranded this way.
1215pub fn apply_hast_commands_lenient(
1216    mut arena: Arena<Hast>,
1217    command_buf: &[u8],
1218) -> Result<(Arena<Hast>, Vec<u32>), CommandError> {
1219    if command_buf.is_empty() {
1220        return Ok((arena, Vec::new()));
1221    }
1222
1223    let mut patches: Vec<Patch<Hast>> = Vec::new();
1224    let mut reader = BufReader::new(command_buf);
1225
1226    while reader.remaining() > 0 {
1227        let cmd = reader.read_u8()?;
1228
1229        match cmd {
1230            CMD_REMOVE => {
1231                let node_id = reader.read_u32()?;
1232                patches.push(Patch::Remove { node_id });
1233            }
1234
1235            CMD_SET_PROPERTY => {
1236                let node_id = reader.read_u32()?;
1237                let value_type = reader.read_u8()?;
1238                let name_len = reader.read_u32()? as usize;
1239                let name = reader.read_str(name_len)?;
1240                let value_len = reader.read_u32()? as usize;
1241                let value = reader.read_str(value_len)?;
1242                apply_hast_set_property(&mut arena, node_id, name, value_type, value)?;
1243            }
1244
1245            CMD_INSERT_BEFORE => {
1246                let node_id = reader.read_u32()?;
1247                let (new_tree, _) = read_hast_payload(&mut reader)?;
1248                patches.push(Patch::InsertBefore { node_id, new_tree });
1249            }
1250
1251            CMD_INSERT_AFTER => {
1252                let node_id = reader.read_u32()?;
1253                let (new_tree, _) = read_hast_payload(&mut reader)?;
1254                patches.push(Patch::InsertAfter { node_id, new_tree });
1255            }
1256
1257            CMD_PREPEND_CHILD => {
1258                let node_id = reader.read_u32()?;
1259                let (child_tree, _) = read_hast_payload(&mut reader)?;
1260                patches.push(Patch::PrependChild {
1261                    node_id,
1262                    child_tree,
1263                });
1264            }
1265
1266            CMD_APPEND_CHILD => {
1267                let node_id = reader.read_u32()?;
1268                let (child_tree, _) = read_hast_payload(&mut reader)?;
1269                patches.push(Patch::AppendChild {
1270                    node_id,
1271                    child_tree,
1272                });
1273            }
1274
1275            CMD_WRAP => {
1276                let node_id = reader.read_u32()?;
1277                let (parent_tree, _) = read_hast_payload(&mut reader)?;
1278                patches.push(Patch::Wrap {
1279                    node_id,
1280                    parent_tree,
1281                });
1282            }
1283
1284            CMD_REPLACE => {
1285                let node_id = reader.read_u32()?;
1286                let (new_tree, keep_children) = read_hast_payload(&mut reader)?;
1287                patches.push(Patch::Replace {
1288                    node_id,
1289                    new_tree,
1290                    keep_children,
1291                });
1292            }
1293
1294            CMD_SET_CHILDREN => {
1295                let node_id = reader.read_u32()?;
1296                let (new_children, _) = read_hast_payload(&mut reader)?;
1297                patches.push(Patch::SetChildren {
1298                    node_id,
1299                    new_children,
1300                });
1301            }
1302
1303            other => return Err(CommandError::UnknownCommand(other)),
1304        }
1305    }
1306
1307    if patches.is_empty() {
1308        Ok((arena, Vec::new()))
1309    } else {
1310        let result = satteri_ast::rebuild::rebuild_lenient(&arena, &patches)?;
1311        Ok((result.arena, result.dropped))
1312    }
1313}
1314
1315#[cfg(test)]
1316mod tests {
1317    use super::*;
1318    use satteri_ast::shared::PROP_INT;
1319
1320    fn test_parse_markdown(source: &str) -> Arena<Mdast> {
1321        let mut b = ArenaBuilder::<Mdast>::new(String::new());
1322        b.open_node(MdastNodeType::Root as u8);
1323        b.open_node(MdastNodeType::Paragraph as u8);
1324        b.open_node(MdastNodeType::Text as u8);
1325        let sref = b.alloc_string(source);
1326        b.set_data_current(&satteri_arena::encode_string_ref_data(sref));
1327        b.close_node();
1328        b.close_node();
1329        b.close_node();
1330        b.finish()
1331    }
1332
1333    fn push_u32(buf: &mut Vec<u8>, v: u32) {
1334        buf.extend_from_slice(&v.to_le_bytes());
1335    }
1336
1337    /// Encode a CMD_SET_PROPERTY command into a buffer.
1338    fn push_set_property(buf: &mut Vec<u8>, node_id: u32, value_type: u8, name: &str, value: &str) {
1339        buf.push(CMD_SET_PROPERTY);
1340        push_u32(buf, node_id);
1341        buf.push(value_type);
1342        push_u32(buf, name.len() as u32);
1343        buf.extend_from_slice(name.as_bytes());
1344        push_u32(buf, value.len() as u32);
1345        buf.extend_from_slice(value.as_bytes());
1346    }
1347
1348    fn build_hello_world() -> Arena<Mdast> {
1349        use satteri_ast::mdast::codec::{encode_heading_data, encode_string_ref_data};
1350
1351        let source = "# Hello\n\nWorld".to_string();
1352        let mut b = ArenaBuilder::<Mdast>::new(source);
1353
1354        b.open_node(MdastNodeType::Root as u8);
1355        b.set_position_current(0, 14, 1, 1, 2, 6);
1356
1357        b.open_node(MdastNodeType::Heading as u8);
1358        b.set_position_current(0, 7, 1, 1, 1, 8);
1359        b.set_data_current(&encode_heading_data(1));
1360
1361        b.open_node(MdastNodeType::Text as u8);
1362        b.set_position_current(2, 7, 1, 3, 1, 8);
1363        b.set_data_current(&encode_string_ref_data(StringRef::new(2, 5)));
1364        b.close_node();
1365
1366        b.close_node();
1367
1368        b.open_node(MdastNodeType::Paragraph as u8);
1369        b.set_position_current(9, 14, 2, 1, 2, 6);
1370
1371        b.open_node(MdastNodeType::Text as u8);
1372        b.set_position_current(9, 14, 2, 1, 2, 6);
1373        b.set_data_current(&encode_string_ref_data(StringRef::new(9, 5)));
1374        b.close_node();
1375
1376        b.close_node();
1377        b.close_node();
1378
1379        b.finish()
1380    }
1381
1382    #[test]
1383    fn empty_command_buffer() {
1384        let arena = build_hello_world();
1385        let result = apply_mdast_commands(arena.clone(), &[], &test_parse_markdown).unwrap();
1386        assert_eq!(result.len(), arena.len());
1387    }
1388
1389    #[test]
1390    fn remove_command() {
1391        let arena = build_hello_world();
1392        let heading_id = arena.get_children(0)[0];
1393        let mut buf = Vec::new();
1394        buf.push(CMD_REMOVE);
1395        push_u32(&mut buf, heading_id);
1396
1397        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1398        assert_eq!(result.get_children(0).len(), 1);
1399        assert_eq!(
1400            result.get_node(result.get_children(0)[0]).node_type,
1401            MdastNodeType::Paragraph as u8
1402        );
1403    }
1404
1405    #[test]
1406    fn set_property_heading_depth() {
1407        let arena = build_hello_world();
1408        let heading_id = arena.get_children(0)[0];
1409
1410        let mut buf = Vec::new();
1411        push_set_property(&mut buf, heading_id, PROP_INT, "depth", "3");
1412
1413        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1414        let heading_data = result.get_type_data(heading_id);
1415        let heading = decode_heading_data(heading_data);
1416        assert_eq!(heading.depth, 3);
1417    }
1418
1419    #[test]
1420    fn set_property_text_value() {
1421        let arena = build_hello_world();
1422        let heading_id = arena.get_children(0)[0];
1423        let text_id = arena.get_children(heading_id)[0];
1424
1425        let mut buf = Vec::new();
1426        push_set_property(&mut buf, text_id, PROP_STRING, "value", "Goodbye");
1427
1428        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1429        let text_data = result.get_type_data(text_id);
1430        let sref = decode_string_ref_data(text_data);
1431        assert_eq!(result.get_str(sref), "Goodbye");
1432    }
1433
1434    #[test]
1435    fn replace_with_raw_markdown() {
1436        let arena = build_hello_world();
1437        let heading_id = arena.get_children(0)[0];
1438
1439        let raw_md = "## New Heading";
1440        let mut buf = Vec::new();
1441        buf.push(CMD_REPLACE);
1442        push_u32(&mut buf, heading_id);
1443        buf.push(PAYLOAD_RAW_MARKDOWN);
1444        push_u32(&mut buf, raw_md.len() as u32);
1445        buf.extend_from_slice(raw_md.as_bytes());
1446
1447        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1448        let root_children = result.get_children(0);
1449        assert!(root_children.len() >= 2);
1450    }
1451
1452    #[test]
1453    fn replace_with_serde_json() {
1454        let arena = build_hello_world();
1455        let heading_id = arena.get_children(0)[0];
1456
1457        let json =
1458            r#"{"type":"heading","depth":2,"children":[{"type":"text","value":"Replaced"}]}"#;
1459        let mut buf = Vec::new();
1460        buf.push(CMD_REPLACE);
1461        push_u32(&mut buf, heading_id);
1462        buf.push(PAYLOAD_SERDE_JSON);
1463        push_u32(&mut buf, json.len() as u32);
1464        buf.extend_from_slice(json.as_bytes());
1465
1466        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1467        let root_children = result.get_children(0);
1468        assert_eq!(root_children.len(), 2);
1469        let new_heading = root_children[0];
1470        assert_eq!(
1471            result.get_node(new_heading).node_type,
1472            MdastNodeType::Heading as u8
1473        );
1474        let heading_data = result.get_type_data(new_heading);
1475        assert_eq!(decode_heading_data(heading_data).depth, 2);
1476    }
1477
1478    #[test]
1479    fn replace_with_directive_child() {
1480        // Directives serialize `attributes` as a map (`{}`), not the array form
1481        // used by MDX JSX. The deserializer must accept both shapes; without
1482        // that, any plugin returning a tree containing a directive child fails
1483        // with "invalid type: map, expected a sequence".
1484        let arena = build_hello_world();
1485        let heading_id = arena.get_children(0)[0];
1486
1487        let json = r#"{"type":"paragraph","children":[{"type":"text","value":"hi "},{"type":"textDirective","name":"inline","attributes":{},"children":[]}]}"#;
1488        let mut buf = Vec::new();
1489        buf.push(CMD_REPLACE);
1490        push_u32(&mut buf, heading_id);
1491        buf.push(PAYLOAD_SERDE_JSON);
1492        push_u32(&mut buf, json.len() as u32);
1493        buf.extend_from_slice(json.as_bytes());
1494
1495        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1496        let root_children = result.get_children(0);
1497        let new_para = root_children[0];
1498        assert_eq!(
1499            result.get_node(new_para).node_type,
1500            MdastNodeType::Paragraph as u8
1501        );
1502        let para_children = result.get_children(new_para);
1503        assert_eq!(para_children.len(), 2);
1504        let directive = para_children[1];
1505        assert_eq!(
1506            result.get_node(directive).node_type,
1507            MdastNodeType::TextDirective as u8
1508        );
1509        let dir_data = result.get_type_data(directive);
1510        assert_eq!(decode_directive_attr_count(dir_data), 0);
1511    }
1512
1513    #[test]
1514    fn replace_with_directive_attrs() {
1515        // Same as above but with non-empty directive attrs to confirm the map
1516        // shape round-trips into the arena's directive type_data.
1517        let arena = build_hello_world();
1518        let heading_id = arena.get_children(0)[0];
1519
1520        let json = r#"{"type":"containerDirective","name":"tip","attributes":{"id":"foo","class":"bar"},"children":[]}"#;
1521        let mut buf = Vec::new();
1522        buf.push(CMD_REPLACE);
1523        push_u32(&mut buf, heading_id);
1524        buf.push(PAYLOAD_SERDE_JSON);
1525        push_u32(&mut buf, json.len() as u32);
1526        buf.extend_from_slice(json.as_bytes());
1527
1528        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1529        let directive = result.get_children(0)[0];
1530        assert_eq!(
1531            result.get_node(directive).node_type,
1532            MdastNodeType::ContainerDirective as u8
1533        );
1534        let dir_data = result.get_type_data(directive);
1535        assert_eq!(decode_directive_attr_count(dir_data), 2);
1536    }
1537
1538    #[test]
1539    fn multiple_commands() {
1540        let arena = build_hello_world();
1541        let heading_id = arena.get_children(0)[0];
1542        let text_id = arena.get_children(heading_id)[0];
1543
1544        let mut buf = Vec::new();
1545        push_set_property(&mut buf, heading_id, PROP_INT, "depth", "3");
1546        push_set_property(&mut buf, text_id, PROP_STRING, "value", "Hi");
1547
1548        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1549
1550        let heading_data = result.get_type_data(heading_id);
1551        assert_eq!(decode_heading_data(heading_data).depth, 3);
1552
1553        let text_data = result.get_type_data(text_id);
1554        let sref = decode_string_ref_data(text_data);
1555        assert_eq!(result.get_str(sref), "Hi");
1556    }
1557
1558    #[test]
1559    fn set_property_null() {
1560        let arena = build_hello_world();
1561        let heading_id = arena.get_children(0)[0];
1562        let text_id = arena.get_children(heading_id)[0];
1563
1564        let mut buf = Vec::new();
1565        push_set_property(&mut buf, text_id, PROP_NULL, "value", "");
1566
1567        let result = apply_mdast_commands(arena.clone(), &buf, &test_parse_markdown).unwrap();
1568        let text_data = result.get_type_data(text_id);
1569        let sref = decode_string_ref_data(text_data);
1570        assert_eq!(sref.len, 0);
1571    }
1572
1573    #[test]
1574    fn set_property_invalid_field_reports_property_and_node_type() {
1575        let arena = build_hello_world();
1576        let heading_id = arena.get_children(0)[0];
1577
1578        let mut buf = Vec::new();
1579        push_set_property(&mut buf, heading_id, PROP_STRING, "value", "x");
1580
1581        let err = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap_err();
1582        assert!(matches!(
1583            err,
1584            CommandError::UnknownField { ref name, ref node_type }
1585                if name == "value" && node_type == "heading"
1586        ));
1587        assert_eq!(
1588            err.to_string(),
1589            "cannot set property 'value' on a 'heading' node"
1590        );
1591    }
1592
1593    #[test]
1594    fn set_property_wrong_value_type_reports_value_mismatch() {
1595        let arena = build_hello_world();
1596        let heading_id = arena.get_children(0)[0];
1597
1598        // `depth` is a valid heading field, but it holds an int, not a string.
1599        let mut buf = Vec::new();
1600        push_set_property(&mut buf, heading_id, PROP_STRING, "depth", "3");
1601
1602        let err = apply_mdast_commands(arena, &buf, &test_parse_markdown).unwrap_err();
1603        assert!(matches!(
1604            err,
1605            CommandError::InvalidPropertyValue { ref name, ref node_type }
1606                if name == "depth" && node_type == "heading"
1607        ));
1608        assert_eq!(
1609            err.to_string(),
1610            "property 'depth' on a 'heading' node cannot hold a value of this type"
1611        );
1612    }
1613
1614    #[test]
1615    fn js_node_to_arena_basic() {
1616        let js = JsNode {
1617            node_type: "heading".to_string(),
1618            children: Some(vec![JsNode {
1619                node_type: "text".to_string(),
1620                children: None,
1621                value: Some("Hello".to_string()),
1622                depth: None,
1623                url: None,
1624                title: None,
1625                alt: None,
1626                lang: None,
1627                meta: None,
1628                ordered: None,
1629                start: None,
1630                spread: None,
1631                checked: None,
1632                align: None,
1633                identifier: None,
1634                label: None,
1635                reference_type: None,
1636                name: None,
1637                attributes: None,
1638                tag_name: None,
1639                properties: None,
1640                is_hast: false,
1641                keep_children: false,
1642                ref_id: None,
1643                data: None,
1644            }]),
1645            depth: Some(2),
1646            value: None,
1647            url: None,
1648            title: None,
1649            alt: None,
1650            lang: None,
1651            meta: None,
1652            ordered: None,
1653            start: None,
1654            spread: None,
1655            checked: None,
1656            align: None,
1657            identifier: None,
1658            label: None,
1659            reference_type: None,
1660            name: None,
1661            attributes: None,
1662            tag_name: None,
1663            properties: None,
1664            is_hast: false,
1665            keep_children: false,
1666            ref_id: None,
1667            data: None,
1668        };
1669
1670        let (arena, _keep) = js_node_to_mdast_arena(&js).unwrap();
1671        assert_eq!(arena.len(), 2);
1672        assert_eq!(arena.get_node(0).node_type, MdastNodeType::Heading as u8);
1673        assert_eq!(arena.get_children(0).len(), 1);
1674        let text_id = arena.get_children(0)[0];
1675        assert_eq!(arena.get_node(text_id).node_type, MdastNodeType::Text as u8);
1676    }
1677
1678    #[test]
1679    fn escape_braces_in_html_text_basic() {
1680        assert_eq!(
1681            escape_braces_in_html_text("<span>{foo: 1}</span>"),
1682            "<span>{'{'}foo: 1{'}'}</span>"
1683        );
1684    }
1685
1686    #[test]
1687    fn escape_braces_preserves_attributes() {
1688        let result = escape_braces_in_html_text(r#"<span data-x="{a}">{b}</span>"#);
1689        assert!(
1690            result.contains(r#"data-x="{a}""#),
1691            "attribute braces preserved"
1692        );
1693        assert!(result.contains("{'{'}"), "text braces escaped");
1694    }
1695
1696    #[test]
1697    fn escape_braces_no_braces() {
1698        let html = r#"<pre class="shiki"><code><span style="color:red">hello</span></code></pre>"#;
1699        assert_eq!(escape_braces_in_html_text(html), html);
1700    }
1701
1702    #[test]
1703    fn escape_braces_shiki_output() {
1704        let html = r#"<pre class="shiki"><code><span style="color:#E1E4E8">const x = </span><span style="color:#B392F0">{</span><span style="color:#E1E4E8">foo: 1</span><span style="color:#B392F0">}</span></code></pre>"#;
1705        let escaped = escape_braces_in_html_text(html);
1706        assert!(
1707            !escaped.contains(">{<"),
1708            "bare braces in text should be escaped"
1709        );
1710        assert!(
1711            !escaped.contains(">}<"),
1712            "bare braces in text should be escaped"
1713        );
1714        assert!(escaped.contains(r#"class="shiki""#));
1715        assert!(escaped.contains(r#"style="color:#E1E4E8""#));
1716    }
1717
1718    #[test]
1719    fn hast_set_property_add_new() {
1720        let arena = build_hast_element(&[]);
1721        let element_id = arena.get_children(0)[0];
1722
1723        let mut buf = Vec::new();
1724        push_set_property(&mut buf, element_id, PROP_STRING, "class", "test");
1725
1726        let result = apply_hast_commands(arena.clone(), &buf).unwrap();
1727        let data = result.get_type_data(element_id);
1728        let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
1729        assert_eq!(prop_count, 1);
1730        let name_ref = StringRef::new(
1731            u32::from_le_bytes(data[16..20].try_into().unwrap()),
1732            u32::from_le_bytes(data[20..24].try_into().unwrap()),
1733        );
1734        assert_eq!(result.get_str(name_ref), "class");
1735        let val_ref = StringRef::new(
1736            u32::from_le_bytes(data[28..32].try_into().unwrap()),
1737            u32::from_le_bytes(data[32..36].try_into().unwrap()),
1738        );
1739        assert_eq!(result.get_str(val_ref), "test");
1740        assert_eq!(data[24], PROP_STRING);
1741    }
1742
1743    #[test]
1744    fn hast_set_property_overwrite_existing() {
1745        let arena = build_hast_element(&[("class", PROP_STRING, "old")]);
1746        let element_id = arena.get_children(0)[0];
1747
1748        let mut buf = Vec::new();
1749        push_set_property(&mut buf, element_id, PROP_STRING, "class", "new-value");
1750
1751        let result = apply_hast_commands(arena.clone(), &buf).unwrap();
1752        let data = result.get_type_data(element_id);
1753        let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
1754        assert_eq!(prop_count, 1);
1755        let val_ref = StringRef::new(
1756            u32::from_le_bytes(data[28..32].try_into().unwrap()),
1757            u32::from_le_bytes(data[32..36].try_into().unwrap()),
1758        );
1759        assert_eq!(result.get_str(val_ref), "new-value");
1760    }
1761
1762    #[test]
1763    fn hast_set_property_bool_true() {
1764        let arena = build_hast_element(&[]);
1765        let element_id = arena.get_children(0)[0];
1766
1767        let mut buf = Vec::new();
1768        push_set_property(&mut buf, element_id, PROP_BOOL_TRUE, "disabled", "");
1769
1770        let result = apply_hast_commands(arena.clone(), &buf).unwrap();
1771        let data = result.get_type_data(element_id);
1772        let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
1773        assert_eq!(prop_count, 1);
1774        assert_eq!(data[24], PROP_BOOL_TRUE);
1775    }
1776
1777    #[test]
1778    fn hast_set_property_multiple_on_same_node() {
1779        let arena = build_hast_element(&[]);
1780        let element_id = arena.get_children(0)[0];
1781
1782        let mut buf = Vec::new();
1783        push_set_property(&mut buf, element_id, PROP_STRING, "class", "foo");
1784        push_set_property(&mut buf, element_id, PROP_STRING, "id", "bar");
1785
1786        let result = apply_hast_commands(arena.clone(), &buf).unwrap();
1787        let data = result.get_type_data(element_id);
1788        let prop_count = u32::from_le_bytes(data[8..12].try_into().unwrap());
1789        assert_eq!(prop_count, 2);
1790    }
1791
1792    /// Build a minimal HAST element arena: root(type 0) → element(type 1, tag "div")
1793    fn build_hast_element(props: &[(&str, u8, &str)]) -> Arena<Hast> {
1794        use satteri_ast::hast::node::HastNodeType;
1795
1796        let mut b = ArenaBuilder::<Hast>::new(String::new());
1797        b.open_node_raw(HastNodeType::Root as u8);
1798        b.open_node_raw(HastNodeType::Element as u8);
1799        let tag_ref = b.alloc_string("div");
1800        let prop_tuples: Vec<(StringRef, u8, StringRef)> = props
1801            .iter()
1802            .map(|(name, kind, value)| {
1803                let n = b.alloc_string(name);
1804                let v = if value.is_empty() {
1805                    StringRef::empty()
1806                } else {
1807                    b.alloc_string(value)
1808                };
1809                (n, *kind, v)
1810            })
1811            .collect();
1812        let mut type_data = Vec::with_capacity(16 + prop_tuples.len() * 20);
1813        type_data.extend_from_slice(&tag_ref.offset.to_le_bytes());
1814        type_data.extend_from_slice(&tag_ref.len.to_le_bytes());
1815        type_data.extend_from_slice(&(prop_tuples.len() as u32).to_le_bytes());
1816        type_data.extend_from_slice(&0u32.to_le_bytes());
1817        for (n, kind, v) in &prop_tuples {
1818            type_data.extend_from_slice(&n.offset.to_le_bytes());
1819            type_data.extend_from_slice(&n.len.to_le_bytes());
1820            type_data.push(*kind);
1821            type_data.extend_from_slice(&[0u8; 3]);
1822            type_data.extend_from_slice(&v.offset.to_le_bytes());
1823            type_data.extend_from_slice(&v.len.to_le_bytes());
1824        }
1825        b.set_data_current(&type_data);
1826        b.close_node();
1827        b.close_node();
1828        b.finish()
1829    }
1830}