Skip to main content

moss_core/ast/
shortcode_extract.rs

1//! Pre-parse extraction of `:::shortcode` blocks from markdown source.
2//!
3//! Walks the markdown line-by-line, tracking fenced code blocks (so
4//! `:::buttons` inside a code fence stays inert) and recognizing
5//! `:::name ...args` / `:::` openers/closers. Each block is replaced with
6//! a sentinel HTML comment (`<!--MOSS_SC_{nonce}_N-->`) that pulldown-cmark
7//! emits as a `Block::Other` raw HTML; the final parser pass walks the
8//! AST and substitutes the sentinels with typed [`Shortcode`] variants.
9//!
10//! Why this design:
11//!
12//! - `:::` block syntax is not standard CommonMark; pulldown-cmark sees
13//!   it as plain text inside a paragraph. Post-parse text-matching is
14//!   fragile (works only when the shortcode is the entire paragraph).
15//! - Pre-parse extraction with a sentinel is the same pattern Zola uses
16//!   and preserves parsing correctness for adjacent content.
17//! - The sentinel is an HTML comment so it survives pulldown-cmark intact
18//!   (pulldown-cmark passes HTML comments through `Event::Html` as
19//!   `Block::HtmlBlock`).
20
21use super::attrs::gather_multi_line_attrs;
22use super::cells::split_cells;
23use super::node::Block;
24use super::shortcode::{
25    ApplyShortcode, ButtonItem, ButtonsShortcode, GalleryItem, GalleryShortcode, GridShortcode,
26    HeroShortcode, RecentShortcode, Shortcode, SubscribeShortcode,
27};
28use super::url::Url;
29
30/// One extracted shortcode block, with its body parsed into a typed variant.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ExtractedShortcode {
33    /// 0-based index used in the placeholder sentinel.
34    pub index: usize,
35    /// Parsed shortcode (typed variants per Phase B).
36    pub shortcode: Shortcode,
37}
38
39/// Result of pre-parse extraction.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ExtractionResult {
42    /// Markdown source with `:::shortcode` blocks replaced by sentinel
43    /// HTML comments. Pulldown-cmark sees this as the input.
44    pub markdown_with_placeholders: String,
45    /// One entry per extracted block, indexed by sentinel number.
46    pub extracted: Vec<ExtractedShortcode>,
47    /// Per-extraction nonce (8 hex chars). Derived from a hash of the
48    /// input markdown so it's deterministic but collision-resistant
49    /// against authored content. The placeholder format is
50    /// `<!--MOSS_SC_{nonce}_{index}-->`; an authored markdown comment
51    /// matching that exact shape would have to embed the same hash of
52    /// itself, which is computationally improbable for any input shorter
53    /// than the SHA universe.
54    pub nonce: String,
55    /// Build warnings collected during extraction (e.g. unknown shortcode
56    /// names). Each entry is a one-line human-readable string. Caller
57    /// surfaces these in the build log; presence does not abort the build.
58    pub warnings: Vec<String>,
59}
60
61/// Names recognized by the typed AST. Other names fall through to the
62/// unknown-name renderer (`<div class="moss-unknown-shortcode" data-name="…">`)
63/// with a build warning.
64const TYPED_KNOWN: &[&str] = &["subscribe", "buttons", "gallery", "hero", "grid", "recent", "apply"];
65
66fn is_typed_known(name: &str) -> bool {
67    TYPED_KNOWN.contains(&name)
68}
69
70/// Recognized shortcode names (Phase B Task 7+ adds variants here).
71///
72/// `args` is the trailing text after `:::name ` on the opening line
73/// (e.g. for `:::buttons {.primary}`, args is `{.primary}`).
74///
75/// Returns `(Some(Shortcode), Vec<String>)` where the second element is
76/// parse-time deprecation warnings. An empty warning vec means the block
77/// used only current-grammar syntax.
78fn parse_shortcode_block(name: &str, args: &str, body: &str) -> (Option<Shortcode>, Vec<String>) {
79    match name {
80        "subscribe" => (Some(Shortcode::Subscribe(parse_subscribe_args(args))), vec![]),
81        "buttons" => (Some(Shortcode::Buttons(parse_buttons_body(args, body))), vec![]),
82        "gallery" => (Some(Shortcode::Gallery(parse_gallery_body(args, body))), vec![]),
83        "hero" => {
84            let (sc, used_p3) = parse_hero(args, body);
85            let mut warns = vec![];
86            if used_p3 {
87                warns.push(
88                    "shortcode `:::hero` uses a body-image fallback (deprecated Priority 3). \
89                     Move the image path to the `image=` attribute: \
90                     `:::hero {image=path.jpg}`."
91                        .to_string(),
92                );
93            }
94            if let Some(ref v) = sc.mobile {
95                if v != "overlay" {
96                    warns.push(format!(
97                        "shortcode `:::hero` has unrecognized `mobile={v}`. \
98                         Only `mobile=overlay` is recognized. The attribute is ignored."
99                    ));
100                }
101            }
102            (Some(Shortcode::Hero(sc)), warns)
103        }
104        "grid" => {
105            let (sc, legacy) = parse_grid(args, body);
106            let mut warns = vec![];
107            if legacy {
108                warns.push(
109                    "shortcode `:::grid` uses `---` cell dividers (deprecated). Migrate to `+++`.\n\
110                     `---` support will be removed in a future release."
111                        .to_string(),
112                );
113            }
114            (Some(Shortcode::Grid(sc)), warns)
115        }
116        "recent" => (Some(Shortcode::Recent(parse_recent_args(args, body))), vec![]),
117        "apply" => (Some(Shortcode::Apply(parse_apply_args(args))), vec![]),
118        _ => (None, vec![]),
119    }
120}
121
122/// Parse `:::recent {since=... last=... count=...}` body into a typed struct.
123///
124/// `args` is the attribute block (e.g. `{since="2026-04-01" count="5"}`);
125/// `body` is the content between the opening and closing `:::` fences,
126/// captured verbatim (trimmed) as the fallback markdown for the zero-match
127/// render path.
128///
129/// Tolerant: unknown keys are ignored. A `count=` value that fails to parse
130/// as a `u32` becomes `None`; the renderer falls back to its default (10).
131/// `since` and `last` are passed through as raw strings — the rendering
132/// layer parses them into a `DateTime` / `Duration` so this stays I/O-free
133/// and chrono-free (moss-core invariant: pure data in / data out).
134pub fn parse_recent_args(args: &str, body: &str) -> RecentShortcode {
135    let attrs = super::attrs::parse_attrs(args).unwrap_or_default();
136    RecentShortcode {
137        since: attrs.get("since").map(str::to_string),
138        last: attrs.get("last").map(str::to_string),
139        count: attrs.get("count").and_then(|v| v.parse::<u32>().ok()),
140        fallback_markdown: body.trim().to_string(),
141    }
142}
143
144/// Parse a `:::grid` block.
145///
146/// Args parsing supports both:
147/// - **Positional** (legacy moss-releases): `:::grid 2 1:2 {.classes}` —
148///   first token is column count, second optional token is the ratio.
149/// - **Attribute** (new grammar): `:::grid {cols=2}` or `:::grid {cols=1:1:2}` —
150///   `cols=integer` sets the column count; `cols=ratio` sets both the
151///   ratio and the count (= ratio length).
152///
153/// Cells are split on lines containing only `+++` (new grammar) or
154/// `---` (legacy moss-releases). Step 3 of #613 rewrites `---` to `+++`
155/// in moss-releases content; the parser accepts both during the
156/// migration window.
157///
158/// Returns `(GridShortcode, bool)` where the bool is `true` when any
159/// `---` legacy divider was encountered (triggers a deprecation warning).
160fn parse_grid(args: &str, body: &str) -> (GridShortcode, bool) {
161    let trimmed = args.trim();
162    let (positional, attr_block): (&str, &str) = if let Some(pos) = trimmed.find('{') {
163        // char-aligned: pos points to ASCII '{' from str::find — safe to slice.
164        #[allow(clippy::string_slice)]
165        (trimmed[..pos].trim(), &trimmed[pos..])
166    } else {
167        (trimmed, "")
168    };
169
170    let parsed = if attr_block.is_empty() {
171        Default::default()
172    } else {
173        super::attrs::parse_attrs(attr_block).unwrap_or_default()
174    };
175    let classes = parsed.class_string();
176    let width = parsed.width.map(str::to_string);
177
178    let mut columns: u32 = 1;
179    let mut ratio: Option<String> = None;
180
181    if let Some(cols_value) = parsed.get("cols") {
182        if cols_value.contains(':') {
183            ratio = Some(cols_value.to_string());
184            columns = cols_value.split(':').count() as u32;
185        } else if let Ok(n) = cols_value.parse::<u32>() {
186            columns = n.max(1);
187        }
188    } else {
189        // Positional fallback: e.g. `2 1:2`.
190        let parts: Vec<&str> = positional.split_whitespace().collect();
191        if let Some(first) = parts.first() {
192            if first.contains(':') {
193                ratio = Some(first.to_string());
194                columns = first.split(':').count() as u32;
195            } else if let Ok(n) = first.parse::<u32>() {
196                columns = n.max(1);
197                if let Some(second) = parts.get(1) {
198                    if second.contains(':') {
199                        ratio = Some(second.to_string());
200                    }
201                }
202            }
203        }
204    }
205
206    let (raw_cells, found_legacy_dash) = split_grid_cells(body);
207
208    // Phase 4 PR4.5 (2026-05-28): cells become Vec<Vec<Block>>. Each raw
209    // cell string is either:
210    //
211    // - A "compound-link" cell whose entire content is wrapped in a markdown
212    //   link `[inner](url)` and whose `inner` carries block-level content
213    //   (image + heading + paragraphs — the SoCiviC pattern). CommonMark's
214    //   inline parser cannot represent a `[](url)` with `### heading` inside,
215    //   so we detect this shape at the cell-string level FIRST and emit a
216    //   typed [`Block::LinkCard { url, children }`] where `children` is the
217    //   inner content parsed as blocks via [`super::parser::parse`].
218    //
219    // - A plain markdown cell. Parse via [`super::parser::parse`] (which
220    //   re-runs extract_shortcodes so any nested `::::buttons` etc. get
221    //   substituted) and drop the wrapping `Document`.
222    let cells: Vec<Vec<Block>> = raw_cells
223        .iter()
224        .map(|raw| parse_cell_to_blocks(raw))
225        .collect();
226
227    (
228        GridShortcode {
229            columns,
230            ratio,
231            classes,
232            cells,
233            width,
234        },
235        found_legacy_dash,
236    )
237}
238
239/// Parse one grid cell's raw markdown source into a `Vec<Block>`.
240///
241/// Phase 4 PR4.5 (2026-05-28): detects the compound-link shape first
242/// (`[inner](url)` wrapping the entire trimmed cell content). On match,
243/// emits a single-element `vec![Block::LinkCard { url, children }]` where
244/// `children` is the inner parsed as blocks. On no match, parses the cell
245/// directly via [`super::parser::parse`].
246fn parse_cell_to_blocks(raw: &str) -> Vec<Block> {
247    if let Some((url, inner)) = detect_compound_link(raw) {
248        let inner_trimmed = inner.trim();
249        // Simple compound-link special case: when the inner content is
250        // plain phrasing text (no images, no nested links, no
251        // block-level markdown) AND the URL is external, fall through
252        // to the normal markdown parse so the cell renders as
253        // `<p><a href="URL">text</a></p>` — the shape `build/render/
254        // grid_post.rs::link_only_cell_href` detects to layer the
255        // `<span class="link-preview-title">` post-pass enhancement
256        // (title + favicon + domain). LinkCard's
257        // `<a class="moss-grid-card link-preview">` shape would skip
258        // the post-pass (tag != "div" guard) and lose the title row.
259        //
260        // Mirrors the pre-PR4.5 carve-out in
261        // `crate::build::markdown::typed_renderers::render_compound_link_cell`
262        // (the `if !inner.contains('!') && !inner.contains('[') && !inner.contains('\n')`
263        // branch).
264        let inner_is_plain_text = !inner_trimmed.contains('!')
265            && !inner_trimmed.contains('[')
266            && !inner_trimmed.contains('\n');
267        let is_external = url.starts_with("http://") || url.starts_with("https://");
268        if inner_is_plain_text && is_external {
269            // Re-emit as standard markdown link inside a paragraph so the
270            // grid_post post-pass owns the rendering.
271            let linkified = format!("[{}]({})", inner_trimmed, url);
272            return super::parser::parse(&linkified).blocks;
273        }
274        let inner_doc = super::parser::parse(inner_trimmed);
275        return vec![Block::LinkCard {
276            url: Url::unresolved(url),
277            children: inner_doc.blocks,
278        }];
279    }
280    // Phase 4 PR4.5 (2026-05-28): bare-URL cell auto-promotion. When the
281    // entire cell content is a single bare URL on its own line (no
282    // markdown link syntax), parse it as `[](URL)` so the cell renders as
283    // `<p><a href="URL"></a></p>` (an empty-text link inside a paragraph).
284    // The grid-render post-pass in `build/render/grid_post.rs` detects
285    // this shape and replaces with a `<span class="link-preview-domain">…</span>`
286    // wrapper carrying title/favicon (from cached link metadata).
287    //
288    // Matches the pre-PR4.5 `linkify_bare_urls_in_cell` behavior — the
289    // helper turned `https://...` into `[](https://...)` so the downstream
290    // compound-link pass picked it up. PR4.5 ports the linkification to
291    // parse time so the bytes flow through the typed AST.
292    if let Some(url) = detect_bare_url_cell(raw) {
293        let linkified = format!("[]({})", url);
294        let doc = super::parser::parse(&linkified);
295        return doc.blocks;
296    }
297    let doc = super::parser::parse(raw);
298    doc.blocks
299}
300
301/// Detect a "bare URL cell": the entire cell content (after trim) is a
302/// single `https?://...` URL on its own line, with no other content.
303///
304/// Returns the URL string on match, `None` otherwise. Used by
305/// [`parse_cell_to_blocks`] to linkify bare-URL cells via `[](URL)` so
306/// they thread through the grid_post link-preview post-pass like
307/// authored `[Title](URL)` cells.
308fn detect_bare_url_cell(cell_text: &str) -> Option<String> {
309    let trimmed = cell_text.trim();
310    if trimmed.is_empty() {
311        return None;
312    }
313    if trimmed.lines().count() > 1 {
314        return None;
315    }
316    if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
317        return None;
318    }
319    if trimmed.chars().any(char::is_whitespace) {
320        return None;
321    }
322    Some(trimmed.to_string())
323}
324
325/// Detect the compound-link shape in a grid cell's markdown content.
326///
327/// Matches cells whose entire content (after trimming whitespace) begins
328/// with `[` and ends with `](url)`. The inner content may span blank lines
329/// and contain any markdown block syntax (headings, images, paragraphs,
330/// lists, emphasis).
331///
332/// Returns `Some((url, inner_content))` on a match, `None` otherwise.
333///
334/// Ported from src-tauri's `crate::build::markdown::typed_renderers::
335/// detect_compound_link` (Phase 4 PR4.5, 2026-05-28) — the AST-level
336/// equivalent of the same string-level detection. The src-tauri version
337/// is deleted in PR4.5.
338///
339/// Safety rules that cause this function to return `None`:
340/// - Cell contains a top-level code fence (\`\`\` or ~~~).
341/// - Cell content starts with a backtick (inline code on first line).
342/// - The outer `[…](url)` shape cannot be confirmed by bracket-balance
343///   scanning (multiple top-level links, bare `]` / `(` without a pair).
344/// - There is non-whitespace content after the closing `)`.
345///
346/// Detection uses bracket balancing so nested `](` sequences inside images
347/// (`![alt](src)`) or inline code do NOT prematurely end the outer link.
348pub(super) fn detect_compound_link(cell_text: &str) -> Option<(String, String)> {
349    let stripped = cell_text.trim();
350
351    if !stripped.starts_with('[') {
352        return None;
353    }
354    if !stripped.ends_with(')') {
355        return None;
356    }
357    if stripped.len() > 1 && stripped.as_bytes()[1] == b'`' {
358        return None;
359    }
360
361    for line in stripped.lines() {
362        let t = line.trim();
363        if t.starts_with("```") || t.starts_with("~~~") {
364            return None;
365        }
366    }
367
368    let bytes = stripped.as_bytes();
369
370    // Phase 1: find the outer closing `]` via bracket-balance scan.
371    let mut i: usize = 1;
372    let mut depth: usize = 1;
373    let mut outer_close: Option<usize> = None;
374
375    while i < bytes.len() {
376        match bytes[i] {
377            b'\\' => {
378                i += 2;
379                continue;
380            }
381            b'`' => {
382                let tick_start = i;
383                while i < bytes.len() && bytes[i] == b'`' {
384                    i += 1;
385                }
386                let fence_len = i - tick_start;
387                'code_scan: while i < bytes.len() {
388                    if bytes[i] == b'`' {
389                        let close_start = i;
390                        while i < bytes.len() && bytes[i] == b'`' {
391                            i += 1;
392                        }
393                        if i - close_start == fence_len {
394                            break 'code_scan;
395                        }
396                    } else {
397                        i += 1;
398                    }
399                }
400                continue;
401            }
402            b'[' => {
403                depth += 1;
404            }
405            b']' => {
406                depth -= 1;
407                if depth == 0 {
408                    outer_close = Some(i);
409                    break;
410                }
411            }
412            _ => {}
413        }
414        i += 1;
415    }
416
417    let close_bracket = outer_close?;
418
419    if bytes.get(close_bracket + 1) != Some(&b'(') {
420        return None;
421    }
422
423    // Phase 2: find the matching `)` with paren balance.
424    let mut j = close_bracket + 2;
425    let mut pdepth: usize = 1;
426    let mut paren_close: Option<usize> = None;
427
428    while j < bytes.len() {
429        match bytes[j] {
430            b'\\' => {
431                j += 2;
432                continue;
433            }
434            b'(' => pdepth += 1,
435            b')' => {
436                pdepth -= 1;
437                if pdepth == 0 {
438                    paren_close = Some(j);
439                    break;
440                }
441            }
442            _ => {}
443        }
444        j += 1;
445    }
446
447    let close_paren = paren_close?;
448
449    // Phase 3: after `)`, only whitespace/blank lines.
450    let tail = &stripped[close_paren + 1..];
451    if !tail.chars().all(|c| c.is_whitespace()) {
452        return None;
453    }
454
455    // Phase 4: validate inner content.
456    let inner = &stripped[1..close_bracket];
457    if inner.trim().is_empty() {
458        return None;
459    }
460
461    // Phase 5: reject multiple top-level links (images allowed).
462    {
463        let inner_bytes = inner.as_bytes();
464        let mut k: usize = 0;
465        let mut image_stack: Vec<bool> = Vec::new();
466
467        while k < inner_bytes.len() {
468            match inner_bytes[k] {
469                b'\\' => {
470                    k += 2;
471                    continue;
472                }
473                b'`' => {
474                    let tick_start = k;
475                    while k < inner_bytes.len() && inner_bytes[k] == b'`' {
476                        k += 1;
477                    }
478                    let fence_len = k - tick_start;
479                    'inner_code: while k < inner_bytes.len() {
480                        if inner_bytes[k] == b'`' {
481                            let cs = k;
482                            while k < inner_bytes.len() && inner_bytes[k] == b'`' {
483                                k += 1;
484                            }
485                            if k - cs == fence_len {
486                                break 'inner_code;
487                            }
488                        } else {
489                            k += 1;
490                        }
491                    }
492                    continue;
493                }
494                b'[' => {
495                    let preceded_by_bang = k > 0 && inner_bytes[k - 1] == b'!';
496                    image_stack.push(preceded_by_bang);
497                }
498                b']' => {
499                    if let Some(is_image) = image_stack.pop() {
500                        if image_stack.is_empty() && inner_bytes.get(k + 1) == Some(&b'(') {
501                            if !is_image {
502                                return None;
503                            }
504                        }
505                    }
506                }
507                _ => {}
508            }
509            k += 1;
510        }
511    }
512
513    let url = &stripped[close_bracket + 2..close_paren];
514    Some((url.to_string(), inner.to_string()))
515}
516
517/// Split a grid body into cells on lines containing only `+++` (new
518/// grammar) or `---` (legacy moss-releases backward-compat).
519///
520/// Mirrors [`super::cells::split_cells`] but accepts either divider.
521/// Step 3 of #613 rewrites `---` to `+++` in moss-releases content;
522/// after that, this helper retires in favor of `split_cells`.
523///
524/// Returns `(cells, found_legacy_dash)` where `found_legacy_dash` is
525/// `true` when at least one `---` divider was encountered, signaling
526/// the caller to emit a deprecation warning.
527fn split_grid_cells(body: &str) -> (Vec<String>, bool) {
528    if body.is_empty() {
529        return (vec![String::new()], false);
530    }
531    let mut cells = Vec::new();
532    let mut current = String::new();
533    let mut first_line_in_cell = true;
534    let mut found_legacy_dash = false;
535
536    for line in body.split_inclusive('\n') {
537        let content_no_eol = line.strip_suffix('\n').unwrap_or(line);
538        let trimmed = content_no_eol.trim();
539        if trimmed == "+++" || trimmed == "---" {
540            if trimmed == "---" {
541                found_legacy_dash = true;
542            }
543            if let Some(stripped) = current.strip_suffix('\n') {
544                current.truncate(stripped.len());
545            }
546            cells.push(std::mem::take(&mut current));
547            first_line_in_cell = true;
548            continue;
549        }
550        if first_line_in_cell {
551            first_line_in_cell = false;
552            if trimmed.is_empty() {
553                continue;
554            }
555        }
556        current.push_str(line);
557    }
558    if let Some(stripped) = current.strip_suffix('\n') {
559        current.truncate(stripped.len());
560    }
561    cells.push(current);
562    (cells, found_legacy_dash)
563}
564
565/// Parse a `:::hero` block in any of three syntactic forms.
566///
567/// Image source priority:
568/// 1. `image=path` attribute in the `{...}` block (new grammar).
569/// 2. **Directive-line path**: `:::hero ./path.jpg` or
570///    `:::hero ./path.jpg|attrs` or `:::hero ./path.jpg {.classes}` —
571///    moss-releases / client-site backward-compat. The path appears as
572///    raw text before any `{...}` attribute block.
573/// 3. **Body-image fallback**: scan first non-empty body line for a
574///    media reference (`![[path|attrs]]`, `![alt](path|attrs)`, or
575///    bare media filename). Step 3 of the grammar migration rewrites
576///    these to use the `image=` attribute.
577/// 4. None — renderer emits a `<section>` with no `<img>`.
578///
579/// Returns `(HeroShortcode, bool)` where the bool is `true` when the
580/// body-image fallback (Priority 3) fired, signaling the caller to
581/// emit a deprecation warning.
582fn parse_hero(args: &str, body: &str) -> (HeroShortcode, bool) {
583    let trimmed_args = args.trim();
584
585    // Split args on the first `{` to separate the directive-line path
586    // (if any) from the attribute block (if any).
587    let (positional, attr_block): (&str, &str) = if let Some(pos) = trimmed_args.find('{') {
588        // char-aligned: pos points to ASCII '{' from str::find — safe to slice.
589        #[allow(clippy::string_slice)]
590        (trimmed_args[..pos].trim(), &trimmed_args[pos..])
591    } else {
592        (trimmed_args, "")
593    };
594
595    // Parse the attribute block, if present.
596    let parsed = if attr_block.is_empty() {
597        Default::default()
598    } else {
599        super::attrs::parse_attrs(attr_block).unwrap_or_default()
600    };
601    let classes = parsed.class_string();
602    let width = parsed.width.map(str::to_string);
603    let mobile = parsed.get("mobile").map(str::to_string);
604
605    // Priority 1: `image=` attribute.
606    if let Some(image_value) = parsed.get("image") {
607        let (path, attrs_str) = crate::media::split_pipe(image_value);
608        let overlay_text = body.trim().to_string();
609        let overlay = parse_overlay_to_blocks(&overlay_text);
610        return (
611            HeroShortcode {
612                image: if path.trim().is_empty() {
613                    None
614                } else {
615                    Some(Url::unresolved(path.trim().to_string()))
616                },
617                attrs: attrs_str.to_string(),
618                classes,
619                overlay,
620                overlay_text,
621                width,
622                mobile,
623            },
624            false,
625        );
626    }
627
628    // Priority 2: directive-line path (legacy syntax). When the
629    // positional text is non-empty, treat it as the image path with
630    // optional `|attrs` pipe suffix. Body becomes pure overlay markdown.
631    if !positional.is_empty() {
632        let (path, attrs_str) = crate::media::split_pipe(positional);
633        let overlay_text = body.trim().to_string();
634        let overlay = parse_overlay_to_blocks(&overlay_text);
635        return (
636            HeroShortcode {
637                image: if path.trim().is_empty() {
638                    None
639                } else {
640                    Some(Url::unresolved(path.trim().to_string()))
641                },
642                attrs: attrs_str.to_string(),
643                classes,
644                overlay,
645                overlay_text,
646                width,
647                mobile,
648            },
649            false,
650        );
651    }
652
653    // Priority 3: body-image fallback. Scan first non-empty line.
654    let mut overlay_lines: Vec<&str> = Vec::new();
655    let mut image_path: Option<String> = None;
656    let mut image_attrs = String::new();
657    let mut found_image = false;
658    let mut used_priority_3 = false;
659    for line in body.lines() {
660        if !found_image && !line.trim().is_empty() {
661            if let Some((path, attrs_str)) = parse_hero_media_line(line) {
662                image_path = Some(path);
663                image_attrs = attrs_str;
664                found_image = true;
665                used_priority_3 = true;
666                continue;
667            }
668            // First non-empty line wasn't a media reference — keep it as overlay.
669            found_image = true;
670        }
671        overlay_lines.push(line);
672    }
673    let overlay_text = overlay_lines.join("\n").trim().to_string();
674    let overlay = parse_overlay_to_blocks(&overlay_text);
675    (
676        HeroShortcode {
677            image: image_path.map(Url::unresolved),
678            attrs: image_attrs,
679            classes,
680            overlay,
681            overlay_text,
682            width,
683            mobile,
684        },
685        used_priority_3,
686    )
687}
688
689/// Parse a hero overlay's raw markdown source into `Vec<Block>`.
690///
691/// Phase 4 PR4.5 (2026-05-28): mirrors `parse_cell_to_blocks` for the
692/// grid-cell path but without compound-link detection (an overlay is not
693/// a compound-link surface; the SoCiviC pattern is grid-cell-specific).
694/// Returns an empty vec when the overlay is empty.
695fn parse_overlay_to_blocks(raw: &str) -> Vec<Block> {
696    if raw.is_empty() {
697        return Vec::new();
698    }
699    let doc = super::parser::parse(raw);
700    doc.blocks
701}
702
703/// File extensions recognized as media for hero body-image fallback.
704const HERO_MEDIA_EXTENSIONS: &[&str] = &[
705    "jpg", "jpeg", "png", "gif", "webp", "avif", "svg", "mp4", "webm", "mov",
706];
707
708fn is_bare_hero_media(s: &str) -> bool {
709    let (path_part, _) = crate::media::split_pipe(s);
710    let path = path_part.trim();
711    path.rfind('.')
712        .map(|dot| {
713            // char-aligned: dot points to ASCII '.' from str::rfind — `dot + 1`
714            // lands on the byte after '.', which is also a char boundary.
715            #[allow(clippy::string_slice)]
716            let ext = &path[dot + 1..];
717            HERO_MEDIA_EXTENSIONS
718                .iter()
719                .any(|e| e.eq_ignore_ascii_case(ext))
720        })
721        .unwrap_or(false)
722}
723
724/// Parse a line as a media reference. Returns `(path, attrs_str)`.
725fn parse_hero_media_line(line: &str) -> Option<(String, String)> {
726    let trimmed = line.trim();
727
728    // Wikilink embed: ![[path|attrs]]
729    if let Some(inner) = trimmed
730        .strip_prefix("![[")
731        .and_then(|s| s.strip_suffix("]]"))
732    {
733        let (path, attrs_str) = crate::media::split_pipe(inner);
734        return Some((path.trim().to_string(), attrs_str.to_string()));
735    }
736
737    // Standard markdown image: ![alt](path|attrs)
738    if trimmed.starts_with("![") {
739        if let Some(paren_open) = trimmed.find("](") {
740            if trimmed.ends_with(')') {
741                // char-aligned: paren_open points to ASCII "](" from str::find
742                // (paren_open + 2 lands on first byte after `](`, char boundary);
743                // `trimmed.len() - 1` is the byte before the trailing ASCII ')'.
744                #[allow(clippy::string_slice)]
745                let inner = &trimmed[paren_open + 2..trimmed.len() - 1];
746                let (path, attrs_str) = crate::media::split_pipe(inner);
747                return Some((path.trim().to_string(), attrs_str.to_string()));
748            }
749        }
750    }
751
752    // Bare media filename: photo.jpg or photo.jpg|contain
753    if is_bare_hero_media(trimmed) {
754        let (path, attrs_str) = crate::media::split_pipe(trimmed);
755        return Some((path.trim().to_string(), attrs_str.to_string()));
756    }
757
758    None
759}
760
761fn parse_gallery_body(args: &str, body: &str) -> GalleryShortcode {
762    // Args: `N {.classes width}` where N is optional columns count and
763    // `width` is one of the spec § P9 width tokens (handled inside
764    // `split_positional_and_classes`).
765    let (positional, classes, width) = split_positional_classes_and_width(args);
766    let columns = if positional.is_empty() {
767        None
768    } else {
769        positional.parse::<u32>().ok()
770    };
771    let mut items: Vec<GalleryItem> = Vec::new();
772    for line in body.lines() {
773        let trimmed = line.trim();
774        if trimmed.is_empty() {
775            continue;
776        }
777        // Each line: `path|attrs`, `![alt](path)|attrs`, or bare `path`.
778        // The pipe split (if any) is BEFORE the markdown-image pattern check.
779        let (src_raw, attrs) = split_pipe(trimmed);
780        let (src_url, alt) = match parse_markdown_image(src_raw) {
781            Some((alt, path)) => (path, alt),
782            None => (src_raw.trim().to_string(), String::new()),
783        };
784        items.push(GalleryItem {
785            src: Url::unresolved(src_url),
786            alt,
787            attrs: attrs.to_string(),
788        });
789    }
790    GalleryShortcode {
791        columns,
792        classes,
793        items,
794        width,
795    }
796}
797
798/// Split `args` into `(positional_text, classes, width)`.
799///
800/// Same routing as [`split_positional_and_classes`], but also surfaces the
801/// spec § P9 width token (`body | wide | page | screen`, with `full`
802/// aliased to `screen`). Returns `width = None` when the author did not
803/// set one, or when the legacy fallback path fires (malformed attrs
804/// where the structured parser bailed).
805fn split_positional_classes_and_width(args: &str) -> (String, String, Option<String>) {
806    let trimmed = args.trim();
807    if let Some(brace_start) = trimmed.find('{') {
808        #[allow(clippy::string_slice)]
809        let after_open = &trimmed[brace_start..];
810        if let Some(brace_end) = after_open.find('}') {
811            #[allow(clippy::string_slice)]
812            let positional = trimmed[..brace_start].trim().to_string();
813            #[allow(clippy::string_slice)]
814            let attr_block_str = &trimmed[brace_start..=brace_start + brace_end];
815            if let Ok(parsed) = super::attrs::parse_attrs(attr_block_str) {
816                return (
817                    positional,
818                    parsed.class_string(),
819                    parsed.width.map(str::to_string),
820                );
821            }
822            // Legacy fallback for malformed inputs: scan only for `.class`.
823            // Width tokens are skipped here on purpose — if attrs are
824            // malformed enough to bail, the author's intent is unclear and
825            // omitting the width is safer than guessing.
826            #[allow(clippy::string_slice)]
827            let inner = &trimmed[brace_start + 1..brace_start + brace_end];
828            let mut classes = Vec::new();
829            for token in inner.split_whitespace() {
830                if let Some(class) = token.strip_prefix('.') {
831                    if !class.is_empty() {
832                        classes.push(class);
833                    }
834                }
835            }
836            return (positional, classes.join(" "), None);
837        }
838    }
839    (trimmed.to_string(), String::new(), None)
840}
841
842/// Split `args` into `(positional_text, classes)` from `{...}` syntax.
843///
844/// Routes the attribute portion through [`crate::ast::attrs::parse_attrs`]
845/// so the unified grammar's full surface (`.class`, `#id`, `key=value`,
846/// quoted values, multi-line) is recognized — even though the legacy
847/// shortcodes (Subscribe / Buttons / Gallery) only consume the class
848/// list today. Step 2 migrates Hero / Grid; once they read `kvs` and
849/// `id` via `parse_attrs` directly, this helper retires.
850///
851/// Falls back to the legacy whitespace-tokenized class scan when
852/// `parse_attrs` returns `Err` (malformed attrs, unterminated quote,
853/// etc.) so existing content with edge-case `{}` shapes still parses
854/// the way it did before.
855fn split_positional_and_classes(args: &str) -> (String, String) {
856    let trimmed = args.trim();
857    if let Some(brace_start) = trimmed.find('{') {
858        // char-aligned: brace_start points to ASCII '{' from str::find — the
859        // byte index is a char boundary, so slicing `trimmed[brace_start..]`
860        // is safe to feed into the next find.
861        #[allow(clippy::string_slice)]
862        let after_open = &trimmed[brace_start..];
863        if let Some(brace_end) = after_open.find('}') {
864            // char-aligned: brace_start (ASCII '{') and brace_start+brace_end
865            // (ASCII '}') are both char boundaries; `brace_start + 1` lands on
866            // the byte after '{', also a boundary.
867            #[allow(clippy::string_slice)]
868            let positional = trimmed[..brace_start].trim().to_string();
869            #[allow(clippy::string_slice)]
870            let attr_block_str = &trimmed[brace_start..=brace_start + brace_end];
871            if let Ok(parsed) = super::attrs::parse_attrs(attr_block_str) {
872                return (positional, parsed.class_string());
873            }
874            // Legacy fallback for malformed inputs that the structured
875            // parser rejects (e.g. unterminated quote on a single line).
876            #[allow(clippy::string_slice)]
877            let inner = &trimmed[brace_start + 1..brace_start + brace_end];
878            let mut classes = Vec::new();
879            for token in inner.split_whitespace() {
880                if let Some(class) = token.strip_prefix('.') {
881                    if !class.is_empty() {
882                        classes.push(class);
883                    }
884                }
885            }
886            return (positional, classes.join(" "));
887        }
888    }
889    (trimmed.to_string(), String::new())
890}
891
892/// Split `s` on `|` into `(before, after)`. If no pipe, returns `(s, "")`.
893fn split_pipe(s: &str) -> (&str, &str) {
894    match s.split_once('|') {
895        Some((before, after)) => (before, after.trim()),
896        None => (s, ""),
897    }
898}
899
900/// Parse `![alt](path)` into `(alt, path)`. Returns `None` if not a
901/// markdown image. Mirrors the legacy parser at shortcode.rs:1615.
902fn parse_markdown_image(s: &str) -> Option<(String, String)> {
903    let s = s.trim();
904    let rest = s.strip_prefix("![")?;
905    let (alt, after) = rest.split_once("](")?;
906    let close_paren = after.rfind(')')?;
907    // char-aligned: close_paren points to ASCII ')' from str::rfind.
908    #[allow(clippy::string_slice)]
909    let path = &after[..close_paren];
910    if path.contains('(') {
911        return None;
912    }
913    Some((alt.to_string(), path.to_string()))
914}
915
916fn parse_buttons_body(args: &str, body: &str) -> ButtonsShortcode {
917    let (_positional, classes) = split_positional_and_classes(args);
918    let mut items: Vec<ButtonItem> = Vec::new();
919    // Split the body on `+++` cell dividers (unified grammar).
920    // Bodies without `+++` produce a single cell containing the entire
921    // body — backward-compatible with the legacy "one link per line"
922    // shape.
923    for cell in split_cells(body) {
924        for line in cell.lines() {
925            let trimmed = line.trim();
926            if trimmed.is_empty() {
927                continue;
928            }
929            if let Some((text, url)) = extract_markdown_link(trimmed) {
930                items.push(ButtonItem {
931                    text,
932                    url: Url::unresolved(url),
933                });
934            }
935            // Non-link lines silently ignored (matches legacy behavior).
936        }
937    }
938    ButtonsShortcode { classes, items }
939}
940
941/// Extract a markdown link `[text](url)` from a single trimmed line.
942/// Returns `(text, url)` if the line is a single link, else `None`.
943fn extract_markdown_link(s: &str) -> Option<(String, String)> {
944    let s = s.trim();
945    let inside = s.strip_prefix('[')?;
946    let (text, after) = inside.split_once(']')?;
947    let url = after.strip_prefix('(').and_then(|r| r.strip_suffix(')'))?;
948    if url.is_empty() {
949        return None;
950    }
951    Some((text.to_string(), url.to_string()))
952}
953
954/// Parse `:::subscribe {placeholder="..." button="..."}` into a typed struct.
955///
956/// Reads `placeholder` and `button` from the attribute block; ignores
957/// classes/id (the renderer uses fixed `moss-subscribe` chrome). Body
958/// must be empty under the unified grammar — caller is responsible for
959/// surfacing a deprecation warning if non-empty.
960fn parse_subscribe_args(args: &str) -> SubscribeShortcode {
961    // Empty args produce an empty AttrBlock; both fields stay None
962    // and the renderer falls back to language defaults.
963    let parsed = match super::attrs::parse_attrs(args) {
964        Ok(b) => b,
965        Err(_) => return SubscribeShortcode::default(),
966    };
967    let placeholder = parsed
968        .get("placeholder")
969        .filter(|s| !s.is_empty())
970        .map(str::to_string);
971    let button = parsed
972        .get("button")
973        .filter(|s| !s.is_empty())
974        .map(str::to_string);
975    SubscribeShortcode {
976        placeholder,
977        button,
978    }
979}
980
981/// Parse `:::apply {placeholder="..." button="..."}` into a typed struct.
982///
983/// Reads `placeholder` and `button` from the attribute block; ignores
984/// classes/id (the renderer uses fixed `moss-apply` chrome). Body must be
985/// empty under the unified grammar. Mirrors `parse_subscribe_args`.
986pub fn parse_apply_args(args: &str) -> ApplyShortcode {
987    let parsed = match super::attrs::parse_attrs(args) {
988        Ok(b) => b,
989        Err(_) => return ApplyShortcode::default(),
990    };
991    let placeholder = parsed
992        .get("placeholder")
993        .filter(|s| !s.is_empty())
994        .map(str::to_string);
995    let button = parsed
996        .get("button")
997        .filter(|s| !s.is_empty())
998        .map(str::to_string);
999    ApplyShortcode {
1000        placeholder,
1001        button,
1002    }
1003}
1004
1005/// The sentinel HTML comment used to mark an extracted shortcode in the
1006/// markdown source. Pulldown-cmark emits these as [`Event::Html`] inside
1007/// a [`Tag::HtmlBlock`], which surfaces as [`Block::Other`] in our AST.
1008///
1009/// `nonce` is the per-extraction hash from [`ExtractionResult::nonce`],
1010/// which forecloses the namespace-collision case where an author writes
1011/// `<!--MOSS_SC_*-->` literally in their markdown.
1012pub fn placeholder_for(nonce: &str, index: usize) -> String {
1013    format!("<!--MOSS_SC_{nonce}_{index}-->")
1014}
1015
1016/// Try to interpret a [`Block::Other`] payload as a shortcode placeholder
1017/// matching the given `nonce`. Returns the `index` if it matches.
1018///
1019/// Any sentinel with a different (or absent) nonce is rejected — that's
1020/// what makes authored content with a similar comment shape inert.
1021pub fn parse_placeholder(nonce: &str, html: &str) -> Option<usize> {
1022    let trim = html.trim();
1023    let prefix = format!("<!--MOSS_SC_{nonce}_");
1024    let inner = trim.strip_prefix(&prefix)?;
1025    let inner = inner.strip_suffix("-->")?;
1026    inner.parse::<usize>().ok()
1027}
1028
1029/// Compute the per-extraction nonce from the input markdown. Uses
1030/// `std::hash::DefaultHasher` (FxHash-like; not cryptographic, but good
1031/// enough to make a literal authored-content collision computationally
1032/// improbable for any short input). Returns 8 hex characters.
1033fn compute_nonce(input: &str) -> String {
1034    use std::hash::{Hash, Hasher};
1035    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1036    input.hash(&mut hasher);
1037    // Truncate to 32 bits for an 8-char hex; collisions across two
1038    // sites are not a concern (each extraction uses its own nonce
1039    // for its own substitution). Per-extraction collision-resistance
1040    // requires only that the nonce differs from any literal string
1041    // in the same input — 32 bits is overkill for that.
1042    let h = hasher.finish() as u32;
1043    format!("{h:08x}")
1044}
1045
1046/// Walk the markdown line-by-line, replace `:::name` blocks with sentinels.
1047///
1048/// Tracks fenced code blocks (` ``` ` and `~~~`) so `:::buttons` inside a
1049/// code fence stays inert. Currently recognizes `:::subscribe`; other
1050/// shortcodes are added in Phase B Tasks 8-11. Unrecognized `:::name`
1051/// blocks pass through verbatim (the legacy string-rewriter still
1052/// processes them during the staged migration).
1053pub fn extract_shortcodes(markdown: &str) -> ExtractionResult {
1054    let nonce = compute_nonce(markdown);
1055    let mut extracted: Vec<ExtractedShortcode> = Vec::new();
1056    let mut warnings: Vec<String> = Vec::new();
1057    let output = extract_with_state(markdown, &nonce, &mut extracted, &mut warnings);
1058    ExtractionResult {
1059        markdown_with_placeholders: output,
1060        extracted,
1061        nonce,
1062        warnings,
1063    }
1064}
1065
1066/// Recursive worker for [`extract_shortcodes`]. Walks `markdown`
1067/// line-by-line and returns the body string with sentinels substituted
1068/// for typed shortcode blocks. Inner CssRegion / Unknown blocks recurse
1069/// here so their bodies also get scanned for typed shortcodes — the
1070/// shared `extracted` and `warnings` accumulators ensure all sentinels
1071/// across nesting levels share the same nonce and a flat index space.
1072fn extract_with_state(
1073    markdown: &str,
1074    nonce: &str,
1075    extracted: &mut Vec<ExtractedShortcode>,
1076    warnings: &mut Vec<String>,
1077) -> String {
1078    let mut output = String::with_capacity(markdown.len());
1079    let lines: Vec<&str> = markdown.lines().collect();
1080    let mut i = 0;
1081    let mut in_code_fence = false;
1082    let mut fence_marker = String::new();
1083
1084    while i < lines.len() {
1085        let line = lines[i];
1086        let trimmed = line.trim();
1087
1088        // Track code fences first; do not parse shortcodes inside them.
1089        if in_code_fence {
1090            output.push_str(line);
1091            output.push('\n');
1092            // `fence_marker` is set non-empty by `detect_code_fence_open`
1093            // when we entered this state, so `chars().next()` returns
1094            // `Some` in practice. `unwrap_or(' ')` is a safe degenerate
1095            // fallback: a literal space could only match a fence-close
1096            // line if the trimmed line *was* spaces, but `trimmed` has
1097            // already had its surrounding whitespace stripped, so the
1098            // is_empty check would still reject it.
1099            let fence_char = fence_marker.chars().next().unwrap_or(' ');
1100            if trimmed.starts_with(&fence_marker)
1101                && trimmed.trim_start_matches(fence_char).trim().is_empty()
1102            {
1103                in_code_fence = false;
1104                fence_marker.clear();
1105            }
1106            i += 1;
1107            continue;
1108        }
1109        if let Some(marker) = detect_code_fence_open(trimmed) {
1110            in_code_fence = true;
1111            fence_marker = marker;
1112            output.push_str(line);
1113            output.push('\n');
1114            i += 1;
1115            continue;
1116        }
1117
1118        // Try to recognize a `:::name` (or `::::name`, etc.) opener.
1119        if let Some((arity, name, single_line_args)) = parse_shortcode_opener(trimmed) {
1120            // Multi-line attribute block support: if the args contain an
1121            // unclosed `{`, gather subsequent lines into the args string
1122            // until the brace closes (respecting quoted strings). The
1123            // body starts on the line AFTER the close-brace line.
1124            //
1125            // `:::name {key=value\n  key2=value2\n}` is valid; the
1126            // attribute parser sees the joined string and treats newlines
1127            // as whitespace.
1128            let (args_owned, opener_lines_consumed) =
1129                gather_multi_line_attrs(single_line_args, &lines[i + 1..]);
1130            let args: &str = args_owned.as_deref().unwrap_or(single_line_args);
1131            let body_start = i + 1 + opener_lines_consumed;
1132
1133            // Look for the matching closer (same arity) on a subsequent line.
1134            let mut body_lines: Vec<&str> = Vec::new();
1135            let mut j = body_start;
1136            let mut closed = false;
1137            while j < lines.len() {
1138                if is_close_fence(lines[j].trim(), arity) {
1139                    closed = true;
1140                    break;
1141                }
1142                body_lines.push(lines[j]);
1143                j += 1;
1144            }
1145
1146            if !closed {
1147                // Unclosed block: emit verbatim, let the legacy rewriter
1148                // surface the syntax error.
1149                output.push_str(line);
1150                output.push('\n');
1151                i += 1;
1152                continue;
1153            }
1154
1155            let body = body_lines.join("\n");
1156
1157            // Branch on the recognized name:
1158            //
1159            // 1. Pure-CSS region (empty name, e.g. `:::{.tagline}`) — emit
1160            //    a plain `<div class="...">` wrapper around the body markdown.
1161            //    Pulldown-cmark processes the body naturally because we
1162            //    insert blank lines around it.
1163            //
1164            // 2. Typed-known name (subscribe / buttons / gallery / hero / grid)
1165            //    — extract into the typed AST and substitute a sentinel.
1166            //    Parse-time deprecation warnings (e.g. legacy `---` dividers
1167            //    in grid, body-image fallback in hero) are threaded back via
1168            //    the warnings vector.
1169            //
1170            // 3. Anything else — render as a `moss-unknown-shortcode` div
1171            //    around the body markdown and emit a build warning.
1172            if name.is_empty() {
1173                // CssRegion (Task D). Recurse into the body so typed
1174                // shortcodes nested inside the styling wrapper (the
1175                // common SoCiviC pattern of `:::{.support-band}` around
1176                // `::::buttons`) also get extracted into sentinels.
1177                // Higher-arity inner blocks survive because the outer
1178                // closer-search only matches the outer's exact arity;
1179                // the recursive call then handles the inner.
1180                let parsed = super::attrs::parse_attrs(args).unwrap_or_default();
1181                let body_processed = extract_with_state(&body, nonce, extracted, warnings);
1182                output.push_str(&render_div_open(&parsed.classes, parsed.id.as_deref(), None));
1183                output.push_str("\n\n");
1184                output.push_str(&body_processed);
1185                if !body_processed.is_empty() && !body_processed.ends_with('\n') {
1186                    output.push('\n');
1187                }
1188                output.push_str("\n</div>\n");
1189                i = j + 1;
1190                continue;
1191            }
1192
1193            if is_typed_known(name) {
1194                if let (Some(sc), parse_warnings) = parse_shortcode_block(name, args, &body) {
1195                    warnings.extend(parse_warnings);
1196                    let index = extracted.len();
1197                    output.push_str(&placeholder_for(&nonce, index));
1198                    output.push('\n');
1199                    // Preserve the block's original line count. The block spanned
1200                    // lines i..=j (opener..closer); the sentinel is a single line,
1201                    // so pad with (j - i) blank lines. This keeps the post-
1202                    // extraction LineLookup (parser.rs) line-accurate: without it,
1203                    // a multi-line shortcode (grid/hero) collapses to one line and
1204                    // every data-source-line AFTER it drifts, breaking editor↔
1205                    // preview scroll sync (the home page grid scrolled the preview
1206                    // to the bottom). Trailing blank lines after the sentinel HTML
1207                    // comment produce no pulldown-cmark events, so the AST is
1208                    // unchanged. See docs/architecture/editor-preview-sync.md.
1209                    for _ in 0..(j - i) {
1210                        output.push('\n');
1211                    }
1212                    extracted.push(ExtractedShortcode {
1213                        index,
1214                        shortcode: sc,
1215                    });
1216                    i = j + 1;
1217                    continue;
1218                }
1219                // Should not happen — typed-known is a closed set of
1220                // names handled by parse_shortcode_block. Fall through
1221                // to verbatim emission as defense-in-depth.
1222                output.push_str(line);
1223                output.push('\n');
1224                i += 1;
1225                continue;
1226            }
1227
1228            // Unknown name (Task E): wrap the body in a fallback div and
1229            // emit a build warning so authors see misspellings. Recurse
1230            // into the body so a misspelled outer doesn't strand any
1231            // valid typed shortcodes nested inside it.
1232            let parsed = super::attrs::parse_attrs(args).unwrap_or_default();
1233            warnings.push(format!("unknown shortcode `:::{}`", name));
1234            let mut classes = vec!["moss-unknown-shortcode".to_string()];
1235            classes.extend(parsed.classes.iter().cloned());
1236            let extra_attrs = format!(r#" data-name="{}""#, html_escape_attr(name));
1237            let body_processed = extract_with_state(&body, nonce, extracted, warnings);
1238            output.push_str(&render_div_open(&classes, parsed.id.as_deref(), Some(&extra_attrs)));
1239            output.push_str("\n\n");
1240            output.push_str(&body_processed);
1241            if !body_processed.is_empty() && !body_processed.ends_with('\n') {
1242                output.push('\n');
1243            }
1244            output.push_str("\n</div>\n");
1245            i = j + 1;
1246            continue;
1247        }
1248
1249        // Regular content line.
1250        output.push_str(line);
1251        output.push('\n');
1252        i += 1;
1253    }
1254
1255    output
1256}
1257
1258/// Render the opening `<div>` tag for a CssRegion or Unknown wrapper.
1259///
1260/// `extra_attrs` (already with leading space) is appended before `>`,
1261/// used by the unknown-name renderer to add `data-name="..."`.
1262fn render_div_open(classes: &[String], id: Option<&str>, extra_attrs: Option<&str>) -> String {
1263    let mut out = String::from("<div");
1264    if !classes.is_empty() {
1265        out.push_str(" class=\"");
1266        for (i, c) in classes.iter().enumerate() {
1267            if i > 0 {
1268                out.push(' ');
1269            }
1270            out.push_str(&html_escape_attr(c));
1271        }
1272        out.push('"');
1273    }
1274    if let Some(id_val) = id {
1275        out.push_str(" id=\"");
1276        out.push_str(&html_escape_attr(id_val));
1277        out.push('"');
1278    }
1279    if let Some(extra) = extra_attrs {
1280        out.push_str(extra);
1281    }
1282    out.push('>');
1283    out
1284}
1285
1286/// HTML-attribute-safe escape. Replaces the five XML special characters
1287/// so attribute values can't break out of `"..."` or close the tag.
1288fn html_escape_attr(s: &str) -> String {
1289    let mut out = String::with_capacity(s.len());
1290    for c in s.chars() {
1291        match c {
1292            '&' => out.push_str("&amp;"),
1293            '<' => out.push_str("&lt;"),
1294            '>' => out.push_str("&gt;"),
1295            '"' => out.push_str("&quot;"),
1296            '\'' => out.push_str("&#39;"),
1297            _ => out.push(c),
1298        }
1299    }
1300    out
1301}
1302
1303fn detect_code_fence_open(trimmed: &str) -> Option<String> {
1304    if trimmed.starts_with("```") {
1305        Some("```".to_string())
1306    } else if trimmed.starts_with("~~~") {
1307        Some("~~~".to_string())
1308    } else {
1309        None
1310    }
1311}
1312
1313/// Parse an opening fence line into (colon_count, name, args). Returns
1314/// `None` if the line is not an opener.
1315///
1316/// Accepts any colon count >= 3 (`:::name`, `::::name`, `:::::name`, ...).
1317/// The colon count is preserved so the closer must match the same arity
1318/// (allows nested shortcodes like `::::buttons` inside `:::grid`).
1319///
1320/// **Pure-CSS region opener** — `:::{.class}` (no name, attrs only) is
1321/// also recognized. The returned `name` is empty, signaling the caller
1322/// to render the block as a plain styling wrapper. Empty name without
1323/// a following `{` is rejected (just colons followed by content is not
1324/// an opener).
1325fn parse_shortcode_opener(trimmed: &str) -> Option<(usize, &str, &str)> {
1326    let colons = trimmed.chars().take_while(|&c| c == ':').count();
1327    if colons < 3 {
1328        return None;
1329    }
1330    // char-aligned: `colons` is a count of ASCII ':' chars (each 1 byte in
1331    // UTF-8), so the byte offset equals the char count and lands on a
1332    // char boundary.
1333    #[allow(clippy::string_slice)]
1334    let rest = &trimmed[colons..];
1335    // Name = letters/digits/underscores/hyphens; rest of line is args.
1336    let name_end = rest
1337        .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '-'))
1338        .unwrap_or(rest.len());
1339    if name_end == 0 {
1340        // No name. Pure-CSS region grammar requires the rest to start
1341        // with `{` (after whitespace).
1342        let after_ws = rest.trim_start();
1343        if !after_ws.starts_with('{') {
1344            return None;
1345        }
1346        return Some((colons, "", rest.trim()));
1347    }
1348    // char-aligned: name_end is a byte index returned by str::find with a
1349    // char predicate, which is guaranteed to be a char boundary (or rest.len()).
1350    #[allow(clippy::string_slice)]
1351    let name = &rest[..name_end];
1352    #[allow(clippy::string_slice)]
1353    let args = rest[name_end..].trim();
1354    Some((colons, name, args))
1355}
1356
1357/// True if `trimmed` is a closing fence with the specified arity (`:::`
1358/// for arity 3, `::::` for arity 4, etc.).
1359///
1360/// Closer semantics: N colons followed by optional whitespace only. A
1361/// line like `::: extra` is NOT a closer (it's body content). This was
1362/// the legacy `parse_fence_close` contract; the typed extractor preserves
1363/// it so author content with trailing text after `:::` still parses the
1364/// same way.
1365///
1366/// Implemented via char iteration (NOT `split_at(arity)`) because the
1367/// `arity` is a count of `:` characters (always ASCII, 1 byte each), but
1368/// the `trimmed` line might start with multi-byte UTF-8 characters
1369/// (e.g. `[申请测试版](...)` from Chinese-language buttons). `split_at`
1370/// is byte-indexed and would panic mid-character on such lines. Char
1371/// iteration sidesteps the issue and is also slightly faster — we early-exit
1372/// on the first non-`:` character.
1373fn is_close_fence(trimmed: &str, arity: usize) -> bool {
1374    let mut chars = trimmed.chars();
1375    for _ in 0..arity {
1376        match chars.next() {
1377            Some(':') => {}
1378            _ => return false,
1379        }
1380    }
1381    // Remaining chars (if any) must all be whitespace.
1382    chars.all(char::is_whitespace)
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387    use super::*;
1388
1389    #[test]
1390    fn no_shortcodes_round_trips_input() {
1391        let md = "# Heading\n\npara with [link](u).\n";
1392        let result = extract_shortcodes(md);
1393        assert_eq!(result.markdown_with_placeholders, md);
1394        assert!(result.extracted.is_empty());
1395    }
1396
1397    #[test]
1398    fn extracts_subscribe_block_with_placeholder_and_button_attrs() {
1399        let md = r#":::subscribe {placeholder="you@domain.com" button="Sign me up"}
1400:::
1401"#;
1402        let result = extract_shortcodes(md);
1403        assert_eq!(result.extracted.len(), 1);
1404        match &result.extracted[0].shortcode {
1405            Shortcode::Subscribe(args) => {
1406                assert_eq!(args.placeholder.as_deref(), Some("you@domain.com"));
1407                assert_eq!(args.button.as_deref(), Some("Sign me up"));
1408            }
1409            other => panic!("expected Subscribe, got {other:?}"),
1410        }
1411        assert!(result
1412            .markdown_with_placeholders
1413            .contains(&placeholder_for(&result.nonce, 0)));
1414        assert!(!result.markdown_with_placeholders.contains(":::subscribe"));
1415    }
1416
1417    #[test]
1418    fn extracts_subscribe_block_with_only_placeholder_attr() {
1419        let md = r#":::subscribe {placeholder="hi@example.com"}
1420:::
1421"#;
1422        let result = extract_shortcodes(md);
1423        match &result.extracted[0].shortcode {
1424            Shortcode::Subscribe(args) => {
1425                assert_eq!(args.placeholder.as_deref(), Some("hi@example.com"));
1426                assert!(args.button.is_none());
1427            }
1428            other => panic!("expected Subscribe, got {other:?}"),
1429        }
1430    }
1431
1432    #[test]
1433    fn extracts_subscribe_block_with_no_args() {
1434        let md = ":::subscribe\n:::\n";
1435        let result = extract_shortcodes(md);
1436        match &result.extracted[0].shortcode {
1437            Shortcode::Subscribe(args) => {
1438                assert!(args.placeholder.is_none());
1439                assert!(args.button.is_none());
1440            }
1441            other => panic!("expected Subscribe, got {other:?}"),
1442        }
1443    }
1444
1445    #[test]
1446    fn extracts_subscribe_block_with_multi_line_attrs() {
1447        let md = r#":::subscribe {
1448  placeholder="you@domain.com"
1449  button="Request access"
1450}
1451:::
1452"#;
1453        let result = extract_shortcodes(md);
1454        match &result.extracted[0].shortcode {
1455            Shortcode::Subscribe(args) => {
1456                assert_eq!(args.placeholder.as_deref(), Some("you@domain.com"));
1457                assert_eq!(args.button.as_deref(), Some("Request access"));
1458            }
1459            other => panic!("expected Subscribe, got {other:?}"),
1460        }
1461    }
1462
1463    #[test]
1464    fn subscribe_legacy_body_keys_no_longer_parsed() {
1465        // The pre-grammar form `description: ...` / `button: ...` body
1466        // lines are no longer recognized. moss-releases content is
1467        // rewritten in Step 3; this test pins the post-cut behavior.
1468        let md = ":::subscribe\ndescription: Get updates\n:::\n";
1469        let result = extract_shortcodes(md);
1470        match &result.extracted[0].shortcode {
1471            Shortcode::Subscribe(args) => {
1472                assert!(args.placeholder.is_none(), "old description body must not populate placeholder");
1473                assert!(args.button.is_none());
1474            }
1475            other => panic!("expected Subscribe, got {other:?}"),
1476        }
1477    }
1478
1479    #[test]
1480    fn subscribe_inside_code_fence_is_not_extracted() {
1481        // Adversarial: `:::subscribe` inside a fenced code block is just
1482        // documentation text. The extractor must not treat it as a
1483        // shortcode.
1484        let md = "```\n:::subscribe\ndescription: doc\n:::\n```\n";
1485        let result = extract_shortcodes(md);
1486        assert!(result.extracted.is_empty());
1487        assert!(result.markdown_with_placeholders.contains(":::subscribe"));
1488    }
1489
1490    #[test]
1491    fn subscribe_inside_tilde_fence_is_not_extracted() {
1492        let md = "~~~\n:::subscribe\n:::\n~~~\n";
1493        let result = extract_shortcodes(md);
1494        assert!(result.extracted.is_empty());
1495    }
1496
1497    #[test]
1498    fn unclosed_subscribe_block_emits_verbatim() {
1499        // Unclosed: emit source verbatim so the author sees the typo.
1500        let md = ":::subscribe\nbutton: Go\n";
1501        let result = extract_shortcodes(md);
1502        assert!(result.extracted.is_empty());
1503        assert!(result.markdown_with_placeholders.contains(":::subscribe"));
1504    }
1505
1506    #[test]
1507    fn extracts_hero_block_with_body_image_typed() {
1508        // Step 2: :::hero is now a typed variant. The extractor consumes
1509        // it and produces Shortcode::Hero with the body-image fallback
1510        // populating args.image when no `image=` attribute is present.
1511        let md = ":::hero\n![[bg.jpg]]\n:::\n";
1512        let result = extract_shortcodes(md);
1513        assert_eq!(result.extracted.len(), 1);
1514        match &result.extracted[0].shortcode {
1515            Shortcode::Hero(args) => match &args.image {
1516                Some(Url::Unresolved(s)) => assert_eq!(s, "bg.jpg"),
1517                _ => panic!("expected Unresolved bg.jpg"),
1518            },
1519            _ => panic!("expected Hero"),
1520        }
1521        // The literal `:::hero` should be replaced by a sentinel.
1522        assert!(!result.markdown_with_placeholders.contains(":::hero"));
1523    }
1524
1525    #[test]
1526    fn extracts_multiple_subscribes_with_increasing_indices() {
1527        let md = ":::subscribe\ndescription: a\n:::\n\nsome text\n\n:::subscribe\nbutton: b\n:::\n";
1528        let result = extract_shortcodes(md);
1529        assert_eq!(result.extracted.len(), 2);
1530        assert_eq!(result.extracted[0].index, 0);
1531        assert_eq!(result.extracted[1].index, 1);
1532        assert!(result
1533            .markdown_with_placeholders
1534            .contains(&placeholder_for(&result.nonce, 0)));
1535        assert!(result
1536            .markdown_with_placeholders
1537            .contains(&placeholder_for(&result.nonce, 1)));
1538    }
1539
1540    #[test]
1541    fn parse_placeholder_round_trips_index() {
1542        let nonce = "deadbeef";
1543        for index in [0, 1, 5, 99] {
1544            let s = placeholder_for(nonce, index);
1545            assert_eq!(parse_placeholder(nonce, &s), Some(index));
1546        }
1547    }
1548
1549    #[test]
1550    fn parse_placeholder_rejects_non_placeholder_html() {
1551        let nonce = "deadbeef";
1552        assert!(parse_placeholder(nonce, "<div>hi</div>").is_none());
1553        assert!(parse_placeholder(nonce, "<!--just a comment-->").is_none());
1554    }
1555
1556    #[test]
1557    fn parse_placeholder_rejects_wrong_nonce() {
1558        // Authored content collision case: an author writes a literal
1559        // <!--MOSS_SC_*_0--> in their markdown. parse_placeholder requires
1560        // the same nonce as this extraction's session, so a mismatched
1561        // nonce returns None. This forecloses the authored-content
1562        // namespace collision.
1563        let s = placeholder_for("aaaa1111", 5);
1564        assert_eq!(parse_placeholder("bbbb2222", &s), None);
1565    }
1566
1567    #[test]
1568    fn extract_uses_content_derived_nonce() {
1569        // The nonce is deterministic per input — calling extract_shortcodes
1570        // twice on the same input produces the same nonce.
1571        let md = ":::subscribe\n:::\n";
1572        let r1 = extract_shortcodes(md);
1573        let r2 = extract_shortcodes(md);
1574        assert_eq!(r1.nonce, r2.nonce);
1575        // Different inputs produce different nonces (with overwhelming
1576        // probability — collision impossible to exhibit here).
1577        let r3 = extract_shortcodes(":::subscribe\ndescription: x\n:::\n");
1578        assert_ne!(r1.nonce, r3.nonce);
1579    }
1580
1581    #[test]
1582    fn nonce_makes_authored_collision_inert() {
1583        // If an author writes a literal placeholder-shape comment, my
1584        // nonce will differ from theirs, so the substitution leaves
1585        // their text alone.
1586        let md = ":::subscribe\n:::\n\nLook: <!--MOSS_SC_00000000_0-->\n";
1587        let result = extract_shortcodes(md);
1588        // The author's comment survives because the embedded nonce
1589        // differs from the computed one (probability of collision = 1/2^32).
1590        assert_ne!(result.nonce, "00000000");
1591        assert!(result
1592            .markdown_with_placeholders
1593            .contains("MOSS_SC_00000000_0"));
1594    }
1595
1596    #[test]
1597    fn parse_shortcode_opener_recognizes_simple_name() {
1598        assert_eq!(
1599            parse_shortcode_opener(":::subscribe"),
1600            Some((3, "subscribe", ""))
1601        );
1602    }
1603
1604    #[test]
1605    fn parse_shortcode_opener_extracts_args() {
1606        assert_eq!(
1607            parse_shortcode_opener(":::grid 3 1:2:1"),
1608            Some((3, "grid", "3 1:2:1"))
1609        );
1610    }
1611
1612    #[test]
1613    fn parse_shortcode_opener_recognizes_quadruple_colon() {
1614        // ::::buttons is the standard way to nest a shortcode inside
1615        // a :::grid cell. The arity is preserved so the closer matches.
1616        assert_eq!(
1617            parse_shortcode_opener("::::buttons"),
1618            Some((4, "buttons", ""))
1619        );
1620    }
1621
1622    #[test]
1623    fn parse_shortcode_opener_rejects_two_colons() {
1624        // Two colons is not a fence.
1625        assert!(parse_shortcode_opener("::name").is_none());
1626    }
1627
1628    #[test]
1629    fn extracts_quadruple_colon_buttons() {
1630        // ::::buttons inside hypothetical grid context. We just test the
1631        // extractor in isolation; grid integration lands in Task 11.
1632        let md = "::::buttons\n[Tickets](go/)\n::::\n";
1633        let result = extract_shortcodes(md);
1634        assert_eq!(result.extracted.len(), 1);
1635        match &result.extracted[0].shortcode {
1636            Shortcode::Buttons(args) => {
1637                assert_eq!(args.items.len(), 1);
1638                assert_eq!(args.items[0].text, "Tickets");
1639            }
1640            _ => panic!("expected Buttons"),
1641        }
1642    }
1643
1644    #[test]
1645    fn extracts_grid_with_nested_buttons_via_arity() {
1646        // SoCiviC pattern: `::::buttons` (4-colon) nested inside `:::grid`
1647        // (3-colon). Phase 4 PR4.5 (2026-05-28) promoted cells from raw
1648        // markdown strings to `Vec<Vec<Block>>`. The inner `::::buttons`
1649        // now extracts into a typed `Block::Shortcode(Buttons)` inside the
1650        // cell at parse time (via the recursive `super::parser::parse` call
1651        // in `parse_cell_to_blocks`), not at render time.
1652        let md = ":::grid 2\n::::buttons\n[Tickets](go/)\n::::\n+++\nfooter cell\n:::\n";
1653        let result = extract_shortcodes(md);
1654        assert_eq!(result.extracted.len(), 1);
1655        match &result.extracted[0].shortcode {
1656            Shortcode::Grid(grid) => {
1657                assert_eq!(grid.columns, 2);
1658                assert_eq!(grid.cells.len(), 2);
1659                // First cell now carries a typed Shortcode::Buttons block.
1660                let has_typed_buttons = grid.cells[0].iter().any(|b| matches!(
1661                    b,
1662                    Block::Shortcode(Shortcode::Buttons(args)) if args.items.len() == 1
1663                        && args.items[0].text == "Tickets"
1664                ));
1665                assert!(has_typed_buttons, "expected typed Buttons in cell[0]; got {:?}", grid.cells[0]);
1666                // Second cell is the footer paragraph.
1667                let has_footer_para = grid.cells[1].iter().any(|b| matches!(
1668                    b,
1669                    Block::Paragraph(inlines) if inlines.iter().any(|i| matches!(
1670                        i,
1671                        super::super::node::Inline::Text(t) if t.contains("footer cell")
1672                    ))
1673                ));
1674                assert!(has_footer_para, "expected footer paragraph in cell[1]; got {:?}", grid.cells[1]);
1675            }
1676            other => panic!("expected Grid, got {other:?}"),
1677        }
1678        // The literal ::: markers don't survive verbatim — they're in the
1679        // typed Grid's body now.
1680        assert!(!result.markdown_with_placeholders.contains(":::grid 2"));
1681    }
1682
1683    #[test]
1684    fn arity_mismatch_does_not_close_block() {
1685        // A `:::` closer inside a `::::buttons` block must NOT close it.
1686        // Body content can contain `:::` strings as text (in code blocks
1687        // or grid cell separators when nested differently).
1688        let md = "::::buttons\n[t](u)\n:::\n[t2](u2)\n::::\n";
1689        let result = extract_shortcodes(md);
1690        // Only one extraction (the ::::buttons block).
1691        assert_eq!(result.extracted.len(), 1);
1692        match &result.extracted[0].shortcode {
1693            Shortcode::Buttons(args) => {
1694                // Both links should be captured (the `:::` was just body text).
1695                assert_eq!(args.items.len(), 2);
1696            }
1697            _ => panic!("expected Buttons"),
1698        }
1699    }
1700
1701    // ---- Buttons (Phase B Task 8) ----
1702
1703    #[test]
1704    fn extracts_buttons_block_with_one_link() {
1705        let md = ":::buttons\n[Documentation](docs/)\n:::\n";
1706        let result = extract_shortcodes(md);
1707        assert_eq!(result.extracted.len(), 1);
1708        match &result.extracted[0].shortcode {
1709            Shortcode::Buttons(args) => {
1710                assert!(args.classes.is_empty());
1711                assert_eq!(args.items.len(), 1);
1712                assert_eq!(args.items[0].text, "Documentation");
1713                match &args.items[0].url {
1714                    Url::Unresolved(s) => assert_eq!(s, "docs/"),
1715                    _ => panic!("expected Unresolved"),
1716                }
1717            }
1718            _ => panic!("expected Buttons"),
1719        }
1720    }
1721
1722    #[test]
1723    fn extracts_buttons_block_with_multiple_links() {
1724        let md = ":::buttons\n[Docs](docs/)\n[GitHub](https://github.com)\n:::\n";
1725        let result = extract_shortcodes(md);
1726        match &result.extracted[0].shortcode {
1727            Shortcode::Buttons(args) => {
1728                assert_eq!(args.items.len(), 2);
1729                assert_eq!(args.items[0].text, "Docs");
1730                assert_eq!(args.items[1].text, "GitHub");
1731            }
1732            _ => panic!("expected Buttons"),
1733        }
1734    }
1735
1736    #[test]
1737    fn extracts_buttons_block_with_class_attrs() {
1738        let md = ":::buttons {.primary .large}\n[Go](go/)\n:::\n";
1739        let result = extract_shortcodes(md);
1740        match &result.extracted[0].shortcode {
1741            Shortcode::Buttons(args) => {
1742                assert_eq!(args.classes, "primary large");
1743                assert_eq!(args.items.len(), 1);
1744            }
1745            _ => panic!("expected Buttons"),
1746        }
1747    }
1748
1749    #[test]
1750    fn extracts_buttons_with_moss_resolved_url_intact() {
1751        // The upstream resolve pipeline rewrites internal links to
1752        // moss-resolved:foo.md before the AST sees them. The extractor
1753        // must preserve the prefix verbatim — visit_urls_mut classifies it.
1754        let md = ":::buttons\n[Docs](moss-resolved:docs/index.md)\n:::\n";
1755        let result = extract_shortcodes(md);
1756        match &result.extracted[0].shortcode {
1757            Shortcode::Buttons(args) => match &args.items[0].url {
1758                Url::Unresolved(s) => assert_eq!(s, "moss-resolved:docs/index.md"),
1759                _ => panic!("expected Unresolved"),
1760            },
1761            _ => panic!("expected Buttons"),
1762        }
1763    }
1764
1765    #[test]
1766    fn buttons_skips_non_link_lines() {
1767        // Non-link lines (commentary, blank lines) are silently skipped
1768        // — matches the legacy rewriter behavior.
1769        let md = ":::buttons\nNot a link, just text.\n[Real](real/)\n\n:::\n";
1770        let result = extract_shortcodes(md);
1771        match &result.extracted[0].shortcode {
1772            Shortcode::Buttons(args) => {
1773                assert_eq!(args.items.len(), 1);
1774                assert_eq!(args.items[0].text, "Real");
1775            }
1776            _ => panic!("expected Buttons"),
1777        }
1778    }
1779
1780    #[test]
1781    fn buttons_inside_code_fence_is_not_extracted() {
1782        let md = "```\n:::buttons\n[t](u)\n:::\n```\n";
1783        let result = extract_shortcodes(md);
1784        assert!(result.extracted.is_empty());
1785    }
1786
1787    #[test]
1788    fn extract_markdown_link_rejects_text_with_close_bracket() {
1789        // Pinning test (code-review P2): the parser uses find(']') for the
1790        // first close-bracket. A link text containing ']' silently fails
1791        // to parse and the line is skipped (silently — matches legacy
1792        // shortcode.rs::extract_markdown_link). If/when this is relaxed,
1793        // this test fails and the change is deliberate.
1794        let md = ":::buttons\n[a]b](u)\n:::\n";
1795        let result = extract_shortcodes(md);
1796        match &result.extracted[0].shortcode {
1797            Shortcode::Buttons(args) => assert!(args.items.is_empty()),
1798            _ => panic!("expected Buttons"),
1799        }
1800    }
1801
1802    #[test]
1803    fn extract_markdown_link_requires_trailing_paren() {
1804        // Pinning test: trailing content after `)` causes the link to be
1805        // rejected (matches legacy behavior).
1806        let md = ":::buttons\n[t](u) <!-- trailing -->\n:::\n";
1807        let result = extract_shortcodes(md);
1808        match &result.extracted[0].shortcode {
1809            Shortcode::Buttons(args) => assert!(args.items.is_empty()),
1810            _ => panic!("expected Buttons"),
1811        }
1812    }
1813
1814    #[test]
1815    fn close_fence_with_trailing_whitespace_is_recognized() {
1816        // Whitespace after the colons is allowed (matches legacy
1817        // parse_fence_close at shortcode.rs:857).
1818        let md = ":::subscribe\nbutton: x\n:::   \n";
1819        let result = extract_shortcodes(md);
1820        assert_eq!(result.extracted.len(), 1);
1821    }
1822
1823    #[test]
1824    fn is_close_fence_handles_multibyte_utf8_lines() {
1825        // Regression test for the moss-releases panic at byte index 3
1826        // (inside `申`) of `[申请测试版](#青苔正在封闭测试)`. The buggy
1827        // `split_at(arity)` was byte-indexed; this line happens to be
1828        // longer than 3 bytes but the first 3 bytes land mid-character
1829        // because `[` (1 byte) + `申` (3 bytes, bytes 1..4). Char-based
1830        // iteration sidesteps the issue.
1831        assert!(!is_close_fence("[申请测试版](#青苔正在封闭测试)", 3));
1832        assert!(!is_close_fence("[申请测试版](#青苔正在封闭测试)", 4));
1833        // CJK lines that would have panicked the old split_at variant.
1834        assert!(!is_close_fence("中文内容", 3));
1835        assert!(!is_close_fence("日本語", 3));
1836        // Truly closing lines still match.
1837        assert!(is_close_fence(":::", 3));
1838        assert!(is_close_fence("::::", 4));
1839    }
1840
1841    #[test]
1842    fn extract_shortcodes_handles_buttons_with_cjk_link_text() {
1843        // End-to-end regression for the moss-releases site bug: a
1844        // :::buttons block containing a markdown link with CJK text
1845        // and a CJK URL anchor. The extractor must not panic, must
1846        // extract the buttons block, and must capture both items.
1847        let md = ":::buttons\n[申请测试版](#青苔正在封闭测试)\n[文档](docs/)\n:::\n";
1848        let result = extract_shortcodes(md);
1849        assert_eq!(result.extracted.len(), 1);
1850        match &result.extracted[0].shortcode {
1851            Shortcode::Buttons(args) => {
1852                assert_eq!(args.items.len(), 2);
1853                assert_eq!(args.items[0].text, "申请测试版");
1854                match &args.items[0].url {
1855                    Url::Unresolved(s) => assert_eq!(s, "#青苔正在封闭测试"),
1856                    _ => panic!("expected Unresolved"),
1857                }
1858                assert_eq!(args.items[1].text, "文档");
1859            }
1860            _ => panic!("expected Buttons"),
1861        }
1862    }
1863
1864    #[test]
1865    fn extract_shortcodes_does_not_panic_on_arbitrary_cjk_content() {
1866        // Smoke test against the shape that triggered the moss-releases
1867        // panic: a document with mixed CJK content INCLUDING lines that
1868        // start with multi-byte characters but happen to have byte
1869        // length ≥ arity. None of these are close-fence candidates;
1870        // the extractor must scan past them without panic.
1871        let md = "# 标题\n\n中文段落,混合 English 单词。\n\n:::buttons\n[申请测试版](#锚点)\n:::\n\n## 二级标题\n\n更多内容。\n";
1872        let result = extract_shortcodes(md);
1873        assert_eq!(result.extracted.len(), 1);
1874    }
1875
1876    #[test]
1877    fn close_fence_with_trailing_text_is_not_recognized() {
1878        // P1 #2 fix: `::: more text` does NOT close the block. Without
1879        // this match against legacy semantics, an author who pasted text
1880        // after the closer would see different behavior between the
1881        // typed-AST path and the legacy grid parser. Using buttons here
1882        // because subscribe under the unified grammar reads attrs only.
1883        let md = ":::buttons\n[a](u)\n::: more text\n[b](v)\n:::\n";
1884        let result = extract_shortcodes(md);
1885        assert_eq!(result.extracted.len(), 1);
1886        // Both links should be in the buttons body — the first `:::` is
1887        // body content; the second `:::` is the closer.
1888        match &result.extracted[0].shortcode {
1889            Shortcode::Buttons(args) => {
1890                assert_eq!(args.items.len(), 2);
1891                assert_eq!(args.items[0].text, "a");
1892                assert_eq!(args.items[1].text, "b");
1893            }
1894            _ => panic!("expected Buttons"),
1895        }
1896    }
1897
1898    // ---- Gallery (Phase B Task 9) ----
1899
1900    #[test]
1901    fn extracts_gallery_with_bare_paths() {
1902        let md = ":::gallery\nphoto1.jpg\nphoto2.png\n:::\n";
1903        let result = extract_shortcodes(md);
1904        assert_eq!(result.extracted.len(), 1);
1905        match &result.extracted[0].shortcode {
1906            Shortcode::Gallery(args) => {
1907                assert!(args.columns.is_none());
1908                assert_eq!(args.items.len(), 2);
1909                assert_eq!(args.items[0].alt, "");
1910                match &args.items[0].src {
1911                    Url::Unresolved(s) => assert_eq!(s, "photo1.jpg"),
1912                    _ => panic!("expected Unresolved"),
1913                }
1914            }
1915            _ => panic!("expected Gallery"),
1916        }
1917    }
1918
1919    #[test]
1920    fn extracts_gallery_with_columns_arg() {
1921        let md = ":::gallery 4\na.jpg\n:::\n";
1922        let result = extract_shortcodes(md);
1923        match &result.extracted[0].shortcode {
1924            Shortcode::Gallery(args) => assert_eq!(args.columns, Some(4)),
1925            _ => panic!("expected Gallery"),
1926        }
1927    }
1928
1929    #[test]
1930    fn extracts_gallery_with_classes() {
1931        let md = ":::gallery 3 {.showcase}\na.jpg\n:::\n";
1932        let result = extract_shortcodes(md);
1933        match &result.extracted[0].shortcode {
1934            Shortcode::Gallery(args) => {
1935                assert_eq!(args.columns, Some(3));
1936                assert_eq!(args.classes, "showcase");
1937            }
1938            _ => panic!("expected Gallery"),
1939        }
1940    }
1941
1942    #[test]
1943    fn extracts_gallery_with_markdown_image_syntax() {
1944        let md = ":::gallery\n![A photo](photo.jpg)\n:::\n";
1945        let result = extract_shortcodes(md);
1946        match &result.extracted[0].shortcode {
1947            Shortcode::Gallery(args) => {
1948                assert_eq!(args.items[0].alt, "A photo");
1949                match &args.items[0].src {
1950                    Url::Unresolved(s) => assert_eq!(s, "photo.jpg"),
1951                    _ => panic!("expected Unresolved"),
1952                }
1953            }
1954            _ => panic!("expected Gallery"),
1955        }
1956    }
1957
1958    #[test]
1959    fn extracts_gallery_with_pipe_attrs() {
1960        let md = ":::gallery\nphoto.jpg|cover top\n:::\n";
1961        let result = extract_shortcodes(md);
1962        match &result.extracted[0].shortcode {
1963            Shortcode::Gallery(args) => {
1964                assert_eq!(args.items[0].attrs, "cover top");
1965                match &args.items[0].src {
1966                    Url::Unresolved(s) => assert_eq!(s, "photo.jpg"),
1967                    _ => panic!("expected Unresolved"),
1968                }
1969            }
1970            _ => panic!("expected Gallery"),
1971        }
1972    }
1973
1974    #[test]
1975    fn gallery_skips_blank_lines() {
1976        let md = ":::gallery\n\na.jpg\n\nb.jpg\n\n:::\n";
1977        let result = extract_shortcodes(md);
1978        match &result.extracted[0].shortcode {
1979            Shortcode::Gallery(args) => assert_eq!(args.items.len(), 2),
1980            _ => panic!("expected Gallery"),
1981        }
1982    }
1983
1984    // ---- Multi-line attribute blocks (Step 1 Task B) ----
1985    // Low-level brace_depth and gather_multi_line_attrs unit tests live
1986    // in `attrs.rs` next to those helpers. The tests below pin the
1987    // extractor's end-to-end behavior on multi-line attribute blocks.
1988
1989    #[test]
1990    fn extracts_buttons_with_multi_line_attrs() {
1991        // The attribute block spans three source lines; the body starts
1992        // after the closing brace's line.
1993        let md = ":::buttons {\n  .primary\n}\n[Go](go/)\n:::\n";
1994        let result = extract_shortcodes(md);
1995        assert_eq!(result.extracted.len(), 1);
1996        match &result.extracted[0].shortcode {
1997            Shortcode::Buttons(args) => {
1998                assert_eq!(args.classes, "primary");
1999                assert_eq!(args.items.len(), 1);
2000                assert_eq!(args.items[0].text, "Go");
2001            }
2002            _ => panic!("expected Buttons"),
2003        }
2004    }
2005
2006    #[test]
2007    fn extracts_gallery_with_multi_line_attrs() {
2008        let md = ":::gallery {\n  .showcase\n}\nphoto.jpg\n:::\n";
2009        let result = extract_shortcodes(md);
2010        match &result.extracted[0].shortcode {
2011            Shortcode::Gallery(args) => {
2012                assert_eq!(args.classes, "showcase");
2013                assert_eq!(args.items.len(), 1);
2014            }
2015            _ => panic!("expected Gallery"),
2016        }
2017    }
2018
2019    #[test]
2020    fn multi_line_attrs_with_quoted_brace_inside() {
2021        // The `}` inside a quoted value must NOT close the attr block.
2022        // The block legitimately closes on the third line.
2023        let md = ":::buttons {\n  .a\n  .b\n}\n[Go](go/)\n:::\n";
2024        let result = extract_shortcodes(md);
2025        assert_eq!(result.extracted.len(), 1);
2026        match &result.extracted[0].shortcode {
2027            Shortcode::Buttons(args) => {
2028                // Multi-line splits both classes — same as space-separated form.
2029                assert_eq!(args.classes, "a b");
2030            }
2031            _ => panic!("expected Buttons"),
2032        }
2033    }
2034
2035    // ---- Pure-CSS regions (Step 1 Task D) ----
2036
2037    #[test]
2038    fn css_region_unnamed_emits_div_wrapper() {
2039        let md = ":::{.tagline}\nA new way to publish.\n:::\n";
2040        let result = extract_shortcodes(md);
2041        assert!(result.extracted.is_empty());
2042        assert!(result
2043            .markdown_with_placeholders
2044            .contains("<div class=\"tagline\">"));
2045        assert!(result
2046            .markdown_with_placeholders
2047            .contains("A new way to publish."));
2048        assert!(result.markdown_with_placeholders.contains("</div>"));
2049    }
2050
2051    #[test]
2052    fn css_region_with_id_only() {
2053        let md = ":::{#intro}\nIntro prose.\n:::\n";
2054        let result = extract_shortcodes(md);
2055        assert!(result
2056            .markdown_with_placeholders
2057            .contains("<div id=\"intro\">"));
2058    }
2059
2060    #[test]
2061    fn css_region_with_classes_and_id() {
2062        let md = ":::{.callout #important}\nWatch out.\n:::\n";
2063        let result = extract_shortcodes(md);
2064        let out = &result.markdown_with_placeholders;
2065        assert!(out.contains("<div"));
2066        assert!(out.contains("class=\"callout\""));
2067        assert!(out.contains("id=\"important\""));
2068    }
2069
2070    #[test]
2071    fn css_region_emits_blank_lines_around_body_for_markdown_processing() {
2072        // Pulldown-cmark needs a blank line between the `<div>` and the
2073        // body to treat the body as markdown rather than raw HTML.
2074        let md = ":::{.foo}\n# Heading\n:::\n";
2075        let out = extract_shortcodes(md).markdown_with_placeholders;
2076        // The `<div>` line is followed by a blank line.
2077        assert!(out.contains(">\n\n# Heading"));
2078        // The closing `</div>` is preceded by a blank line.
2079        assert!(out.contains("# Heading\n\n</div>"));
2080    }
2081
2082    #[test]
2083    fn css_region_no_warning_emitted() {
2084        let md = ":::{.foo}\nbody\n:::\n";
2085        assert!(extract_shortcodes(md).warnings.is_empty());
2086    }
2087
2088    // ---- Unknown-name fallback (Step 1 Task E) ----
2089
2090    #[test]
2091    fn unknown_name_renders_fallback_wrapper() {
2092        let md = ":::nope {.extra}\nbody text\n:::\n";
2093        let result = extract_shortcodes(md);
2094        let out = &result.markdown_with_placeholders;
2095        assert!(out.contains("class=\"moss-unknown-shortcode extra\""));
2096        assert!(out.contains(r#"data-name="nope""#));
2097        assert!(out.contains("body text"));
2098    }
2099
2100    #[test]
2101    fn unknown_name_emits_build_warning() {
2102        let md = ":::nope\n:::\n";
2103        let warnings = extract_shortcodes(md).warnings;
2104        assert_eq!(warnings.len(), 1);
2105        assert!(warnings[0].contains("nope"));
2106    }
2107
2108    #[test]
2109    fn unknown_name_html_escapes_data_name() {
2110        // Defense: a maliciously crafted name (which the opener parser
2111        // wouldn't actually accept since names are [A-Za-z0-9_-]) shouldn't
2112        // be able to break out of the attribute. This test pins the
2113        // escape regardless.
2114        let md = ":::weird-name\nbody\n:::\n";
2115        let out = extract_shortcodes(md).markdown_with_placeholders;
2116        assert!(out.contains(r#"data-name="weird-name""#));
2117    }
2118
2119    // Grid left LEGACY_PASSTHROUGH in Step 2b — it's now a typed variant.
2120    // Coverage moved to the Grid section below (extracts_grid_*).
2121
2122    #[test]
2123    fn extracts_grid_with_positional_columns() {
2124        // Legacy moss-releases form: `:::grid 2` (positional column count)
2125        // with `---` cell divider. Both the positional cols and the legacy
2126        // `---` divider are accepted during the migration window.
2127        //
2128        // Phase 4 PR4.5 (2026-05-28): cells are now typed Vec<Vec<Block>>.
2129        // Each "cell A" / "cell B" parses to a single Paragraph block.
2130        let md = ":::grid 2\ncell A\n---\ncell B\n:::\n";
2131        let result = extract_shortcodes(md);
2132        assert_eq!(result.extracted.len(), 1);
2133        match &result.extracted[0].shortcode {
2134            Shortcode::Grid(grid) => {
2135                assert_eq!(grid.columns, 2);
2136                assert!(grid.ratio.is_none());
2137                assert_eq!(grid.cells.len(), 2);
2138                assert_paragraph_text(&grid.cells[0], "cell A");
2139                assert_paragraph_text(&grid.cells[1], "cell B");
2140            }
2141            other => panic!("expected Grid, got {other:?}"),
2142        }
2143    }
2144
2145    /// Test helper: assert that `cell_blocks` is a single `Block::Paragraph`
2146    /// whose inline text content (concatenated) equals `expected`.
2147    ///
2148    /// PR4.5 cells parse via pulldown-cmark; trivial cells like `"A"` yield
2149    /// `[Block::Paragraph(vec![Inline::Text("A".into())])]`.
2150    fn assert_paragraph_text(cell_blocks: &[Block], expected: &str) {
2151        if cell_blocks.is_empty() && expected.is_empty() {
2152            return;
2153        }
2154        let para = match cell_blocks {
2155            [Block::Paragraph(inlines)] => inlines,
2156            other => panic!(
2157                "expected single Paragraph cell with text {expected:?}, got: {other:?}"
2158            ),
2159        };
2160        let mut text = String::new();
2161        for inline in para {
2162            match inline {
2163                super::super::node::Inline::Text(t) => text.push_str(t),
2164                super::super::node::Inline::Code(c) => text.push_str(c),
2165                _ => {}
2166            }
2167        }
2168        assert_eq!(text, expected, "cell text mismatch");
2169    }
2170
2171    #[test]
2172    fn extracts_grid_with_positional_ratio() {
2173        let md = ":::grid 2 1:2\nleft\n---\nright\n:::\n";
2174        let result = extract_shortcodes(md);
2175        match &result.extracted[0].shortcode {
2176            Shortcode::Grid(grid) => {
2177                assert_eq!(grid.columns, 2);
2178                assert_eq!(grid.ratio.as_deref(), Some("1:2"));
2179            }
2180            _ => panic!("expected Grid"),
2181        }
2182    }
2183
2184    #[test]
2185    fn extracts_grid_with_cols_attr_integer() {
2186        let md = ":::grid {cols=3}\nA\n+++\nB\n+++\nC\n:::\n";
2187        let result = extract_shortcodes(md);
2188        match &result.extracted[0].shortcode {
2189            Shortcode::Grid(grid) => {
2190                assert_eq!(grid.columns, 3);
2191                assert_eq!(grid.cells.len(), 3);
2192                assert_paragraph_text(&grid.cells[0], "A");
2193                assert_paragraph_text(&grid.cells[1], "B");
2194                assert_paragraph_text(&grid.cells[2], "C");
2195            }
2196            _ => panic!("expected Grid"),
2197        }
2198    }
2199
2200    #[test]
2201    fn extracts_grid_with_cols_attr_ratio_implies_count() {
2202        let md = ":::grid {cols=1:1:2}\nA\n+++\nB\n+++\nC\n:::\n";
2203        let result = extract_shortcodes(md);
2204        match &result.extracted[0].shortcode {
2205            Shortcode::Grid(grid) => {
2206                assert_eq!(grid.columns, 3, "ratio length implies column count");
2207                assert_eq!(grid.ratio.as_deref(), Some("1:1:2"));
2208            }
2209            _ => panic!("expected Grid"),
2210        }
2211    }
2212
2213    #[test]
2214    fn extracts_grid_accepts_plus_plus_plus_divider() {
2215        let md = ":::grid 2\nA\n+++\nB\n:::\n";
2216        let result = extract_shortcodes(md);
2217        match &result.extracted[0].shortcode {
2218            Shortcode::Grid(grid) => {
2219                assert_eq!(grid.cells.len(), 2);
2220                assert_paragraph_text(&grid.cells[0], "A");
2221                assert_paragraph_text(&grid.cells[1], "B");
2222            }
2223            _ => panic!("expected Grid"),
2224        }
2225    }
2226
2227    #[test]
2228    fn extracts_grid_with_classes() {
2229        let md = ":::grid 3 {.work-cards .featured}\nA\n---\nB\n---\nC\n:::\n";
2230        let result = extract_shortcodes(md);
2231        match &result.extracted[0].shortcode {
2232            Shortcode::Grid(grid) => {
2233                assert_eq!(grid.columns, 3);
2234                assert_eq!(grid.classes, "work-cards featured");
2235            }
2236            _ => panic!("expected Grid"),
2237        }
2238    }
2239
2240    #[test]
2241    fn extracts_grid_single_cell_no_separator() {
2242        let md = ":::grid 1\nonly cell\n:::\n";
2243        let result = extract_shortcodes(md);
2244        match &result.extracted[0].shortcode {
2245            Shortcode::Grid(grid) => {
2246                assert_eq!(grid.columns, 1);
2247                assert_eq!(grid.cells.len(), 1);
2248                assert_paragraph_text(&grid.cells[0], "only cell");
2249            }
2250            _ => panic!("expected Grid"),
2251        }
2252    }
2253
2254    #[test]
2255    fn extracts_grid_with_empty_middle_cell() {
2256        // Two consecutive `+++` dividers leave a middle cell empty.
2257        // Legacy behavior preserved this; verify the typed extractor
2258        // does too. PR4.5: empty cells are `Vec<Block>::new()` (the
2259        // parser sees no content and emits zero blocks).
2260        let md = ":::grid 3\nA\n+++\n+++\nC\n:::\n";
2261        let result = extract_shortcodes(md);
2262        match &result.extracted[0].shortcode {
2263            Shortcode::Grid(grid) => {
2264                assert_eq!(grid.cells.len(), 3);
2265                assert_paragraph_text(&grid.cells[0], "A");
2266                assert!(grid.cells[1].is_empty(), "empty cell should have no blocks");
2267                assert_paragraph_text(&grid.cells[2], "C");
2268            }
2269            _ => panic!("expected Grid"),
2270        }
2271    }
2272
2273    #[test]
2274    fn nested_grid_via_arity_is_unsupported_authoring() {
2275        // Pinning test: `::::grid` (arity 4) wrapping `:::grid` (arity 3)
2276        // does NOT cleanly nest. The outer fence's body captures the
2277        // inner literally, but `split_grid_cells` then splits the outer's
2278        // body on the inner's `+++` divider — mis-attributing the inner's
2279        // cells to the outer. There's no separate "nested-cell-divider"
2280        // syntax in moss, so this nesting pattern isn't supported.
2281        //
2282        // Authors who need a "grid inside a grid" should use a CSS region
2283        // wrapper (`::::{.outer-grid}`) and set CSS-only column rules.
2284        // This test pins the actual extraction behavior so a future
2285        // refactor that changes it is visible.
2286        let md = "::::grid 1\n:::grid 2\nA\n+++\nB\n:::\n::::\n";
2287        let result = extract_shortcodes(md);
2288        // The outer ::::grid is extracted; the inner is captured as
2289        // literal text and the +++ inside the inner triggers the outer's
2290        // own cell split. The inner Grid is NOT a top-level entry.
2291        assert_eq!(result.extracted.len(), 1);
2292        match &result.extracted[0].shortcode {
2293            Shortcode::Grid(outer) => {
2294                assert_eq!(outer.columns, 1);
2295                // The +++ in the inner's body split the OUTER's cells,
2296                // which is the documented limitation.
2297                assert!(outer.cells.len() >= 2,
2298                    "outer's body got split by inner's +++, demonstrating the \
2299                     unsupported-nesting failure mode");
2300            }
2301            _ => panic!("expected Grid"),
2302        }
2303    }
2304
2305    #[test]
2306    fn extracts_grid_with_compound_link_cell_typed_as_link_card() {
2307        // SoCiviC pattern: a cell whose entire body is a single markdown
2308        // link wrapping multiple block children. Phase 4 PR4.5
2309        // (2026-05-28) detects this at the cell-string level (before
2310        // pulldown-cmark, which can't represent `[heading](url)`) and
2311        // emits a typed [`Block::LinkCard { url, children }`] with the
2312        // inner content parsed as blocks. The second cell is a plain
2313        // markdown link that fits the compound shape too (single line,
2314        // no block children) — also typed as `Block::LinkCard`.
2315        let md = ":::grid 2 {.work-cards}\n[![[poster.jpg]]\n#### Title\nbody](/url)\n+++\n[Card 2](/url2)\n:::\n";
2316        let result = extract_shortcodes(md);
2317        match &result.extracted[0].shortcode {
2318            Shortcode::Grid(grid) => {
2319                assert_eq!(grid.classes, "work-cards");
2320                assert_eq!(grid.cells.len(), 2);
2321                match &grid.cells[0][..] {
2322                    [Block::LinkCard { url, children }] => {
2323                        match url {
2324                            Url::Unresolved(u) => assert_eq!(u, "/url"),
2325                            _ => panic!("expected Unresolved /url"),
2326                        }
2327                        // children should include a Paragraph (with image)
2328                        // and a Heading (#### Title) — non-empty proves
2329                        // the inner block-parse ran.
2330                        assert!(!children.is_empty(), "compound-link inner blocks empty");
2331                    }
2332                    other => panic!("expected single LinkCard cell, got {other:?}"),
2333                }
2334                match &grid.cells[1][..] {
2335                    [Block::LinkCard { url, .. }] => match url {
2336                        Url::Unresolved(u) => assert_eq!(u, "/url2"),
2337                        _ => panic!("expected Unresolved /url2"),
2338                    },
2339                    other => panic!("expected LinkCard for cell[1], got {other:?}"),
2340                }
2341            }
2342            _ => panic!("expected Grid"),
2343        }
2344    }
2345
2346    // Hero left LEGACY_PASSTHROUGH in Step 2 — it's now a typed variant.
2347    // The replacement test (`extracts_hero_block_with_no_image`) lives in
2348    // the Hero section above.
2349
2350    #[test]
2351    fn toc_now_renders_as_unknown_shortcode() {
2352        // Step 2c removed `:::toc` without replacement. Sites still using
2353        // it fall through to the moss-unknown-shortcode wrapper with a
2354        // build warning. moss-releases content rewrite (Step 3) deletes
2355        // its 3 :::toc blocks.
2356        let md = ":::toc\n:::\n";
2357        let result = extract_shortcodes(md);
2358        assert!(result.extracted.is_empty(), "toc is no longer typed");
2359        assert_eq!(result.warnings.len(), 1, "unknown-name fallback warning");
2360        assert!(result.warnings[0].contains("toc"));
2361        assert!(result
2362            .markdown_with_placeholders
2363            .contains(r#"data-name="toc""#));
2364    }
2365
2366    // ---- Hero (Step 2) ----
2367
2368    #[test]
2369    fn extracts_hero_block_with_no_image() {
2370        let md = ":::hero\n# A House of Daowu\n:::\n";
2371        let result = extract_shortcodes(md);
2372        assert_eq!(result.extracted.len(), 1, "hero should be extracted");
2373        match &result.extracted[0].shortcode {
2374            Shortcode::Hero(args) => {
2375                assert!(args.image.is_none());
2376                assert_eq!(args.overlay_text, "# A House of Daowu");
2377            }
2378            other => panic!("expected Hero, got {other:?}"),
2379        }
2380        // The literal `:::hero` should not survive in the output.
2381        assert!(!result.markdown_with_placeholders.contains(":::hero"));
2382    }
2383
2384    #[test]
2385    fn extracts_hero_block_with_wikilink_body_image() {
2386        let md = ":::hero\n![[panorama.jpg]]\n# Welcome\n:::\n";
2387        let result = extract_shortcodes(md);
2388        assert_eq!(result.extracted.len(), 1);
2389        match &result.extracted[0].shortcode {
2390            Shortcode::Hero(args) => {
2391                match &args.image {
2392                    Some(Url::Unresolved(s)) => assert_eq!(s, "panorama.jpg"),
2393                    other => panic!("expected Unresolved url, got {other:?}"),
2394                }
2395                assert_eq!(args.overlay_text, "# Welcome");
2396            }
2397            other => panic!("expected Hero, got {other:?}"),
2398        }
2399    }
2400
2401    #[test]
2402    fn extracts_hero_block_with_image_attr() {
2403        let md = ":::hero {image=cover.jpg}\n# Title\n:::\n";
2404        let result = extract_shortcodes(md);
2405        match &result.extracted[0].shortcode {
2406            Shortcode::Hero(args) => {
2407                match &args.image {
2408                    Some(Url::Unresolved(s)) => assert_eq!(s, "cover.jpg"),
2409                    other => panic!("expected Unresolved, got {other:?}"),
2410                }
2411                assert_eq!(args.overlay_text, "# Title");
2412            }
2413            other => panic!("expected Hero, got {other:?}"),
2414        }
2415    }
2416
2417    #[test]
2418    fn extracts_hero_block_with_image_attr_and_pipe_attrs() {
2419        // The pipe character isn't in the bareword set, so values containing
2420        // `|` must be quoted under the unified grammar.
2421        let md = r#":::hero {image="cover.jpg|contain top"}
2422:::
2423"#;
2424        let result = extract_shortcodes(md);
2425        match &result.extracted[0].shortcode {
2426            Shortcode::Hero(args) => {
2427                match &args.image {
2428                    Some(Url::Unresolved(s)) => assert_eq!(s, "cover.jpg"),
2429                    _ => panic!("expected Unresolved"),
2430                }
2431                assert_eq!(args.attrs, "contain top");
2432            }
2433            _ => panic!("expected Hero"),
2434        }
2435    }
2436
2437    #[test]
2438    fn extracts_hero_block_with_classes() {
2439        let md = ":::hero {.full .center}\n# Title\n:::\n";
2440        let result = extract_shortcodes(md);
2441        match &result.extracted[0].shortcode {
2442            Shortcode::Hero(args) => {
2443                assert_eq!(args.classes, "full center");
2444            }
2445            _ => panic!("expected Hero"),
2446        }
2447    }
2448
2449    #[test]
2450    fn extracts_hero_block_with_directive_line_path() {
2451        // Legacy syntax used by Yi-website and chps-site:
2452        // `:::hero ./path.jpg` (image path on the directive line, empty body).
2453        // Step 3 rewrites these blocks to `:::hero {image=./path.jpg}`,
2454        // but the typed extractor must keep producing the same Hero AST
2455        // node until then to avoid silently dropping the homepage hero.
2456        let md = ":::hero ./assets/header.png\n:::\n";
2457        let result = extract_shortcodes(md);
2458        assert_eq!(result.extracted.len(), 1);
2459        match &result.extracted[0].shortcode {
2460            Shortcode::Hero(args) => match &args.image {
2461                Some(Url::Unresolved(s)) => assert_eq!(s, "./assets/header.png"),
2462                other => panic!("expected Unresolved ./assets/header.png, got {other:?}"),
2463            },
2464            _ => panic!("expected Hero"),
2465        }
2466    }
2467
2468    #[test]
2469    fn extracts_hero_block_with_directive_line_path_and_pipe_attrs() {
2470        let md = ":::hero ./bg.jpg|contain top\n:::\n";
2471        let result = extract_shortcodes(md);
2472        match &result.extracted[0].shortcode {
2473            Shortcode::Hero(args) => {
2474                match &args.image {
2475                    Some(Url::Unresolved(s)) => assert_eq!(s, "./bg.jpg"),
2476                    _ => panic!("expected Unresolved"),
2477                }
2478                assert_eq!(args.attrs, "contain top");
2479            }
2480            _ => panic!("expected Hero"),
2481        }
2482    }
2483
2484    #[test]
2485    fn extracts_hero_block_with_directive_line_path_and_classes() {
2486        // `:::hero ./path.jpg {.landing}` — directive-line path AND
2487        // an attribute block (classes only, no `image=` to avoid conflict).
2488        let md = ":::hero ./bg.jpg {.landing}\n# Welcome\n:::\n";
2489        let result = extract_shortcodes(md);
2490        match &result.extracted[0].shortcode {
2491            Shortcode::Hero(args) => {
2492                match &args.image {
2493                    Some(Url::Unresolved(s)) => assert_eq!(s, "./bg.jpg"),
2494                    _ => panic!("expected Unresolved"),
2495                }
2496                assert_eq!(args.classes, "landing");
2497                assert_eq!(args.overlay_text, "# Welcome");
2498            }
2499            _ => panic!("expected Hero"),
2500        }
2501    }
2502
2503    // ---- Adversarial cases for Step 1 (D/E semantics) ----
2504
2505    #[test]
2506    fn nested_css_region_outer_closes_at_first_inner_close() {
2507        // Pinning test: same-arity nested `:::{.outer}` containing
2508        // `:::{.inner}` is NOT a Step 1 feature. The outer block closes at
2509        // the inner block's `:::` because both fences are arity 3.
2510        // Authors who need nesting must use mismatched arities
2511        // (`::::{.outer}` containing `:::{.inner}`).
2512        //
2513        // This test pins the current behavior so a future regression
2514        // surfaces.
2515        let md = ":::{.outer}\n:::{.inner}\nbody\n:::\n:::\n";
2516        let result = extract_shortcodes(md);
2517        let out = &result.markdown_with_placeholders;
2518        // The outer `<div class="outer">` opens.
2519        assert!(out.contains("<div class=\"outer\""));
2520        // The inner `:::{.inner}` opener is left as literal text in the
2521        // outer body — the outer fence closed at the first arity-3 `:::`.
2522        assert!(out.contains(":::{.inner}"));
2523    }
2524
2525    #[test]
2526    fn nested_css_region_higher_arity_outer_recurses_into_inner() {
2527        // `::::{.outer}` (arity 4) survives past the inner `:::{.inner}`
2528        // close. The extractor recurses into the outer's body, so the
2529        // inner CssRegion gets its own `<div class="inner">` wrapper.
2530        // Both wrappers are present in the rendered output.
2531        let md = "::::{.outer}\n:::{.inner}\nbody\n:::\n::::\n";
2532        let result = extract_shortcodes(md);
2533        let out = &result.markdown_with_placeholders;
2534        assert!(out.contains("<div class=\"outer\""));
2535        assert!(out.contains("<div class=\"inner\""));
2536        // No literal `:::{.inner}` should leak into the body.
2537        assert!(!out.contains(":::{.inner}"));
2538    }
2539
2540    #[test]
2541    fn css_region_containing_typed_subscribe_is_not_recursively_extracted() {
2542        // Same-arity nesting: outer `:::{.wrapper}` closes at the first
2543        // matching `:::`, so the inner `:::subscribe` is never seen.
2544        let md = ":::{.wrapper}\n:::subscribe\n:::\n:::\n";
2545        let result = extract_shortcodes(md);
2546        // The wrapper opens. No subscribe is extracted because the
2547        // outer block consumed its arity-3 closer at the inner block's
2548        // first `:::`.
2549        assert!(result.markdown_with_placeholders.contains("<div class=\"wrapper\""));
2550        // Subscribe is NOT extracted in Step 1.
2551        assert!(result.extracted.is_empty());
2552    }
2553
2554    #[test]
2555    fn higher_arity_wrapper_recursively_extracts_typed_subscribe() {
2556        // `::::{.wrapper}` (arity 4) keeps the inner `:::subscribe`
2557        // intact in its body, and the extractor recurses into the body
2558        // so subscribe is parsed into a typed Shortcode and replaced
2559        // with a sentinel. Body markdown contains the sentinel, not the
2560        // literal source.
2561        let md = "::::{.wrapper}\n:::subscribe\n:::\n::::\n";
2562        let result = extract_shortcodes(md);
2563        assert!(result.markdown_with_placeholders.contains("<div class=\"wrapper\""));
2564        assert_eq!(result.extracted.len(), 1);
2565        match &result.extracted[0].shortcode {
2566            Shortcode::Subscribe(_) => {}
2567            _ => panic!("expected Subscribe"),
2568        }
2569        // Source `:::subscribe` is replaced by a sentinel — must not
2570        // leak into the rendered body.
2571        assert!(!result.markdown_with_placeholders.contains(":::subscribe"));
2572    }
2573
2574    #[test]
2575    fn lower_arity_outer_wraps_higher_arity_typed_inner() {
2576        // SoCiviC pattern: `:::{.support-band}` (arity 3) wraps
2577        // `::::buttons` (arity 4). The outer arity-3 closer at the end
2578        // closes the outer, so the inner arity-4 buttons block lives
2579        // intact inside the outer's body. Recursive extraction picks
2580        // it up and emits a sentinel.
2581        let md = ":::{.support-band}\n## Title\n\n::::buttons {.inverted}\n[Support Us](/support)\n::::\n*footnote*\n:::\n";
2582        let result = extract_shortcodes(md);
2583        let out = &result.markdown_with_placeholders;
2584        // Outer CssRegion wrapper.
2585        assert!(out.contains("<div class=\"support-band\""));
2586        // Inner buttons extracted as typed Shortcode.
2587        assert_eq!(result.extracted.len(), 1);
2588        match &result.extracted[0].shortcode {
2589            Shortcode::Buttons(args) => {
2590                assert_eq!(args.items.len(), 1);
2591            }
2592            _ => panic!("expected Buttons"),
2593        }
2594        // No literal `::::buttons` text should leak through.
2595        assert!(!out.contains("::::buttons"));
2596        assert!(!out.contains("::::"));
2597    }
2598
2599    #[test]
2600    fn lower_arity_outer_wraps_grid_with_buttons_in_cell() {
2601        // SoCiviC index pattern: 3-colon `:::{.hero-split}` outer,
2602        // 4-colon `::::grid 2 {.no-cards}` middle, 5-colon
2603        // `:::::buttons {.inverted}` innermost. The middle grid block
2604        // is the recursive-extraction target — its body in turn
2605        // contains the buttons block, but buttons-inside-grid-cells is
2606        // resolved by the grid renderer, not the extractor.
2607        let md = "::: {.hero-split}\n::::grid 2 {.no-cards}\nleft\n+++\nright\n::::\n:::\n";
2608        let result = extract_shortcodes(md);
2609        let out = &result.markdown_with_placeholders;
2610        // Outer hero-split CssRegion wrapper.
2611        assert!(out.contains("<div class=\"hero-split\""));
2612        // Inner grid extracted as typed Grid.
2613        assert_eq!(result.extracted.len(), 1);
2614        match &result.extracted[0].shortcode {
2615            Shortcode::Grid(_) => {}
2616            _ => panic!("expected Grid"),
2617        }
2618        // No literal `::::grid` text should leak through.
2619        assert!(!out.contains("::::grid"));
2620    }
2621
2622    #[test]
2623    fn unknown_name_body_recursively_extracts_typed_inner() {
2624        // Unknown-name fallback (e.g. typo'd `:::buttosn`) wraps in a
2625        // moss-unknown-shortcode div. If the body contains a higher-
2626        // arity typed block (e.g. nested `::::buttons`), recursion
2627        // picks it up so authors can debug their typo without losing
2628        // valid inner content.
2629        let md = ":::buttosn\n::::buttons\n[a](u)\n::::\n:::\n";
2630        let result = extract_shortcodes(md);
2631        let out = &result.markdown_with_placeholders;
2632        // Unknown wrapper.
2633        assert!(out.contains("data-name=\"buttosn\""));
2634        // Inner buttons extracted.
2635        assert_eq!(result.extracted.len(), 1);
2636        match &result.extracted[0].shortcode {
2637            Shortcode::Buttons(_) => {}
2638            _ => panic!("expected Buttons"),
2639        }
2640    }
2641
2642    #[test]
2643    fn unknown_name_with_plus_plus_plus_in_body_passes_through() {
2644        // The `+++` cell divider is a Buttons-and-Grid concern, not
2645        // generic shortcode body syntax. Unknown blocks should emit
2646        // their body verbatim including any `+++` lines. Authors who
2647        // misspell `:::buttons` as `:::buttosn` shouldn't see their
2648        // dividers eaten.
2649        let md = ":::buttosn\n[a](u)\n+++\n[b](v)\n:::\n";
2650        let result = extract_shortcodes(md);
2651        let out = &result.markdown_with_placeholders;
2652        assert!(out.contains(r#"data-name="buttosn""#));
2653        assert!(out.contains("[a](u)"));
2654        assert!(out.contains("+++"));
2655        assert!(out.contains("[b](v)"));
2656    }
2657
2658    #[test]
2659    fn parse_shortcode_opener_recognizes_empty_name_with_attrs() {
2660        assert_eq!(
2661            parse_shortcode_opener(":::{.tagline}"),
2662            Some((3, "", "{.tagline}"))
2663        );
2664    }
2665
2666    #[test]
2667    fn parse_shortcode_opener_rejects_just_colons() {
2668        assert!(parse_shortcode_opener(":::").is_none());
2669        assert!(parse_shortcode_opener(":::   ").is_none());
2670    }
2671
2672    #[test]
2673    fn unclosed_multi_line_attrs_block_emits_verbatim() {
2674        // A `{` that never closes within the doc should bubble up as
2675        // an unclosed block (verbatim emission).
2676        let md = ":::buttons {\n  .primary\n[Go](go/)\n:::\n";
2677        let result = extract_shortcodes(md);
2678        // The attribute parser surfaces an UnclosedBrace error inside
2679        // split_positional_and_classes' brace search. The block silently
2680        // falls through to the unrecognized-name path → verbatim.
2681        // (Step 1 Task E will tighten this into an explicit warning.)
2682        assert!(result.extracted.is_empty() || matches!(result.extracted[0].shortcode, Shortcode::Buttons(_)));
2683        // The opener is preserved either way.
2684    }
2685
2686    // ---- Deprecation warnings (Step 3 E2) ----
2687
2688    #[test]
2689    fn grid_legacy_dash_emits_deprecation_warning() {
2690        let md = ":::grid 2\ncell A\n---\ncell B\n:::\n";
2691        let result = extract_shortcodes(md);
2692        assert_eq!(result.warnings.len(), 1);
2693        assert!(result.warnings[0].contains("deprecated"));
2694        assert!(result.warnings[0].contains("+++"));
2695    }
2696
2697    #[test]
2698    fn grid_plus_plus_plus_no_deprecation_warning() {
2699        let md = ":::grid 2\ncell A\n+++\ncell B\n:::\n";
2700        let result = extract_shortcodes(md);
2701        assert!(result.warnings.is_empty());
2702    }
2703
2704    #[test]
2705    fn hero_priority3_body_image_emits_deprecation_warning() {
2706        let md = ":::hero\nphoto.jpg\n# Title\n:::\n";
2707        let result = extract_shortcodes(md);
2708        assert_eq!(result.warnings.len(), 1);
2709        assert!(result.warnings[0].contains("deprecated"));
2710        assert!(result.warnings[0].contains("image="));
2711    }
2712
2713    #[test]
2714    fn hero_explicit_image_attr_no_deprecation_warning() {
2715        let md = ":::hero {image=photo.jpg}\n# Title\n:::\n";
2716        let result = extract_shortcodes(md);
2717        assert!(result.warnings.is_empty());
2718    }
2719
2720    // ── spec § P9 width-flag extraction ─────────────────────────────
2721    //
2722    // `:::hero {full}` / `:::gallery {wide}` / `:::grid {page}` set the
2723    // `width` field on the typed shortcode. `full` aliases to `screen`.
2724    // Absence of a width flag leaves `width = None`, which the emitter
2725    // turns into "no `data-width` attribute on the wrapper".
2726
2727    fn first_extracted(md: &str) -> Shortcode {
2728        let result = extract_shortcodes(md);
2729        result
2730            .extracted
2731            .into_iter()
2732            .next()
2733            .expect("at least one shortcode")
2734            .shortcode
2735    }
2736
2737    #[test]
2738    fn hero_with_full_flag_sets_width_screen() {
2739        let md = ":::hero {image=photo.jpg full}\n# Title\n:::\n";
2740        match first_extracted(md) {
2741            Shortcode::Hero(h) => assert_eq!(h.width.as_deref(), Some("screen")),
2742            other => panic!("expected Hero, got {other:?}"),
2743        }
2744    }
2745
2746    #[test]
2747    fn hero_with_screen_flag_sets_width_screen() {
2748        let md = ":::hero {image=photo.jpg screen}\n# Title\n:::\n";
2749        match first_extracted(md) {
2750            Shortcode::Hero(h) => assert_eq!(h.width.as_deref(), Some("screen")),
2751            other => panic!("expected Hero, got {other:?}"),
2752        }
2753    }
2754
2755    #[test]
2756    fn hero_with_wide_flag_sets_width_wide() {
2757        let md = ":::hero {image=photo.jpg wide}\n# Title\n:::\n";
2758        match first_extracted(md) {
2759            Shortcode::Hero(h) => assert_eq!(h.width.as_deref(), Some("wide")),
2760            other => panic!("expected Hero, got {other:?}"),
2761        }
2762    }
2763
2764    #[test]
2765    fn hero_without_width_flag_leaves_width_none() {
2766        let md = ":::hero {image=photo.jpg}\n# Title\n:::\n";
2767        match first_extracted(md) {
2768            Shortcode::Hero(h) => assert!(h.width.is_none(), "got {:?}", h.width),
2769            other => panic!("expected Hero, got {other:?}"),
2770        }
2771    }
2772
2773    #[test]
2774    fn hero_mobile_overlay_attr_is_parsed() {
2775        let md = ":::hero {image=hero.jpg mobile=overlay}\n# Title\n:::\n";
2776        let result = extract_shortcodes(md);
2777        assert_eq!(result.extracted.len(), 1);
2778        match &result.extracted[0].shortcode {
2779            Shortcode::Hero(args) => {
2780                assert_eq!(args.mobile.as_deref(), Some("overlay"));
2781            }
2782            other => panic!("expected Hero, got {other:?}"),
2783        }
2784    }
2785
2786    #[test]
2787    fn hero_without_mobile_attr_has_none() {
2788        let md = ":::hero {image=hero.jpg}\n# Title\n:::\n";
2789        let result = extract_shortcodes(md);
2790        match &result.extracted[0].shortcode {
2791            Shortcode::Hero(args) => {
2792                assert!(args.mobile.is_none());
2793            }
2794            other => panic!("expected Hero, got {other:?}"),
2795        }
2796    }
2797
2798    #[test]
2799    fn hero_mobile_overlay_with_body_image_fallback() {
2800        let md = ":::hero {mobile=overlay}\n![[bg.jpg]]\n# Title\n:::\n";
2801        let result = extract_shortcodes(md);
2802        match &result.extracted[0].shortcode {
2803            Shortcode::Hero(args) => {
2804                assert_eq!(args.mobile.as_deref(), Some("overlay"));
2805                assert!(args.image.is_some());
2806            }
2807            other => panic!("expected Hero, got {other:?}"),
2808        }
2809    }
2810
2811    #[test]
2812    fn hero_unknown_mobile_value_emits_warning() {
2813        let md = ":::hero {image=hero.jpg mobile=fullscreen}\n# Title\n:::\n";
2814        let result = extract_shortcodes(md);
2815        assert!(
2816            result.warnings.iter().any(|w| w.contains("unrecognized") && w.contains("fullscreen")),
2817            "expected warning for unknown mobile value, got: {:?}",
2818            result.warnings,
2819        );
2820        // The shortcode is still extracted (not dropped).
2821        assert_eq!(result.extracted.len(), 1);
2822    }
2823
2824    #[test]
2825    fn placeholder_preserves_block_line_count_for_source_line_accuracy() {
2826        // A multi-line shortcode must collapse to a placeholder occupying the
2827        // SAME number of lines, so the post-extraction LineLookup stays line-
2828        // accurate. Without padding, data-source-line drifts after the block →
2829        // broken editor↔preview scroll sync (the home-page grid bug).
2830        let md = "# Title\n\n:::grid 3\n[\n![](a.jpg)\n](/x)\n+++\n[\n![](b.jpg)\n](/y)\n:::\n\n## After\n";
2831        let input_lines = md.lines().count();
2832        let result = extract_shortcodes(md);
2833        assert_eq!(
2834            result.markdown_with_placeholders.lines().count(),
2835            input_lines,
2836            "placeholder must preserve the block's line count; got:\n{}",
2837            result.markdown_with_placeholders
2838        );
2839        // The heading after the grid must still be on its original line 13.
2840        let after_line = result
2841            .markdown_with_placeholders
2842            .lines()
2843            .position(|l| l.contains("## After"))
2844            .map(|p| p + 1);
2845        assert_eq!(after_line, Some(13), "## After should stay on line 13");
2846    }
2847
2848    #[test]
2849    fn gallery_with_page_flag_sets_width_page() {
2850        let md = ":::gallery 3 {page}\nphoto.jpg\n:::\n";
2851        match first_extracted(md) {
2852            Shortcode::Gallery(g) => assert_eq!(g.width.as_deref(), Some("page")),
2853            other => panic!("expected Gallery, got {other:?}"),
2854        }
2855    }
2856
2857    #[test]
2858    fn gallery_without_width_flag_leaves_width_none() {
2859        let md = ":::gallery 3\nphoto.jpg\n:::\n";
2860        match first_extracted(md) {
2861            Shortcode::Gallery(g) => assert!(g.width.is_none()),
2862            other => panic!("expected Gallery, got {other:?}"),
2863        }
2864    }
2865
2866    #[test]
2867    fn grid_with_wide_flag_sets_width_wide() {
2868        let md = ":::grid {cols=2 wide}\ncell A\n+++\ncell B\n:::\n";
2869        match first_extracted(md) {
2870            Shortcode::Grid(g) => assert_eq!(g.width.as_deref(), Some("wide")),
2871            other => panic!("expected Grid, got {other:?}"),
2872        }
2873    }
2874
2875    #[test]
2876    fn grid_with_full_flag_normalizes_to_screen() {
2877        let md = ":::grid {cols=2 full}\ncell A\n+++\ncell B\n:::\n";
2878        match first_extracted(md) {
2879            Shortcode::Grid(g) => assert_eq!(g.width.as_deref(), Some("screen")),
2880            other => panic!("expected Grid, got {other:?}"),
2881        }
2882    }
2883
2884    #[test]
2885    fn grid_without_width_flag_leaves_width_none() {
2886        let md = ":::grid 2\ncell A\n+++\ncell B\n:::\n";
2887        match first_extracted(md) {
2888            Shortcode::Grid(g) => assert!(g.width.is_none()),
2889            other => panic!("expected Grid, got {other:?}"),
2890        }
2891    }
2892
2893    // ---- Recent (Phase B / Task 4.2) ----
2894
2895    #[test]
2896    fn parses_recent_with_since_and_count() {
2897        let (sc, warns) = parse_shortcode_block(
2898            "recent",
2899            r#"{since="2026-04-01" count="5"}"#,
2900            "",
2901        );
2902        assert!(warns.is_empty());
2903        match sc.expect("expected Some(Shortcode)") {
2904            Shortcode::Recent(args) => {
2905                assert_eq!(args.since.as_deref(), Some("2026-04-01"));
2906                assert_eq!(args.count, Some(5));
2907                assert!(args.last.is_none());
2908                assert!(args.fallback_markdown.is_empty());
2909            }
2910            other => panic!("expected Recent, got {other:?}"),
2911        }
2912    }
2913
2914    #[test]
2915    fn parses_recent_with_last_window() {
2916        let (sc, _) = parse_shortcode_block("recent", r#"{last="month"}"#, "");
2917        match sc.expect("expected Some(Shortcode)") {
2918            Shortcode::Recent(args) => {
2919                assert_eq!(args.last.as_deref(), Some("month"));
2920                assert!(args.since.is_none());
2921                assert!(args.count.is_none());
2922            }
2923            other => panic!("expected Recent, got {other:?}"),
2924        }
2925    }
2926
2927    #[test]
2928    fn captures_recent_body_as_fallback_markdown() {
2929        let body = "No posts yet. [Follow along](/).";
2930        let (sc, _) = parse_shortcode_block("recent", "", body);
2931        match sc.expect("expected Some(Shortcode)") {
2932            Shortcode::Recent(args) => {
2933                assert_eq!(args.fallback_markdown, body);
2934            }
2935            other => panic!("expected Recent, got {other:?}"),
2936        }
2937    }
2938
2939    #[test]
2940    fn recent_with_no_args_yields_all_none() {
2941        let (sc, warns) = parse_shortcode_block("recent", "", "");
2942        assert!(warns.is_empty());
2943        match sc.expect("expected Some(Shortcode)") {
2944            Shortcode::Recent(args) => {
2945                assert!(args.since.is_none());
2946                assert!(args.last.is_none());
2947                assert!(args.count.is_none());
2948                assert!(args.fallback_markdown.is_empty());
2949            }
2950            other => panic!("expected Recent, got {other:?}"),
2951        }
2952    }
2953
2954    #[test]
2955    fn parses_recent_with_all_three_attrs() {
2956        let (sc, warns) = parse_shortcode_block(
2957            "recent",
2958            r#"{since="2026-01-01" last="month" count="3"}"#,
2959            "",
2960        );
2961        assert!(warns.is_empty());
2962        match sc.expect("expected Some(Shortcode)") {
2963            Shortcode::Recent(args) => {
2964                assert_eq!(args.since.as_deref(), Some("2026-01-01"));
2965                assert_eq!(args.last.as_deref(), Some("month"));
2966                assert_eq!(args.count, Some(3));
2967            }
2968            other => panic!("expected Recent, got {other:?}"),
2969        }
2970    }
2971
2972    #[test]
2973    fn recent_with_malformed_count_yields_none_count() {
2974        // Tolerant parsing: a non-numeric count value drops to None
2975        // rather than failing the whole block. The renderer will fall
2976        // back to its default (10).
2977        let (sc, _) = parse_shortcode_block("recent", r#"{count="lots"}"#, "");
2978        match sc.expect("expected Some(Shortcode)") {
2979            Shortcode::Recent(args) => assert!(args.count.is_none()),
2980            other => panic!("expected Recent, got {other:?}"),
2981        }
2982    }
2983
2984    #[test]
2985    fn recent_body_is_trimmed() {
2986        // Surrounding whitespace and trailing newlines do not need to
2987        // travel as part of the fallback markdown.
2988        let (sc, _) = parse_shortcode_block("recent", "", "\n  hello world  \n\n");
2989        match sc.expect("expected Some(Shortcode)") {
2990            Shortcode::Recent(args) => assert_eq!(args.fallback_markdown, "hello world"),
2991            other => panic!("expected Recent, got {other:?}"),
2992        }
2993    }
2994
2995    // ---- Apply ----
2996
2997    #[test]
2998    fn parses_apply_directive() {
2999        use super::super::shortcode::ShortcodeKind;
3000        use super::super::visit::has_shortcode_recursive;
3001        let doc = crate::ast::parse(":::apply\n:::\n");
3002        assert!(
3003            has_shortcode_recursive(&doc, ShortcodeKind::Apply),
3004            "expected an Apply shortcode"
3005        );
3006    }
3007
3008    #[test]
3009    fn apply_parse_bare_has_none_overrides() {
3010        let (sc, warns) = parse_shortcode_block("apply", "", "");
3011        assert!(warns.is_empty());
3012        match sc.expect("expected Some(Shortcode)") {
3013            Shortcode::Apply(args) => {
3014                assert!(args.placeholder.is_none());
3015                assert!(args.button.is_none());
3016            }
3017            other => panic!("expected Apply, got {other:?}"),
3018        }
3019    }
3020
3021    #[test]
3022    fn apply_parse_with_overrides() {
3023        let (sc, _) = parse_shortcode_block("apply", r#"{placeholder="email" button="申请"}"#, "");
3024        match sc.expect("expected Some(Shortcode)") {
3025            Shortcode::Apply(args) => {
3026                assert_eq!(args.placeholder.as_deref(), Some("email"));
3027                assert_eq!(args.button.as_deref(), Some("申请"));
3028            }
3029            other => panic!("expected Apply, got {other:?}"),
3030        }
3031    }
3032
3033    #[test]
3034    fn extracts_recent_end_to_end_with_sentinel() {
3035        // Full extraction path: `:::recent` opener is recognized as
3036        // typed-known, gets routed through parse_shortcode_block, and the
3037        // literal `:::recent` is replaced by a sentinel.
3038        let md = ":::recent {since=\"2026-04-01\" count=\"5\"}\nNo posts yet.\n:::\n";
3039        let result = extract_shortcodes(md);
3040        assert_eq!(result.extracted.len(), 1);
3041        match &result.extracted[0].shortcode {
3042            Shortcode::Recent(args) => {
3043                assert_eq!(args.since.as_deref(), Some("2026-04-01"));
3044                assert_eq!(args.count, Some(5));
3045                assert_eq!(args.fallback_markdown, "No posts yet.");
3046            }
3047            other => panic!("expected Recent, got {other:?}"),
3048        }
3049        assert!(!result.markdown_with_placeholders.contains(":::recent"));
3050        assert!(result
3051            .markdown_with_placeholders
3052            .contains(&placeholder_for(&result.nonce, 0)));
3053    }
3054}