Skip to main content

moss_core/ast/
shortcode_extract.rs

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