Skip to main content

docgen_core/
directivepass.rs

1//! Source-level directive pre/post pass. `extract` rewrites raw markdown,
2//! replacing each directive with an HTML-comment sentinel and returning the
3//! parsed instances; `substitute` swaps sentinels for rendered component HTML
4//! after comrak has formatted the surrounding markdown.
5//!
6//! Why a source-level pre-pass and not a comrak AST pass: comrak 0.52 has no
7//! generic `:::` directive extension, and a block directive's inner content must
8//! itself be parsed as markdown. Reconstructing block boundaries from a flattened
9//! inline AST is fragile and loses the raw inner-markdown span we need. Operating
10//! on the raw body string before `parse_document` keeps the directive system
11//! orthogonal to comrak's AST passes (wikilink/math/mermaid still run on the
12//! rewritten source) and yields the verbatim inner-markdown span block directives
13//! require. The sentinel is an HTML comment so comrak passes it through verbatim
14//! (with `render.unsafe = true`); a post-pass substitutes the rendered HTML.
15
16use std::collections::BTreeMap;
17
18/// One directive found in a doc body.
19#[derive(Debug, Clone, PartialEq)]
20pub struct DirectiveInstance {
21    pub name: String,
22    pub attrs: BTreeMap<String, String>,
23    /// Leaf `[label]`; empty for block form.
24    pub label: String,
25    /// Block inner markdown; empty for leaf form.
26    pub inner_md: String,
27    pub is_block: bool,
28}
29
30/// The sentinel a directive is replaced with in the rewritten source. `idx` is
31/// the instance index. An HTML comment so comrak passes it through verbatim.
32fn sentinel(idx: usize) -> String {
33    format!("<!--docgen-directive:{idx}-->")
34}
35
36/// True if `c` may start/continue a directive name (`[A-Za-z][A-Za-z0-9_-]*`).
37fn is_name_start(c: char) -> bool {
38    c.is_ascii_alphabetic()
39}
40fn is_name_char(c: char) -> bool {
41    c.is_ascii_alphanumeric() || c == '_' || c == '-'
42}
43
44/// Parse an attr string (`type=warning title="x y" wide`) → ordered map. Total:
45/// malformed input degrades gracefully (best-effort token split), never panics.
46/// A bare key (`wide`) becomes `wide="true"`.
47pub fn parse_attrs(s: &str) -> BTreeMap<String, String> {
48    let mut out = BTreeMap::new();
49    let chars: Vec<char> = s.chars().collect();
50    let mut i = 0;
51    while i < chars.len() {
52        // Skip whitespace between tokens.
53        if chars[i].is_whitespace() {
54            i += 1;
55            continue;
56        }
57        // Read a key: up to `=` or whitespace.
58        let key_start = i;
59        while i < chars.len() && chars[i] != '=' && !chars[i].is_whitespace() {
60            i += 1;
61        }
62        let key: String = chars[key_start..i].iter().collect();
63        if key.is_empty() {
64            i += 1;
65            continue;
66        }
67        // Bare key (no `=`): value "true".
68        if i >= chars.len() || chars[i] != '=' {
69            out.insert(key, "true".to_string());
70            continue;
71        }
72        // Consume `=` and read the value (quoted or bare).
73        i += 1; // skip '='
74        let value = if i < chars.len() && chars[i] == '"' {
75            i += 1; // skip opening quote
76            let v_start = i;
77            while i < chars.len() && chars[i] != '"' {
78                i += 1;
79            }
80            let v: String = chars[v_start..i].iter().collect();
81            if i < chars.len() {
82                i += 1; // skip closing quote
83            }
84            v
85        } else {
86            let v_start = i;
87            while i < chars.len() && !chars[i].is_whitespace() {
88                i += 1;
89            }
90            chars[v_start..i].iter().collect()
91        };
92        out.insert(key, value);
93    }
94    out
95}
96
97/// Parse a `:::<name>{attrs}` open fence line (already trimmed). Returns
98/// `(name, attrs_str)` on success.
99fn parse_block_open(trimmed: &str) -> Option<(String, String)> {
100    let rest = trimmed.strip_prefix(":::")?;
101    let mut chars = rest.char_indices();
102    let (first_i, first) = chars.next()?;
103    debug_assert_eq!(first_i, 0);
104    if !is_name_start(first) {
105        return None;
106    }
107    let mut end = first.len_utf8();
108    for (i, c) in rest.char_indices().skip(1) {
109        if is_name_char(c) {
110            end = i + c.len_utf8();
111        } else {
112            break;
113        }
114    }
115    let name = &rest[..end];
116    let after = rest[end..].trim();
117    // After the name, only an optional `{...}` attr block (and nothing else).
118    let attrs = if after.is_empty() {
119        String::new()
120    } else if after.starts_with('{') && after.ends_with('}') {
121        after[1..after.len() - 1].to_string()
122    } else {
123        return None;
124    };
125    Some((name.to_string(), attrs))
126}
127
128/// A fenced-code-block delimiter parsed from a line: the fence char (`` ` `` or
129/// `~`), its run length, and the line's leading indentation width. A closing
130/// fence must use the same char with a run length >= the opener's and carry no
131/// info string. Returned for any line that *could* open or close a fence.
132struct Fence {
133    ch: char,
134    len: usize,
135    has_info: bool,
136}
137
138/// If `line` is a fenced-code delimiter (`` ``` ``/`~~~`, possibly indented up to
139/// three spaces, with an optional info string), return its `Fence`. Mirrors
140/// CommonMark fence recognition closely enough to guard directive content.
141fn parse_fence(line: &str) -> Option<Fence> {
142    let indent = line.len() - line.trim_start().len();
143    if indent > 3 {
144        return None; // 4+ spaces is an indented code block, not a fence
145    }
146    let rest = &line[indent..];
147    let ch = rest.chars().next()?;
148    if ch != '`' && ch != '~' {
149        return None;
150    }
151    let len = rest.chars().take_while(|&c| c == ch).count();
152    if len < 3 {
153        return None;
154    }
155    let info = rest.chars().skip(len).collect::<String>();
156    let info = info.trim();
157    // A backtick info string may not itself contain a backtick (CommonMark).
158    if ch == '`' && info.contains('`') {
159        return None;
160    }
161    Some(Fence {
162        ch,
163        len,
164        has_info: !info.is_empty(),
165    })
166}
167
168/// Pass 1: scan `body_md`, replace directives with sentinels, return instances
169/// (index-aligned with the sentinels). Unknown-vs-known is NOT decided here —
170/// every syntactic directive is extracted; resolution happens in `substitute`.
171///
172/// Code-aware: lines inside a fenced code block (and inline-code spans within a
173/// line) are emitted verbatim and never scanned for directives, so a doc that
174/// *documents* the directive syntax in a code sample keeps it literal.
175pub fn extract(body_md: &str) -> (String, Vec<DirectiveInstance>) {
176    let mut instances: Vec<DirectiveInstance> = Vec::new();
177    let mut out_lines: Vec<String> = Vec::new();
178
179    let lines: Vec<&str> = body_md.split('\n').collect();
180    let mut i = 0;
181    // Open fence we are currently inside, if any.
182    let mut open_fence: Option<Fence> = None;
183    while i < lines.len() {
184        let line = lines[i];
185
186        // Inside a fenced code block: emit verbatim, only watching for the close.
187        if let Some(open) = &open_fence {
188            if let Some(f) = parse_fence(line) {
189                if f.ch == open.ch && f.len >= open.len && !f.has_info {
190                    open_fence = None;
191                }
192            }
193            out_lines.push(line.to_string());
194            i += 1;
195            continue;
196        }
197        // A fence opener starts a code block; skip directive scanning until close.
198        if let Some(f) = parse_fence(line) {
199            open_fence = Some(f);
200            out_lines.push(line.to_string());
201            i += 1;
202            continue;
203        }
204
205        let trimmed = line.trim();
206
207        // Escaped directive opener: `\:::name...` → emit literal, drop backslash.
208        if let Some(rest) = trimmed.strip_prefix('\\') {
209            if rest.starts_with(":::") || (rest.starts_with(':') && looks_like_leaf(rest)) {
210                let indent = &line[..line.len() - line.trim_start().len()];
211                out_lines.push(format!("{indent}{rest}"));
212                i += 1;
213                continue;
214            }
215        }
216
217        // Block directive open?
218        if let Some((name, attrs_str)) = parse_block_open(trimmed) {
219            // Collect inner lines until the matching `:::` close (depth-counted).
220            let mut depth = 1;
221            let mut inner: Vec<&str> = Vec::new();
222            let mut j = i + 1;
223            let mut closed = false;
224            while j < lines.len() {
225                let t = lines[j].trim();
226                if t == ":::" {
227                    depth -= 1;
228                    if depth == 0 {
229                        closed = true;
230                        break;
231                    }
232                } else if parse_block_open(t).is_some() {
233                    depth += 1;
234                }
235                inner.push(lines[j]);
236                j += 1;
237            }
238            if closed {
239                let idx = instances.len();
240                instances.push(DirectiveInstance {
241                    name,
242                    attrs: parse_attrs(&attrs_str),
243                    label: String::new(),
244                    inner_md: inner.join("\n"),
245                    is_block: true,
246                });
247                out_lines.push(sentinel(idx));
248                i = j + 1; // skip past the closing `:::`
249                continue;
250            }
251            // Unterminated block: fall through, treat line as ordinary text.
252        }
253
254        // Otherwise scan the line for inline leaf directives.
255        out_lines.push(scan_leaf_line(line, &mut instances));
256        i += 1;
257    }
258
259    (out_lines.join("\n"), instances)
260}
261
262/// Heuristic for the escape branch: does `rest` (after a leading `:`) look like a
263/// leaf directive `name[...]` or `name{...}`?
264fn looks_like_leaf(rest: &str) -> bool {
265    let body = &rest[1..];
266    let name_len = body
267        .char_indices()
268        .take_while(|(k, c)| {
269            if *k == 0 {
270                is_name_start(*c)
271            } else {
272                is_name_char(*c)
273            }
274        })
275        .map(|(_, c)| c.len_utf8())
276        .sum::<usize>();
277    if name_len == 0 {
278        return false;
279    }
280    matches!(body[name_len..].chars().next(), Some('[') | Some('{'))
281}
282
283/// Replace every inline `:name[label]{attrs}` leaf directive in `line` with its
284/// sentinel, appending instances. A `:::` block opener is never matched here
285/// (block openers are handled before this is called, and a `::` prefix is
286/// skipped). Plain `:` in prose (`10:30`) is left untouched.
287fn scan_leaf_line(line: &str, instances: &mut Vec<DirectiveInstance>) -> String {
288    let chars: Vec<char> = line.chars().collect();
289    let mut out = String::with_capacity(line.len());
290    let mut i = 0;
291    while i < chars.len() {
292        // Inline code span: a run of N backticks is closed by the next run of
293        // exactly N backticks. Everything between is emitted verbatim and never
294        // scanned for directives (so `` `:note[x]{}` `` stays literal source).
295        if chars[i] == '`' {
296            let run = (i..chars.len()).take_while(|&k| chars[k] == '`').count();
297            if let Some(end) = find_inline_code_close(&chars, i + run, run) {
298                out.extend(&chars[i..end]); // open + content + close, verbatim
299                i = end;
300            } else {
301                // Unterminated run: emit the backticks literally and continue.
302                out.extend(&chars[i..i + run]);
303                i += run;
304            }
305            continue;
306        }
307        if chars[i] == ':' {
308            // Not a leaf if preceded or followed by another colon (`::`).
309            let prev_colon = i > 0 && chars[i - 1] == ':';
310            let next_colon = i + 1 < chars.len() && chars[i + 1] == ':';
311            if !prev_colon && !next_colon {
312                if let Some((inst, consumed)) = try_parse_leaf(&chars, i) {
313                    let idx = instances.len();
314                    instances.push(inst);
315                    out.push_str(&sentinel(idx));
316                    i += consumed;
317                    continue;
318                }
319            }
320        }
321        out.push(chars[i]);
322        i += 1;
323    }
324    out
325}
326
327/// Given a backtick run of length `run` opened just before `from`, return the
328/// index *past* the matching closing run (a run of exactly `run` backticks), or
329/// `None` if the span is unterminated on this line.
330fn find_inline_code_close(chars: &[char], from: usize, run: usize) -> Option<usize> {
331    let mut j = from;
332    while j < chars.len() {
333        if chars[j] == '`' {
334            let close = (j..chars.len()).take_while(|&k| chars[k] == '`').count();
335            if close == run {
336                return Some(j + close);
337            }
338            j += close;
339        } else {
340            j += 1;
341        }
342    }
343    None
344}
345
346/// Try to parse a leaf directive starting at `chars[start] == ':'`. Returns the
347/// instance and the number of chars consumed (including the leading `:`).
348fn try_parse_leaf(chars: &[char], start: usize) -> Option<(DirectiveInstance, usize)> {
349    let mut i = start + 1; // skip ':'
350    if i >= chars.len() || !is_name_start(chars[i]) {
351        return None;
352    }
353    let name_start = i;
354    while i < chars.len() && is_name_char(chars[i]) {
355        i += 1;
356    }
357    let name: String = chars[name_start..i].iter().collect();
358
359    // Leaf form: an optional `[label]` then an optional `{attrs}`. At least one
360    // must be present — a bare `:name` is not a directive (so `:include{src=...}`
361    // parses, while plain `:foo` text does not).
362    let mut label = String::new();
363    let mut had_label = false;
364    if i < chars.len() && chars[i] == '[' {
365        i += 1; // skip '['
366        let label_start = i;
367        while i < chars.len() && chars[i] != ']' {
368            i += 1;
369        }
370        if i >= chars.len() {
371            return None; // unterminated label
372        }
373        label = chars[label_start..i].iter().collect();
374        i += 1; // skip ']'
375        had_label = true;
376    }
377
378    // Optional `{attrs}`.
379    let mut attrs = BTreeMap::new();
380    let mut had_attrs = false;
381    if i < chars.len() && chars[i] == '{' {
382        i += 1; // skip '{'
383        let a_start = i;
384        // Scan to the closing `}`, but skip any `}` inside a double-quoted value
385        // (mirrors parse_attrs' quote handling) so `{title="a } b"}` parses whole.
386        let mut in_quote = false;
387        while i < chars.len() && (in_quote || chars[i] != '}') {
388            if chars[i] == '"' {
389                in_quote = !in_quote;
390            }
391            i += 1;
392        }
393        if i >= chars.len() {
394            return None; // unterminated attrs
395        }
396        let attrs_str: String = chars[a_start..i].iter().collect();
397        attrs = parse_attrs(&attrs_str);
398        i += 1; // skip '}'
399        had_attrs = true;
400    }
401
402    if !had_label && !had_attrs {
403        return None;
404    }
405
406    Some((
407        DirectiveInstance {
408            name,
409            attrs,
410            label,
411            inner_md: String::new(),
412            is_block: false,
413        },
414        i - start,
415    ))
416}
417
418/// Pass 2: replace each `<!--docgen-directive:N-->` sentinel in `html` with the
419/// component's rendered HTML. `render_inner` renders a block directive's inner
420/// markdown to HTML (the full pipeline, recursively). Returns the substituted
421/// HTML and the set of component names that were actually rendered (for per-page
422/// island/style gating). An unknown directive (or a component whose template
423/// errors) becomes a clearly-marked inert error span — never a crash.
424pub fn substitute(
425    html: &str,
426    instances: &[DirectiveInstance],
427    registry: &docgen_components::Registry,
428    render_inner: &dyn Fn(&str) -> String,
429    resolve_include: &dyn Fn(&str) -> String,
430) -> (String, std::collections::BTreeSet<String>) {
431    use docgen_components::DirectiveContext;
432    let mut used = std::collections::BTreeSet::new();
433    let mut out = html.to_string();
434    for (idx, inst) in instances.iter().enumerate() {
435        // `:include{src=...}` is a built-in, file-transcluding directive — not a
436        // registry component. It renders the resolved partial's markdown here.
437        if inst.name == "include" {
438            let src = inst.attrs.get("src").map(String::as_str).unwrap_or("");
439            let rendered = if src.is_empty() {
440                error_span("include", "missing `src`")
441            } else {
442                resolve_include(src)
443            };
444            out = out.replace(&sentinel(idx), &rendered);
445            continue;
446        }
447        let rendered = match registry.get(&inst.name) {
448            Some(component) => {
449                let content = if inst.is_block {
450                    render_inner(&inst.inner_md)
451                } else {
452                    String::new()
453                };
454                let ctx = DirectiveContext {
455                    attrs: inst.attrs.clone(),
456                    content,
457                    label: inst.label.clone(),
458                    id: format!("docgen-d-{idx}"),
459                };
460                match component.render(&ctx) {
461                    Ok(h) => {
462                        used.insert(inst.name.clone());
463                        h
464                    }
465                    Err(_) => error_span(&inst.name, "template error"),
466                }
467            }
468            None => error_span(&inst.name, "unknown directive"),
469        };
470        out = out.replace(&sentinel(idx), &rendered);
471    }
472    (out, used)
473}
474
475/// An inert, clearly-marked error span for an unresolved/failed directive. The
476/// directive name is HTML-escaped so a malformed name cannot inject markup.
477pub(crate) fn error_span(name: &str, reason: &str) -> String {
478    let safe = crate::util::escape_html(name);
479    format!(
480        "<span class=\"docgen-directive-error\" data-directive=\"{safe}\">[docgen: {reason} `{safe}`]</span>"
481    )
482}
483
484#[cfg(test)]
485mod substitute_tests {
486    use super::*;
487
488    fn reg_with(name: &str, tpl: &str) -> docgen_components::Registry {
489        let mut r = docgen_components::Registry::empty();
490        r.insert(docgen_components::Component::from_parts(
491            name, tpl, None, None,
492        ));
493        r
494    }
495
496    #[test]
497    fn substitutes_known_block_component_and_renders_inner() {
498        let (html, inst) = extract(":::callout{type=note}\n**hi**\n:::\n");
499        let reg = reg_with(
500            "callout",
501            "<aside class=\"c--{{ attrs.type }}\">{{ content | safe }}</aside>",
502        );
503        let render_inner = |md: &str| format!("<p>{}</p>", md.trim().replace("**", ""));
504        let (out, used) = substitute(&html, &inst, &reg, &render_inner, &|_s| String::new());
505        assert!(out.contains("c--note"));
506        assert!(out.contains("<p>hi</p>"));
507        assert!(used.contains("callout"));
508        assert!(!out.contains("docgen-directive:")); // sentinel gone
509    }
510
511    #[test]
512    fn unknown_directive_becomes_marked_error_span_not_panic() {
513        let (html, inst) = extract(":bogus[x]{}\n");
514        let reg = docgen_components::Registry::empty();
515        let (out, used) = substitute(&html, &inst, &reg, &|s| s.to_string(), &|_s| String::new());
516        assert!(out.contains("docgen-directive-error"));
517        assert!(out.contains("unknown directive"));
518        assert!(out.contains("bogus"));
519        assert!(used.is_empty());
520    }
521
522    /// Build a doc that is just the sentinel for instance 0.
523    fn sentinel_doc() -> String {
524        format!("before {} after", sentinel(0))
525    }
526
527    #[test]
528    fn directive_name_in_error_is_escaped() {
529        // Craft an instance with a name that contains markup to exercise escaping.
530        let inst = vec![DirectiveInstance {
531            name: "<img>".into(),
532            attrs: Default::default(),
533            label: String::new(),
534            inner_md: String::new(),
535            is_block: false,
536        }];
537        let html = sentinel_doc();
538        let (out, _) = substitute(
539            &html,
540            &inst,
541            &docgen_components::Registry::empty(),
542            &|s| s.to_string(),
543            &|_s| String::new(),
544        );
545        assert!(out.contains("&lt;img&gt;"));
546        assert!(!out.contains("<img>"));
547    }
548
549    #[test]
550    fn template_error_becomes_error_span_not_panic() {
551        // A template referencing an undefined filter fails to render.
552        let reg = reg_with("boom", "{{ content | nonexistent_filter }}");
553        let (html, inst) = extract(":::boom{}\nx\n:::\n");
554        let (out, used) = substitute(&html, &inst, &reg, &|s| s.to_string(), &|_s| String::new());
555        assert!(out.contains("docgen-directive-error"));
556        assert!(out.contains("template error"));
557        assert!(used.is_empty());
558    }
559}
560
561#[cfg(test)]
562mod extract_tests {
563    use super::*;
564
565    #[test]
566    fn parse_attrs_handles_bare_quoted_and_empty() {
567        let a = parse_attrs("type=warning title=\"Back up first\" wide");
568        assert_eq!(a.get("type").unwrap(), "warning");
569        assert_eq!(a.get("title").unwrap(), "Back up first");
570        assert_eq!(a.get("wide").unwrap(), "true");
571        assert!(parse_attrs("").is_empty());
572    }
573
574    #[test]
575    fn extracts_block_directive_with_inner_markdown() {
576        let src = ":::callout{type=warning title=\"Heads up\"}\nThis is **bold**.\n:::\n";
577        let (out, inst) = extract(src);
578        assert_eq!(inst.len(), 1);
579        assert!(inst[0].is_block);
580        assert_eq!(inst[0].name, "callout");
581        assert_eq!(inst[0].attrs.get("type").unwrap(), "warning");
582        assert_eq!(inst[0].inner_md.trim(), "This is **bold**.");
583        assert!(out.contains("<!--docgen-directive:0-->"));
584        assert!(!out.contains(":::"));
585    }
586
587    #[test]
588    fn extracts_leaf_directive_with_label_and_attrs() {
589        let src = "See :youtube[Intro]{id=abc123} now.\n";
590        let (out, inst) = extract(src);
591        assert_eq!(inst.len(), 1);
592        assert!(!inst[0].is_block);
593        assert_eq!(inst[0].name, "youtube");
594        assert_eq!(inst[0].label, "Intro");
595        assert_eq!(inst[0].attrs.get("id").unwrap(), "abc123");
596        assert!(out.contains("See <!--docgen-directive:0--> now."));
597    }
598
599    #[test]
600    fn nested_block_directives_match_outermost() {
601        let src = ":::callout{type=note}\nouter\n:::callout{type=warning}\ninner\n:::\n:::\n";
602        let (_out, inst) = extract(src);
603        assert_eq!(inst.len(), 1); // only the outer is extracted at this level
604        assert!(inst[0].inner_md.contains(":::callout{type=warning}"));
605        assert!(inst[0].inner_md.contains("inner"));
606    }
607
608    #[test]
609    fn escaped_directive_is_left_literal() {
610        let src = "\\:::callout{}\nnot a directive\n:::\n";
611        let (out, inst) = extract(src);
612        assert!(inst.is_empty());
613        assert!(out.contains(":::callout{}")); // literal, backslash removed
614    }
615
616    #[test]
617    fn plain_text_with_colons_is_not_a_directive() {
618        let src = "time is 10:30 and ratio 3:4\n";
619        let (out, inst) = extract(src);
620        assert!(inst.is_empty());
621        assert_eq!(out, src);
622    }
623
624    #[test]
625    fn block_directive_inside_fenced_code_is_left_literal() {
626        // A docs author showing the directive syntax in a ``` fence must keep it
627        // verbatim — comrak then renders the fence as a literal code block.
628        let src = "```\n:::callout{type=note}\nhello\n:::\n```\n";
629        let (out, inst) = extract(src);
630        assert!(
631            inst.is_empty(),
632            "directive inside a code fence must not be extracted"
633        );
634        assert!(out.contains(":::callout{type=note}"));
635        assert!(out.contains("hello"));
636        assert!(!out.contains("docgen-directive"));
637    }
638
639    #[test]
640    fn block_directive_inside_tilde_fence_with_info_is_left_literal() {
641        let src = "~~~markdown\n:::callout{type=warning}\nBe careful.\n:::\n~~~\n";
642        let (out, inst) = extract(src);
643        assert!(inst.is_empty());
644        assert!(out.contains(":::callout{type=warning}"));
645        assert!(out.contains("Be careful."));
646    }
647
648    #[test]
649    fn leaf_directive_inside_inline_code_is_left_literal() {
650        let src = "Use `:youtube[x]{id=1}` syntax.\n";
651        let (out, inst) = extract(src);
652        assert!(
653            inst.is_empty(),
654            "directive inside inline code must not be extracted"
655        );
656        assert!(out.contains("`:youtube[x]{id=1}`"));
657        assert!(!out.contains("docgen-directive"));
658    }
659
660    #[test]
661    fn leaf_directive_outside_inline_code_on_same_line_still_parses() {
662        // Inline code is skipped, but a real directive elsewhere on the line works.
663        let src = "code `:a[x]{}` then :note[real]{} here\n";
664        let (_out, inst) = extract(src);
665        assert_eq!(inst.len(), 1);
666        assert_eq!(inst[0].name, "note");
667        assert_eq!(inst[0].label, "real");
668    }
669
670    #[test]
671    fn indented_code_fence_is_respected() {
672        // A fence indented under a list item still guards its body.
673        let src = "- item\n\n  ```\n  :::callout{}\n  body\n  ```\n";
674        let (_out, inst) = extract(src);
675        assert!(inst.is_empty());
676    }
677
678    #[test]
679    fn leaf_attrs_with_brace_inside_quoted_value() {
680        // The closing `}` scan must respect quotes so `}` inside a value is kept.
681        let src = ":note[hi]{title=\"a } b\"}\n";
682        let (_out, inst) = extract(src);
683        assert_eq!(inst.len(), 1);
684        assert_eq!(inst[0].attrs.get("title").unwrap(), "a } b");
685    }
686}