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