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