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, skipping the inert lines
4//! [`crate::inert_regions`] reports (so `:::buttons` inside a code fence,
5//! an indented code block, an inline code span or an HTML comment stays
6//! literal text) and recognizing
7//! `:::name ...args` / `:::` openers/closers. Each block is replaced with
8//! a sentinel HTML comment (`<!--MOSS_SC_{nonce}_N-->`) that pulldown-cmark
9//! emits as a `Block::Other` raw HTML; the final parser pass walks the
10//! AST and substitutes the sentinels with typed [`Shortcode`] variants.
11//!
12//! Why this design:
13//!
14//! - `:::` block syntax is not standard CommonMark; pulldown-cmark sees
15//!   it as plain text inside a paragraph. Post-parse text-matching is
16//!   fragile (works only when the shortcode is the entire paragraph).
17//! - Pre-parse extraction with a sentinel is the same pattern Zola uses
18//!   and preserves parsing correctness for adjacent content.
19//! - The sentinel is an HTML comment so it survives pulldown-cmark intact
20//!   (pulldown-cmark passes HTML comments through `Event::Html` as
21//!   `Block::HtmlBlock`).
22
23use super::attrs::gather_multi_line_attrs;
24use super::cells::split_cells;
25use super::node::Block;
26use super::parser::{parse_fragment_with_config, ParseConfig};
27use super::shortcode::{
28    ApplyShortcode, ButtonItem, ButtonsShortcode, GalleryItem, GalleryShortcode, GridShortcode,
29    RecentShortcode, Shortcode, SubscribeShortcode,
30};
31use super::url::Url;
32use crate::resolve::md_extract::{line_table, AssetPathSpan, MediaLineSpan, PathContainer};
33
34/// One extracted shortcode block, with its body parsed into a typed variant.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ExtractedShortcode {
37    /// 0-based index used in the placeholder sentinel.
38    pub index: usize,
39    /// Parsed shortcode (typed variants per Phase B).
40    pub shortcode: Shortcode,
41}
42
43/// Result of pre-parse extraction.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct ExtractionResult {
46    /// Markdown source with `:::shortcode` blocks replaced by sentinel
47    /// HTML comments. Pulldown-cmark sees this as the input.
48    pub markdown_with_placeholders: String,
49    /// One entry per extracted block, indexed by sentinel number.
50    pub extracted: Vec<ExtractedShortcode>,
51    /// Per-extraction nonce (8 hex chars). Derived from a hash of the
52    /// input markdown so it's deterministic but collision-resistant
53    /// against authored content. The placeholder format is
54    /// `<!--MOSS_SC_{nonce}_{index}-->`; an authored markdown comment
55    /// matching that exact shape would have to embed the same hash of
56    /// itself, which is computationally improbable for any input shorter
57    /// than the SHA universe.
58    pub nonce: String,
59    /// Build warnings collected during extraction (e.g. unknown shortcode
60    /// names). Each entry is a one-line human-readable string. Caller
61    /// surfaces these in the build log; presence does not abort the build.
62    pub warnings: Vec<String>,
63}
64
65/// Names recognized by the typed AST. Other names fall through to the
66/// unknown-name renderer (`<div class="moss-unknown-shortcode" data-name="…">`)
67/// with a build warning.
68const TYPED_KNOWN: &[&str] = &["subscribe", "buttons", "gallery", "hero", "grid", "recent", "apply"];
69
70fn is_typed_known(name: &str) -> bool {
71    TYPED_KNOWN.contains(&name)
72}
73
74/// Recognized shortcode names (Phase B Task 7+ adds variants here).
75///
76/// `args` is the trailing text after `:::name ` on the opening line
77/// (e.g. for `:::buttons {.primary}`, args is `{.primary}`).
78///
79/// Returns `(Some(Shortcode), Vec<String>)` where the second element is
80/// parse-time deprecation warnings. An empty warning vec means the block
81/// used only current-grammar syntax.
82fn parse_shortcode_block(
83    name: &str,
84    args: &str,
85    body: &str,
86    config: &ParseConfig,
87) -> (Option<Shortcode>, Vec<String>) {
88    match name {
89        "subscribe" => (Some(Shortcode::Subscribe(parse_subscribe_args(args))), vec![]),
90        "buttons" => (Some(Shortcode::Buttons(parse_buttons_body(args, body))), vec![]),
91        "gallery" => (Some(Shortcode::Gallery(parse_gallery_body(args, body))), vec![]),
92        "hero" => {
93            // Body media lines are the CANONICAL multi-slide grammar (the
94            // only way to express a crossfading hero), not a deprecated
95            // fallback — the old Priority-3 deprecation warning retired
96            // with the multi-image hero.
97            let (sc, _used_p3, fragment_warnings) = super::extract_hero::parse_hero(args, body, config);
98            let mut warns = fragment_warnings;
99            if let Some(ref v) = sc.mobile {
100                if v != "overlay" {
101                    warns.push(format!(
102                        "shortcode `:::hero` has unrecognized `mobile={v}`. \
103                         Only `mobile=overlay` is recognized. The attribute is ignored."
104                    ));
105                }
106            }
107            (Some(Shortcode::Hero(sc)), warns)
108        }
109        "grid" => {
110            let (sc, legacy, fragment_warnings) = parse_grid(args, body, config);
111            let mut warns = fragment_warnings;
112            if legacy {
113                warns.push(
114                    "shortcode `:::grid` uses `---` cell dividers (deprecated). Migrate to `+++`.\n\
115                     `---` support will be removed in a future release."
116                        .to_string(),
117                );
118            }
119            (Some(Shortcode::Grid(sc)), warns)
120        }
121        "recent" => (Some(Shortcode::Recent(parse_recent_args(args, body))), vec![]),
122        "apply" => (Some(Shortcode::Apply(parse_apply_args(args))), vec![]),
123        _ => (None, vec![]),
124    }
125}
126
127/// Parse `:::recent {since=... last=... count=...}` body into a typed struct.
128///
129/// `args` is the attribute block (e.g. `{since="2026-04-01" count="5"}`);
130/// `body` is the content between the opening and closing `:::` fences,
131/// captured verbatim (trimmed) as the fallback markdown for the zero-match
132/// render path.
133///
134/// Tolerant: unknown keys are ignored. A `count=` value that fails to parse
135/// as a `u32` becomes `None`; the renderer falls back to its default (10).
136/// `since` and `last` are passed through as raw strings — the rendering
137/// layer parses them into a `DateTime` / `Duration` so this stays I/O-free
138/// and chrono-free (moss-core invariant: pure data in / data out).
139pub fn parse_recent_args(args: &str, body: &str) -> RecentShortcode {
140    let attrs = super::attrs::parse_attrs(args).unwrap_or_default();
141    RecentShortcode {
142        since: attrs.get("since").map(str::to_string),
143        last: attrs.get("last").map(str::to_string),
144        count: attrs.get("count").and_then(|v| v.parse::<u32>().ok()),
145        fallback_markdown: body.trim().to_string(),
146    }
147}
148
149/// Parse a `:::grid` block.
150///
151/// Args parsing supports both:
152/// - **Positional** (legacy moss-releases): `:::grid 2 1:2 {.classes}` —
153///   first token is column count, second optional token is the ratio.
154/// - **Attribute** (new grammar): `:::grid {cols=2}` or `:::grid {cols=1:1:2}` —
155///   `cols=integer` sets the column count; `cols=ratio` sets both the
156///   ratio and the count (= ratio length).
157///
158/// Cells are split on lines containing only `+++` (new grammar) or
159/// `---` (legacy moss-releases). Step 3 of #613 rewrites `---` to `+++`
160/// in moss-releases content; the parser accepts both during the
161/// migration window.
162///
163/// Returns `(GridShortcode, bool, Vec<String>)` where the bool is `true`
164/// when any `---` legacy divider was encountered (triggers a deprecation
165/// warning), and the `Vec<String>` carries warnings collected while
166/// re-parsing cell bodies as fragments (e.g. a misspelled shortcode nested
167/// inside a cell) — see [`parse_cell_to_blocks`].
168fn parse_grid(args: &str, body: &str, config: &ParseConfig) -> (GridShortcode, bool, Vec<String>) {
169    let trimmed = args.trim();
170    let (positional, attr_block): (&str, &str) = if let Some(pos) = trimmed.find('{') {
171        // char-aligned: pos points to ASCII '{' from str::find — safe to slice.
172        #[allow(clippy::string_slice)]
173        (trimmed[..pos].trim(), &trimmed[pos..])
174    } else {
175        (trimmed, "")
176    };
177
178    let parsed = if attr_block.is_empty() {
179        Default::default()
180    } else {
181        super::attrs::parse_attrs(attr_block).unwrap_or_default()
182    };
183    let classes = parsed.class_string();
184    let width = parsed.width.map(str::to_string);
185
186    let mut columns: u32 = 1;
187    let mut ratio: Option<String> = None;
188
189    if let Some(cols_value) = parsed.get("cols") {
190        if cols_value.contains(':') {
191            ratio = Some(cols_value.to_string());
192            columns = cols_value.split(':').count() as u32;
193        } else if let Ok(n) = cols_value.parse::<u32>() {
194            columns = n.max(1);
195        }
196    } else {
197        // Positional fallback: e.g. `2 1:2`.
198        let parts: Vec<&str> = positional.split_whitespace().collect();
199        if let Some(first) = parts.first() {
200            if first.contains(':') {
201                ratio = Some(first.to_string());
202                columns = first.split(':').count() as u32;
203            } else if let Ok(n) = first.parse::<u32>() {
204                columns = n.max(1);
205                if let Some(second) = parts.get(1) {
206                    if second.contains(':') {
207                        ratio = Some(second.to_string());
208                    }
209                }
210            }
211        }
212    }
213
214    let (raw_cells, found_legacy_dash) = split_grid_cells(body);
215
216    // Phase 4 PR4.5 (2026-05-28): cells become Vec<Vec<Block>>. Each raw
217    // cell string is either:
218    //
219    // - A "compound-link" cell whose entire content is wrapped in a markdown
220    //   link `[inner](url)` and whose `inner` carries block-level content
221    //   (image + heading + paragraphs — the SoCiviC pattern). CommonMark's
222    //   inline parser cannot represent a `[](url)` with `### heading` inside,
223    //   so we detect this shape at the cell-string level FIRST and emit a
224    //   typed [`Block::LinkCard { url, children }`] where `children` is the
225    //   inner content parsed as blocks via [`super::parser::parse`].
226    //
227    // - A plain markdown cell. Parse via [`super::parser::parse`] (which
228    //   re-runs extract_shortcodes so any nested `::::buttons` etc. get
229    //   substituted) and drop the wrapping `Document`.
230    let mut fragment_warnings: Vec<String> = Vec::new();
231    let cells: Vec<Vec<Block>> = raw_cells
232        .iter()
233        .map(|raw| {
234            let (blocks, warns) = parse_cell_to_blocks(raw, config);
235            fragment_warnings.extend(warns);
236            blocks
237        })
238        .collect();
239
240    (
241        GridShortcode {
242            columns,
243            ratio,
244            classes,
245            cells,
246            width,
247        },
248        found_legacy_dash,
249        fragment_warnings,
250    )
251}
252
253/// Parse one grid cell's raw markdown source into a `Vec<Block>`.
254///
255/// Phase 4 PR4.5 (2026-05-28): detects the compound-link shape first
256/// (`[inner](url)` wrapping the entire trimmed cell content, optionally
257/// followed by blank-line-separated caption paragraphs for an image-link
258/// cell). On match, emits `vec![Block::LinkCard { url, children }, ...]`
259/// where `children` is the inner parsed as blocks and any trailing caption
260/// content is appended as sibling blocks after the card. On no match,
261/// parses the cell directly via [`super::parser::parse`].
262///
263/// Returns `(Vec<Block>, Vec<String>)`: the parsed blocks, plus any
264/// warnings collected by the fragment parse(s) underneath (e.g. a
265/// misspelled `:::name` shortcode nested inside this cell). Each internal
266/// [`parse_fragment_with_config`] call produces its own independent
267/// `Document`, whose `warnings` would otherwise be dropped on the floor —
268/// this is the plumbing that carries them out to [`parse_shortcode_block`],
269/// which already merges its `Vec<String>` into `doc.warnings`.
270fn parse_cell_to_blocks(raw: &str, config: &ParseConfig) -> (Vec<Block>, Vec<String>) {
271    if let Some((url, inner, trailing)) = detect_compound_link(raw) {
272        let inner_trimmed = inner.trim();
273        let trailing_trimmed = trailing.trim();
274        // Simple compound-link special case: when the inner content is
275        // plain phrasing text (no images, no nested links, no
276        // block-level markdown) AND the URL is external, fall through
277        // to the normal markdown parse so the cell stays a
278        // `[Paragraph([Link])]` — the typed shape the host's grid-cell
279        // classifier (`build/render/grid_cells.rs`) turns into a link
280        // preview (title + favicon + domain). A `LinkCard` cell is final
281        // markup that already carries its own `<a class="moss-grid-card">`
282        // chrome, so the host leaves it alone and the title row is lost.
283        //
284        // Mirrors the pre-PR4.5 carve-out in
285        // `crate::build::markdown::typed_renderers::render_compound_link_cell`
286        // (the `if !inner.contains('!') && !inner.contains('[') && !inner.contains('\n')`
287        // branch).
288        let inner_is_plain_text = !inner_trimmed.contains('!')
289            && !inner_trimmed.contains('[')
290            && !inner_trimmed.contains('\n');
291        let is_external = url.starts_with("http://") || url.starts_with("https://");
292        if inner_is_plain_text && is_external {
293            // Re-emit as standard markdown link inside a paragraph so the
294            // host's link-preview pass owns the rendering.
295            let linkified = format!("[{}]({})", inner_trimmed, url);
296            let doc = parse_fragment_with_config(&linkified, config);
297            return (doc.blocks, doc.warnings);
298        }
299        let inner_doc = parse_fragment_with_config(inner_trimmed, config);
300        let mut warnings = inner_doc.warnings;
301        let mut blocks = vec![Block::LinkCard {
302            url: Url::unresolved(url),
303            children: inner_doc.blocks,
304        }];
305        if !trailing_trimmed.is_empty() {
306            let trailing_doc = parse_fragment_with_config(trailing_trimmed, config);
307            blocks.extend(trailing_doc.blocks);
308            warnings.extend(trailing_doc.warnings);
309        }
310        return (blocks, warnings);
311    }
312    // Phase 4 PR4.5 (2026-05-28): bare-URL cell auto-promotion. When the
313    // entire cell content is a single bare URL on its own line (no
314    // markdown link syntax), parse it as `[](URL)` so the cell renders as
315    // `<p><a href="URL"></a></p>` (an empty-text link inside a paragraph).
316    // The host's grid-cell pass (`build/render/grid_cells.rs`) reads this
317    // shape off the typed cell and replaces it with a
318    // `<span class="link-preview-domain">…</span>` wrapper carrying
319    // title/favicon (from cached link metadata).
320    //
321    // Matches the pre-PR4.5 `linkify_bare_urls_in_cell` behavior — the
322    // helper turned `https://...` into `[](https://...)` so the downstream
323    // compound-link pass picked it up. PR4.5 ports the linkification to
324    // parse time so the bytes flow through the typed AST.
325    if let Some(url) = detect_bare_url_cell(raw) {
326        let linkified = format!("[]({})", url);
327        let doc = parse_fragment_with_config(&linkified, config);
328        return (doc.blocks, doc.warnings);
329    }
330    let doc = parse_fragment_with_config(raw, config);
331    (doc.blocks, doc.warnings)
332}
333
334/// Detect a "bare URL cell": the entire cell content (after trim) is a
335/// single `https?://...` URL on its own line, with no other content.
336///
337/// Returns the URL string on match, `None` otherwise. Used by
338/// [`parse_cell_to_blocks`] to linkify bare-URL cells via `[](URL)` so
339/// they thread through the host's link-preview pass like authored
340/// `[Title](URL)` cells.
341fn detect_bare_url_cell(cell_text: &str) -> Option<String> {
342    let trimmed = cell_text.trim();
343    if trimmed.is_empty() {
344        return None;
345    }
346    if trimmed.lines().count() > 1 {
347        return None;
348    }
349    if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
350        return None;
351    }
352    if trimmed.chars().any(char::is_whitespace) {
353        return None;
354    }
355    Some(trimmed.to_string())
356}
357
358/// Detect the compound-link shape in a grid cell's markdown content.
359///
360/// Matches cells whose content (after trimming whitespace) begins with `[`
361/// and, after balanced-bracket scanning, `](url)`. The inner content may
362/// span blank lines and contain any markdown block syntax (headings,
363/// images, paragraphs, lists, emphasis).
364///
365/// Returns `Some((url, inner_content, trailing_content))` on a match,
366/// `None` otherwise. `trailing_content` is non-empty only for the
367/// image-link-plus-caption shape described below; it is `""` for the
368/// classic whole-cell-is-the-link shape.
369///
370/// Ported from src-tauri's `crate::build::markdown::typed_renderers::
371/// detect_compound_link` (Phase 4 PR4.5, 2026-05-28) — the AST-level
372/// equivalent of the same string-level detection. The src-tauri version
373/// is deleted in PR4.5. NOT the general cure for `[![[x.png]]](/url)` —
374/// [`super::linked_embed`] is; this is the block-level grid *card*.
375///
376/// Safety rules that cause this function to return `None`:
377/// - Cell contains a top-level code fence (\`\`\` or ~~~).
378/// - Cell content starts with a backtick (inline code on first line).
379/// - The outer `[…](url)` shape cannot be confirmed by bracket-balance
380///   scanning (multiple top-level links, bare `]` / `(` without a pair).
381/// - There is content after the closing `)` that continues the SAME
382///   paragraph (no blank line before it) — CommonMark already renders
383///   `[Link](url) more text` correctly as one inline paragraph, and
384///   hijacking it into a card would change ordinary link cells.
385/// - There is content after the closing `)`, separated by a blank line,
386///   but the inner content does not lead with a WIKILINK image (`![[`).
387///   Trailing caption paragraphs are only recognized for this exact shape,
388///   the image-card-plus-caption cell (see moss#928-adjacent). A cell led by
389///   an ordinary markdown image (`![alt](src)`) is deliberately excluded:
390///   pulldown-cmark parses `![alt](src)` fine on its own, so
391///   `[![alt](src)](url)\n\ncaption` already reaches the plain block
392///   parser and comes out as `Paragraph([Link([Image])])` +
393///   `Paragraph([caption])` — exactly the shape the host's grid-cell
394///   classifier (`build/render/grid_cells.rs`) needs to recognize an
395///   external link preview. Widening this gate to any `!` would silently
396///   swallow that into a `LinkCard` and drop the preview chrome.
397///
398/// Detection uses bracket balancing so nested `](` sequences inside images
399/// (`![alt](src)`) or inline code do NOT prematurely end the outer link.
400pub(super) fn detect_compound_link(cell_text: &str) -> Option<(String, String, String)> {
401    let stripped = cell_text.trim();
402
403    if !stripped.starts_with('[') {
404        return None;
405    }
406    if stripped.len() > 1 && stripped.as_bytes()[1] == b'`' {
407        return None;
408    }
409
410    for line in stripped.lines() {
411        let t = line.trim();
412        if t.starts_with("```") || t.starts_with("~~~") {
413            return None;
414        }
415    }
416
417    let bytes = stripped.as_bytes();
418
419    // Phase 1: find the outer closing `]` via bracket-balance scan.
420    let mut i: usize = 1;
421    let mut depth: usize = 1;
422    let mut outer_close: Option<usize> = None;
423
424    while i < bytes.len() {
425        match bytes[i] {
426            b'\\' => {
427                i += 2;
428                continue;
429            }
430            b'`' => {
431                let tick_start = i;
432                while i < bytes.len() && bytes[i] == b'`' {
433                    i += 1;
434                }
435                let fence_len = i - tick_start;
436                'code_scan: while i < bytes.len() {
437                    if bytes[i] == b'`' {
438                        let close_start = i;
439                        while i < bytes.len() && bytes[i] == b'`' {
440                            i += 1;
441                        }
442                        if i - close_start == fence_len {
443                            break 'code_scan;
444                        }
445                    } else {
446                        i += 1;
447                    }
448                }
449                continue;
450            }
451            b'[' => {
452                depth += 1;
453            }
454            b']' => {
455                depth -= 1;
456                if depth == 0 {
457                    outer_close = Some(i);
458                    break;
459                }
460            }
461            _ => {}
462        }
463        i += 1;
464    }
465
466    let close_bracket = outer_close?;
467
468    if bytes.get(close_bracket + 1) != Some(&b'(') {
469        return None;
470    }
471
472    // Phase 2: find the matching `)` with paren balance.
473    let mut j = close_bracket + 2;
474    let mut pdepth: usize = 1;
475    let mut paren_close: Option<usize> = None;
476
477    while j < bytes.len() {
478        match bytes[j] {
479            b'\\' => {
480                j += 2;
481                continue;
482            }
483            b'(' => pdepth += 1,
484            b')' => {
485                pdepth -= 1;
486                if pdepth == 0 {
487                    paren_close = Some(j);
488                    break;
489                }
490            }
491            _ => {}
492        }
493        j += 1;
494    }
495
496    let close_paren = paren_close?;
497
498    // Phase 3: after `)`, either nothing/whitespace (classic shape), or a
499    // blank-line-separated caption for an image-link cell (see doc comment).
500    let tail = stripped.get(close_paren + 1..)?;
501    let inner = stripped.get(1..close_bracket)?;
502    let trailing = if tail.chars().all(char::is_whitespace) {
503        ""
504    } else {
505        let blank_line_before_trailing = {
506            let mut newlines = 0;
507            for c in tail.chars() {
508                if c == '\n' {
509                    newlines += 1;
510                } else if !c.is_whitespace() {
511                    break;
512                }
513            }
514            newlines >= 2
515        };
516        let candidate = tail.trim();
517        // Scoped to the wikilink-image shape only (see doc comment): an
518        // ordinary `![alt](src)` inner already reaches the plain block
519        // parser and renders correctly on its own, so it's excluded here
520        // rather than swallowed into a LinkCard.
521        if !blank_line_before_trailing || !inner.trim_start().starts_with("![[") {
522            return None;
523        }
524        candidate
525    };
526
527    // Phase 4: validate inner content.
528    if inner.trim().is_empty() {
529        return None;
530    }
531
532    // Phase 5: reject multiple top-level links (images allowed).
533    {
534        let inner_bytes = inner.as_bytes();
535        let mut k: usize = 0;
536        let mut image_stack: Vec<bool> = Vec::new();
537
538        while k < inner_bytes.len() {
539            match inner_bytes[k] {
540                b'\\' => {
541                    k += 2;
542                    continue;
543                }
544                b'`' => {
545                    let tick_start = k;
546                    while k < inner_bytes.len() && inner_bytes[k] == b'`' {
547                        k += 1;
548                    }
549                    let fence_len = k - tick_start;
550                    'inner_code: while k < inner_bytes.len() {
551                        if inner_bytes[k] == b'`' {
552                            let cs = k;
553                            while k < inner_bytes.len() && inner_bytes[k] == b'`' {
554                                k += 1;
555                            }
556                            if k - cs == fence_len {
557                                break 'inner_code;
558                            }
559                        } else {
560                            k += 1;
561                        }
562                    }
563                    continue;
564                }
565                b'[' => {
566                    let preceded_by_bang = k > 0 && inner_bytes[k - 1] == b'!';
567                    image_stack.push(preceded_by_bang);
568                }
569                b']' => {
570                    if let Some(is_image) = image_stack.pop() {
571                        if image_stack.is_empty() && inner_bytes.get(k + 1) == Some(&b'(') {
572                            if !is_image {
573                                return None;
574                            }
575                        }
576                    }
577                }
578                _ => {}
579            }
580            k += 1;
581        }
582    }
583
584    let url = stripped.get(close_bracket + 2..close_paren)?;
585    Some((url.to_string(), inner.to_string(), trailing.to_string()))
586}
587
588/// Split a grid body into cells on lines containing only `+++` (new
589/// grammar) or `---` (legacy moss-releases backward-compat).
590///
591/// Mirrors [`super::cells::split_cells`] but accepts either divider.
592/// Step 3 of #613 rewrites `---` to `+++` in moss-releases content;
593/// after that, this helper retires in favor of `split_cells`.
594///
595/// **A divider only counts when it belongs to THIS grid.** A `+++` inside a
596/// nested block, a code fence or an HTML comment is somebody else's. It used
597/// to split this grid anyway, so `::::grid` around `:::grid` — the nesting
598/// the grammar recommends — handed the inner grid's cells to the outer one,
599/// with no warning and plausible-looking output. Nested bodies pass through
600/// verbatim and are re-extracted when the cell is parsed, which is where the
601/// inner block gets its own dividers. Depth is tracked as
602/// [`super::editor_scan`] tracks it: an opener pushes its arity, a closer of
603/// the innermost arity pops it. Same-arity nesting is a different bug with
604/// its own warning (`nested_arity_warning`).
605///
606/// Returns `(cells, found_legacy_dash)` where `found_legacy_dash` is
607/// `true` when at least one `---` divider **of this grid** was encountered,
608/// signaling the caller to emit a deprecation warning. A `---` belonging to
609/// a nested block is reported when that block is parsed, not here.
610fn split_grid_cells(body: &str) -> (Vec<String>, bool) {
611    if body.is_empty() {
612        return (vec![String::new()], false);
613    }
614    // One flag per line, aligned with `str::lines`, which agrees index-for-
615    // index with `split_inclusive('\n')` for every input.
616    let inert = crate::inert_regions::inert_lines(body);
617    let mut cells = Vec::new();
618    let mut current = String::new();
619    let mut first_line_in_cell = true;
620    let mut found_legacy_dash = false;
621    // Arities of the nested blocks we are currently inside; empty means
622    // "at this grid's own depth", which is the only place a divider counts.
623    let mut nested_arities: Vec<usize> = Vec::new();
624
625    for (idx, line) in body.split_inclusive('\n').enumerate() {
626        let content_no_eol = line.strip_suffix('\n').unwrap_or(line);
627        let trimmed = content_no_eol.trim();
628        let live = !inert.get(idx).copied().unwrap_or(false);
629        if live {
630            if let Some((inner_arity, _, _)) = parse_shortcode_opener(trimmed) {
631                nested_arities.push(inner_arity);
632            } else if let Some(&innermost) = nested_arities.last() {
633                if is_close_fence(trimmed, innermost) {
634                    nested_arities.pop();
635                }
636            } else if trimmed == "+++" || trimmed == "---" {
637                if trimmed == "---" {
638                    found_legacy_dash = true;
639                }
640                if let Some(stripped) = current.strip_suffix('\n') {
641                    current.truncate(stripped.len());
642                }
643                cells.push(std::mem::take(&mut current));
644                first_line_in_cell = true;
645                continue;
646            }
647        }
648        if first_line_in_cell {
649            first_line_in_cell = false;
650            if trimmed.is_empty() {
651                continue;
652            }
653        }
654        current.push_str(line);
655    }
656    if let Some(stripped) = current.strip_suffix('\n') {
657        current.truncate(stripped.len());
658    }
659    cells.push(current);
660    (cells, found_legacy_dash)
661}
662
663/// Byte-offset asset paths inside `:::gallery` / `:::hero` blocks.
664///
665/// The structural half of rename tracking: these paths carry NO markdown
666/// reference syntax, so [`crate::resolve::md_extract::extract_md_references`]
667/// cannot see them and a rename silently broke them. Offsets are absolute in
668/// `source`; the body is never joined, so CRLF sources are exact by
669/// construction (unlike `extract_with_state`, which parses a
670/// `lines().join("\n")` copy and destroys offsets one level above the
671/// per-line parsers).
672///
673/// Recognition reads [`crate::inert_regions::mask_inert`] — the same mask
674/// [`crate::resolve::md_extract`] scans, so the two passes agree byte for
675/// byte on what is live syntax. Emitted STRINGS are sliced from `source`.
676///
677/// # Where the mask applies, and where it must not
678///
679/// The mask decides which lines carry a live `:::` OPENER or CLOSER — the
680/// same question `extract_with_state` asks it. It does NOT filter body
681/// lines: once a `:::gallery` opener is live, the block grammar owns every
682/// line until the closer, and `parse_gallery_body` reads them verbatim. A
683/// path inside a fenced code block in a gallery body really does render as
684/// an image, so masking it would leave exactly the silent rename break this
685/// scanner exists to fix.
686///
687/// # Deliberate divergence from `extract_with_state`
688///
689/// **Descends into every unrecognized block**, where the extractor recurses
690/// only into CssRegion and Unknown bodies. A `:::gallery` nested inside
691/// `::::grid` is therefore found. Over-approximating is safe: a rename only
692/// fires on an exact path match.
693///
694/// Kept in step with the parsers by `spans_agree_with_parsers`.
695pub fn shortcode_asset_spans(source: &str) -> Vec<AssetPathSpan> {
696    let mask = crate::inert_regions::mask_inert(source);
697    // Line table over the RAW source. Index-aligned with `str::lines()`, but
698    // additionally carrying each line's absolute base and terminator length
699    // so `outer` can cover the whole physical line.
700    let table = line_table(source);
701    let mask_lines: Vec<&str> = mask.lines().collect();
702    let mut out = Vec::new();
703    let mut i = 0;
704
705    while i < table.len() {
706        let Some(mline) = mask_lines.get(i) else { break };
707        let Some((arity, name, single_line_args)) = parse_shortcode_opener(mline.trim()) else {
708            i += 1;
709            continue;
710        };
711
712        // Where does the opener's attribute block end? Reuse the extractor's
713        // own ladder so a multi-line `{ … }` is measured identically.
714        let (_, opener_lines_consumed) =
715            gather_multi_line_attrs(single_line_args, &mask_lines[i + 1..]);
716        let body_start = i + 1 + opener_lines_consumed;
717
718        // Matching closer at this arity.
719        let mut close = None;
720        for j in body_start..table.len() {
721            if is_close_fence(mask_lines.get(j).map_or("", |l| l.trim()), arity) {
722                close = Some(j);
723                break;
724            }
725        }
726        let Some(j) = close else {
727            // Unclosed: `extract_with_state` emits the block verbatim, so
728            // nothing inside it is live. Descend in place.
729            i += 1;
730            continue;
731        };
732
733        match name {
734            "gallery" => {
735                for k in body_start..j {
736                    if let Some(span) = gallery_body_span(source, &table, k) {
737                        out.push(span);
738                    }
739                }
740                i = j + 1;
741            }
742            "hero" => {
743                super::extract_hero::hero_asset_spans(source, &mask, &table, i, body_start, j, &mut out);
744                i = j + 1;
745            }
746            // Unknown / CssRegion / other typed block: descend in place so a
747            // gallery nested inside it is still found.
748            _ => i += 1,
749        }
750    }
751
752    out
753}
754
755/// One `:::gallery` body line → an [`AssetPathSpan`], or `None`.
756///
757/// The **emission filter** lives here, not in [`gallery_item_span`]: a
758/// gallery body line is only reported as an asset reference when it carried
759/// markdown reference syntax or its bare path has a known media extension
760/// ([`HERO_MEDIA_EXTENSIONS`], the SSOT — extending that list extends gallery
761/// rename tracking). Without it, every line of prose in a gallery body would
762/// show up in the delete-confirmation modal.
763fn gallery_body_span(
764    source: &str,
765    table: &[(usize, usize, usize)],
766    k: usize,
767) -> Option<AssetPathSpan> {
768    let (base, content_len, term_len) = *table.get(k)?;
769    #[allow(clippy::string_slice)]
770    // `base` and `base + content_len` are line boundaries from `line_table`,
771    // which splits on ASCII '\n'/'\r' only.
772    let line = &source[base..base + content_len];
773    let it = gallery_item_span(line)?;
774    if !it.is_token && !super::extract_hero::is_bare_hero_media(&it.path) {
775        return None;
776    }
777    Some(AssetPathSpan {
778        path: crate::media::strip_wikilink(&it.path).to_string(),
779        attrs: it.value_attrs,
780        quote: None,
781        value: base + it.value.start..base + it.value.end,
782        outer: base..base + content_len + term_len,
783        container: PathContainer::GalleryBody,
784    })
785}
786
787/// Recognize one `:::gallery` body line, with LINE-RELATIVE byte offsets.
788///
789/// This is the single grammar for a gallery body line: [`parse_gallery_body`]
790/// is a thin loop over it, and [`shortcode_asset_spans`] lifts its offsets to
791/// absolute. The three arms and their ORDER mirror the historical parser
792/// exactly — `![[…]]` first, then the pipe split, then the markdown-image
793/// pattern — because that ordering is observable (`![alt](x.png|cover)`
794/// splits on the pipe first and therefore is NOT a markdown image).
795///
796/// Returns a span for every non-blank line, prose included; the media filter
797/// lives in [`shortcode_asset_spans`], which is what must not put prose in
798/// the delete-confirmation modal.
799pub(crate) fn gallery_item_span(line: &str) -> Option<MediaLineSpan> {
800    let lead = line.len() - line.trim_start().len();
801    let trimmed = line.trim();
802    if trimmed.is_empty() {
803        return None;
804    }
805
806    // Wikilink embed: ![[path|attrs]] — checked BEFORE the generic pipe
807    // split below, mirroring `hero_media_line_span`'s ordering (the
808    // wikilink's own `|` separates path from attrs and must be split on
809    // the INNER content, not the whole `![[...]]` line).
810    if let Some(inner) = trimmed.strip_prefix("![[").and_then(|s| s.strip_suffix("]]")) {
811        let (src_raw, attrs) = split_pipe(inner);
812        // `![[` is 3 ASCII bytes; the inner text is contiguous.
813        let inner_start = lead + 3;
814        return Some(MediaLineSpan {
815            path: src_raw.trim().to_string(),
816            alt: String::new(),
817            attrs: attrs.to_string(),
818            value: inner_start..inner_start + inner.len(),
819            value_attrs: attrs.to_string(),
820            is_token: true,
821        });
822    }
823
824    // Each line: `path|attrs`, `![alt](path)|attrs`, or bare `path`.
825    // The pipe split (if any) is BEFORE the markdown-image pattern check.
826    let (src_raw, attrs) = split_pipe(trimmed);
827    match parse_markdown_image(src_raw) {
828        Some((alt, path)) => {
829            // The rename span is the destination inside the parens only. A
830            // `|attrs` suffix here sits AFTER the closing paren, outside the
831            // span, so `value_attrs` is empty and the suffix is left alone.
832            let s2 = src_raw.trim();
833            let s2_lead = lead + (src_raw.len() - src_raw.trim_start().len());
834            // `](` is ASCII and `parse_markdown_image` already matched it.
835            let path_start = s2_lead + s2.find("](").map_or(0, |i| i + 2);
836            Some(MediaLineSpan {
837                value: path_start..path_start + path.len(),
838                path,
839                alt,
840                attrs: attrs.to_string(),
841                value_attrs: String::new(),
842                is_token: true,
843            })
844        }
845        None => {
846            let path = src_raw.trim().to_string();
847            let path_start = lead + (src_raw.len() - src_raw.trim_start().len());
848            // With attrs, the span covers `path|attrs` so a rewrite can
849            // rebuild the pair; without, just the path.
850            let value_end = if attrs.is_empty() {
851                path_start + path.len()
852            } else {
853                lead + trimmed.len()
854            };
855            Some(MediaLineSpan {
856                value: path_start..value_end,
857                path,
858                alt: String::new(),
859                attrs: attrs.to_string(),
860                value_attrs: attrs.to_string(),
861                is_token: false,
862            })
863        }
864    }
865}
866
867fn parse_gallery_body(args: &str, body: &str) -> GalleryShortcode {
868    // Args: `N {.classes width}` where N is optional columns count and
869    // `width` is one of the spec § P9 width tokens (handled inside
870    // `split_positional_and_classes`).
871    let (positional, classes, width) = split_positional_classes_and_width(args);
872    let columns = if positional.is_empty() {
873        None
874    } else {
875        positional.parse::<u32>().ok()
876    };
877    let mut items: Vec<GalleryItem> = Vec::new();
878    for line in body.lines() {
879        if let Some(it) = gallery_item_span(line) {
880            items.push(GalleryItem {
881                src: Url::unresolved(it.path),
882                alt: it.alt,
883                attrs: it.attrs,
884            });
885        }
886    }
887    GalleryShortcode {
888        columns,
889        classes,
890        items,
891        width,
892    }
893}
894
895/// Split `args` into `(positional_text, classes, width)`.
896///
897/// Same routing as [`split_positional_and_classes`], but also surfaces the
898/// spec § P9 width token (`body | wide | page | screen`, with `full`
899/// aliased to `screen`). Returns `width = None` when the author did not
900/// set one, or when the legacy fallback path fires (malformed attrs
901/// where the structured parser bailed).
902fn split_positional_classes_and_width(args: &str) -> (String, String, Option<String>) {
903    let trimmed = args.trim();
904    if let Some(brace_start) = trimmed.find('{') {
905        #[allow(clippy::string_slice)]
906        let after_open = &trimmed[brace_start..];
907        if let Some(brace_end) = after_open.find('}') {
908            #[allow(clippy::string_slice)]
909            let positional = trimmed[..brace_start].trim().to_string();
910            #[allow(clippy::string_slice)]
911            let attr_block_str = &trimmed[brace_start..=brace_start + brace_end];
912            if let Ok(parsed) = super::attrs::parse_attrs(attr_block_str) {
913                return (
914                    positional,
915                    parsed.class_string(),
916                    parsed.width.map(str::to_string),
917                );
918            }
919            // Legacy fallback for malformed inputs: scan only for `.class`.
920            // Width tokens are skipped here on purpose — if attrs are
921            // malformed enough to bail, the author's intent is unclear and
922            // omitting the width is safer than guessing.
923            #[allow(clippy::string_slice)]
924            let inner = &trimmed[brace_start + 1..brace_start + brace_end];
925            let mut classes = Vec::new();
926            for token in inner.split_whitespace() {
927                if let Some(class) = token.strip_prefix('.') {
928                    if !class.is_empty() {
929                        classes.push(class);
930                    }
931                }
932            }
933            return (positional, classes.join(" "), None);
934        }
935    }
936    (trimmed.to_string(), String::new(), None)
937}
938
939/// Split `args` into `(positional_text, classes)` from `{...}` syntax.
940///
941/// Routes the attribute portion through [`crate::ast::attrs::parse_attrs`]
942/// so the unified grammar's full surface (`.class`, `#id`, `key=value`,
943/// quoted values, multi-line) is recognized — even though the legacy
944/// shortcodes (Subscribe / Buttons / Gallery) only consume the class
945/// list today. Step 2 migrates Hero / Grid; once they read `kvs` and
946/// `id` via `parse_attrs` directly, this helper retires.
947///
948/// Falls back to the legacy whitespace-tokenized class scan when
949/// `parse_attrs` returns `Err` (malformed attrs, unterminated quote,
950/// etc.) so existing content with edge-case `{}` shapes still parses
951/// the way it did before.
952fn split_positional_and_classes(args: &str) -> (String, String) {
953    let trimmed = args.trim();
954    if let Some(brace_start) = trimmed.find('{') {
955        // char-aligned: brace_start points to ASCII '{' from str::find — the
956        // byte index is a char boundary, so slicing `trimmed[brace_start..]`
957        // is safe to feed into the next find.
958        #[allow(clippy::string_slice)]
959        let after_open = &trimmed[brace_start..];
960        if let Some(brace_end) = after_open.find('}') {
961            // char-aligned: brace_start (ASCII '{') and brace_start+brace_end
962            // (ASCII '}') are both char boundaries; `brace_start + 1` lands on
963            // the byte after '{', also a boundary.
964            #[allow(clippy::string_slice)]
965            let positional = trimmed[..brace_start].trim().to_string();
966            #[allow(clippy::string_slice)]
967            let attr_block_str = &trimmed[brace_start..=brace_start + brace_end];
968            if let Ok(parsed) = super::attrs::parse_attrs(attr_block_str) {
969                return (positional, parsed.class_string());
970            }
971            // Legacy fallback for malformed inputs that the structured
972            // parser rejects (e.g. unterminated quote on a single line).
973            #[allow(clippy::string_slice)]
974            let inner = &trimmed[brace_start + 1..brace_start + brace_end];
975            let mut classes = Vec::new();
976            for token in inner.split_whitespace() {
977                if let Some(class) = token.strip_prefix('.') {
978                    if !class.is_empty() {
979                        classes.push(class);
980                    }
981                }
982            }
983            return (positional, classes.join(" "));
984        }
985    }
986    (trimmed.to_string(), String::new())
987}
988
989/// Split `s` on `|` into `(before, after)`. If no pipe, returns `(s, "")`.
990fn split_pipe(s: &str) -> (&str, &str) {
991    match s.split_once('|') {
992        Some((before, after)) => (before, after.trim()),
993        None => (s, ""),
994    }
995}
996
997/// Parse `![alt](path)` into `(alt, path)`. Returns `None` if not a
998/// markdown image. Mirrors the legacy parser at shortcode.rs:1615.
999fn parse_markdown_image(s: &str) -> Option<(String, String)> {
1000    let s = s.trim();
1001    let rest = s.strip_prefix("![")?;
1002    let (alt, after) = rest.split_once("](")?;
1003    let close_paren = after.rfind(')')?;
1004    // char-aligned: close_paren points to ASCII ')' from str::rfind.
1005    #[allow(clippy::string_slice)]
1006    let path = &after[..close_paren];
1007    if path.contains('(') {
1008        return None;
1009    }
1010    Some((alt.to_string(), path.to_string()))
1011}
1012
1013fn parse_buttons_body(args: &str, body: &str) -> ButtonsShortcode {
1014    let (_positional, classes) = split_positional_and_classes(args);
1015    let mut items: Vec<ButtonItem> = Vec::new();
1016    // Split the body on `+++` cell dividers (unified grammar).
1017    // Bodies without `+++` produce a single cell containing the entire
1018    // body — backward-compatible with the legacy "one link per line"
1019    // shape.
1020    for cell in split_cells(body) {
1021        for line in cell.lines() {
1022            let trimmed = line.trim();
1023            if trimmed.is_empty() {
1024                continue;
1025            }
1026            if let Some((text, url)) = extract_markdown_link(trimmed) {
1027                items.push(ButtonItem {
1028                    text,
1029                    url: Url::unresolved(url),
1030                });
1031            }
1032            // Non-link lines silently ignored (matches legacy behavior).
1033        }
1034    }
1035    ButtonsShortcode { classes, items }
1036}
1037
1038/// Extract a markdown link `[text](url)` from a single trimmed line.
1039/// Returns `(text, url)` if the line is a single link, else `None`.
1040fn extract_markdown_link(s: &str) -> Option<(String, String)> {
1041    let s = s.trim();
1042    let inside = s.strip_prefix('[')?;
1043    let (text, after) = inside.split_once(']')?;
1044    let url = after.strip_prefix('(').and_then(|r| r.strip_suffix(')'))?;
1045    if url.is_empty() {
1046        return None;
1047    }
1048    Some((text.to_string(), url.to_string()))
1049}
1050
1051/// Parse `:::subscribe {placeholder="..." button="..."}` into a typed struct.
1052///
1053/// Reads `placeholder` and `button` from the attribute block; ignores
1054/// classes/id (the renderer uses fixed `moss-subscribe` chrome). Body
1055/// must be empty under the unified grammar — caller is responsible for
1056/// surfacing a deprecation warning if non-empty.
1057fn parse_subscribe_args(args: &str) -> SubscribeShortcode {
1058    // Empty args produce an empty AttrBlock; both fields stay None
1059    // and the renderer falls back to language defaults.
1060    let parsed = match super::attrs::parse_attrs(args) {
1061        Ok(b) => b,
1062        Err(_) => return SubscribeShortcode::default(),
1063    };
1064    let placeholder = parsed
1065        .get("placeholder")
1066        .filter(|s| !s.is_empty())
1067        .map(str::to_string);
1068    let button = parsed
1069        .get("button")
1070        .filter(|s| !s.is_empty())
1071        .map(str::to_string);
1072    SubscribeShortcode {
1073        placeholder,
1074        button,
1075    }
1076}
1077
1078/// Parse `:::apply {placeholder="..." button="..."}` into a typed struct.
1079///
1080/// Reads `placeholder` and `button` from the attribute block; ignores
1081/// classes/id (the renderer uses fixed `moss-apply` chrome). Body must be
1082/// empty under the unified grammar. Mirrors `parse_subscribe_args`.
1083pub fn parse_apply_args(args: &str) -> ApplyShortcode {
1084    let parsed = match super::attrs::parse_attrs(args) {
1085        Ok(b) => b,
1086        Err(_) => return ApplyShortcode::default(),
1087    };
1088    let placeholder = parsed
1089        .get("placeholder")
1090        .filter(|s| !s.is_empty())
1091        .map(str::to_string);
1092    let button = parsed
1093        .get("button")
1094        .filter(|s| !s.is_empty())
1095        .map(str::to_string);
1096    ApplyShortcode {
1097        placeholder,
1098        button,
1099    }
1100}
1101
1102/// The sentinel HTML comment used to mark an extracted shortcode in the
1103/// markdown source. Pulldown-cmark emits these as [`Event::Html`] inside
1104/// a [`Tag::HtmlBlock`], which surfaces as [`Block::Other`] in our AST.
1105///
1106/// `nonce` is the per-extraction hash from [`ExtractionResult::nonce`],
1107/// which forecloses the namespace-collision case where an author writes
1108/// `<!--MOSS_SC_*-->` literally in their markdown.
1109pub fn placeholder_for(nonce: &str, index: usize) -> String {
1110    format!("<!--MOSS_SC_{nonce}_{index}-->")
1111}
1112
1113/// Try to interpret a [`Block::Other`] payload as a shortcode placeholder
1114/// matching the given `nonce`. Returns the `index` if it matches.
1115///
1116/// Any sentinel with a different (or absent) nonce is rejected — that's
1117/// what makes authored content with a similar comment shape inert.
1118pub fn parse_placeholder(nonce: &str, html: &str) -> Option<usize> {
1119    let trim = html.trim();
1120    let prefix = format!("<!--MOSS_SC_{nonce}_");
1121    let inner = trim.strip_prefix(&prefix)?;
1122    let inner = inner.strip_suffix("-->")?;
1123    inner.parse::<usize>().ok()
1124}
1125
1126/// Compute the per-extraction nonce from the input markdown. Uses
1127/// `std::hash::DefaultHasher` (FxHash-like; not cryptographic, but good
1128/// enough to make a literal authored-content collision computationally
1129/// improbable for any short input). Returns 8 hex characters.
1130fn compute_nonce(input: &str) -> String {
1131    use std::hash::{Hash, Hasher};
1132    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1133    input.hash(&mut hasher);
1134    // Truncate to 32 bits for an 8-char hex; collisions across two
1135    // sites are not a concern (each extraction uses its own nonce
1136    // for its own substitution). Per-extraction collision-resistance
1137    // requires only that the nonce differs from any literal string
1138    // in the same input — 32 bits is overkill for that.
1139    let h = hasher.finish() as u32;
1140    format!("{h:08x}")
1141}
1142
1143/// Walk the markdown line-by-line, replace `:::name` blocks with sentinels.
1144///
1145/// Tracks fenced code blocks (` ``` ` and `~~~`) so `:::buttons` inside a
1146/// code fence stays inert. Currently recognizes `:::subscribe`; other
1147/// shortcodes are added in Phase B Tasks 8-11. Unrecognized `:::name`
1148/// blocks pass through verbatim (the legacy string-rewriter still
1149/// processes them during the staged migration).
1150pub fn extract_shortcodes(markdown: &str) -> ExtractionResult {
1151    extract_shortcodes_with_config(markdown, &ParseConfig::default())
1152}
1153
1154/// [`extract_shortcodes`], parsing shortcode bodies with the caller's
1155/// [`ParseConfig`].
1156///
1157/// Shortcode inner content is sub-parsed, so a config that stops here does
1158/// not stop being observable — it produces a page written in two dialects.
1159/// With math on, `$E=mc^2$` one line outside a `:::hero` became an
1160/// equation while the identical bytes inside it stayed literal text,
1161/// because every sub-parse called the default-config `parse()`.
1162/// `shortcode_config_leak_invariant` pins the bare call out of existence.
1163pub fn extract_shortcodes_with_config(
1164    markdown: &str,
1165    config: &ParseConfig,
1166) -> ExtractionResult {
1167    let nonce = compute_nonce(markdown);
1168    let mut extracted: Vec<ExtractedShortcode> = Vec::new();
1169    let mut warnings: Vec<String> = Vec::new();
1170    let output = extract_with_state(markdown, &nonce, &mut extracted, &mut warnings, config);
1171    ExtractionResult {
1172        markdown_with_placeholders: output,
1173        extracted,
1174        nonce,
1175        warnings,
1176    }
1177}
1178
1179/// Recursive worker for [`extract_shortcodes`]. Walks `markdown`
1180/// line-by-line and returns the body string with sentinels substituted
1181/// for typed shortcode blocks. Inner CssRegion / Unknown blocks recurse
1182/// here so their bodies also get scanned for typed shortcodes — the
1183/// shared `extracted` and `warnings` accumulators ensure all sentinels
1184/// across nesting levels share the same nonce and a flat index space.
1185fn extract_with_state(
1186    markdown: &str,
1187    nonce: &str,
1188    extracted: &mut Vec<ExtractedShortcode>,
1189    warnings: &mut Vec<String>,
1190    config: &ParseConfig,
1191) -> String {
1192    let mut output = String::with_capacity(markdown.len());
1193    let lines: Vec<&str> = markdown.lines().collect();
1194    // Which lines are code or comment, and therefore carry no live `:::`
1195    // syntax. One shared scanner for every pre-parse pass in the tree —
1196    // this module used to track fenced code itself and knew nothing about
1197    // HTML comments, which is how a `:::gallery` inside an authored
1198    // `<!-- TODO … -->` block got extracted, spliced a sentinel into the
1199    // middle of the comment, and deleted the rest of the page (#903 bug 2).
1200    let inert = crate::inert_regions::inert_lines(markdown);
1201    let is_inert = |idx: usize| inert.get(idx).copied().unwrap_or(false);
1202    let mut i = 0;
1203
1204    while i < lines.len() {
1205        let line = lines[i];
1206        let trimmed = line.trim();
1207
1208        // Inert line: emit verbatim, recognize nothing.
1209        if is_inert(i) {
1210            output.push_str(line);
1211            output.push('\n');
1212            i += 1;
1213            continue;
1214        }
1215
1216        // Try to recognize a `:::name` (or `::::name`, etc.) opener.
1217        if let Some((arity, name, single_line_args)) = parse_shortcode_opener(trimmed) {
1218            // Multi-line attribute block support: if the args contain an
1219            // unclosed `{`, gather subsequent lines into the args string
1220            // until the brace closes (respecting quoted strings). The
1221            // body starts on the line AFTER the close-brace line.
1222            //
1223            // `:::name {key=value\n  key2=value2\n}` is valid; the
1224            // attribute parser sees the joined string and treats newlines
1225            // as whitespace.
1226            let (args_owned, opener_lines_consumed) =
1227                gather_multi_line_attrs(single_line_args, &lines[i + 1..]);
1228            let args: &str = args_owned.as_deref().unwrap_or(single_line_args);
1229            let body_start = i + 1 + opener_lines_consumed;
1230
1231            // Look for the matching closer (same arity) on a subsequent line.
1232            //
1233            // While scanning, remember the first body line that is itself an
1234            // OPENER at the same arity. First-closer-wins means the closer we
1235            // are about to accept is that inner block's, not ours — see
1236            // `nested_same_arity` below.
1237            let mut body_lines: Vec<&str> = Vec::new();
1238            let mut j = body_start;
1239            let mut closed = false;
1240            let mut nested_same_arity: Option<&str> = None;
1241            while j < lines.len() {
1242                // An inert line cannot close the block either: a bare `:::`
1243                // inside a code fence or a comment in the body used to end
1244                // the shortcode early and strand the rest of it as prose.
1245                let body_trimmed = lines[j].trim();
1246                if !is_inert(j) && is_close_fence(body_trimmed, arity) {
1247                    closed = true;
1248                    break;
1249                }
1250                if nested_same_arity.is_none() && !is_inert(j) {
1251                    if let Some((inner_arity, _, _)) = parse_shortcode_opener(body_trimmed) {
1252                        if inner_arity == arity {
1253                            nested_same_arity = Some(body_trimmed);
1254                        }
1255                    }
1256                }
1257                body_lines.push(lines[j]);
1258                j += 1;
1259            }
1260
1261            if !closed {
1262                // Unclosed block: emit verbatim, let the legacy rewriter
1263                // surface the syntax error.
1264                output.push_str(line);
1265                output.push('\n');
1266                i += 1;
1267                continue;
1268            }
1269
1270            // The block closed — but if a same-arity opener sat in the body,
1271            // the fence we just stopped on belongs to THAT block, and this
1272            // one ended early. Nothing about the parse changes; the author
1273            // just gets told, because the page still builds and only looks
1274            // wrong (stray `+++`, cells outside the grid). See #1014.
1275            if let Some(inner) = nested_same_arity {
1276                warnings.push(nested_arity_warning(arity, trimmed, inner));
1277            }
1278
1279            let body = body_lines.join("\n");
1280
1281            // Branch on the recognized name:
1282            //
1283            // 1. Pure-CSS region (empty name, e.g. `:::{.tagline}`) — emit
1284            //    a plain `<div class="...">` wrapper around the body markdown.
1285            //    Pulldown-cmark processes the body naturally because we
1286            //    insert blank lines around it.
1287            //
1288            // 2. Typed-known name (subscribe / buttons / gallery / hero / grid)
1289            //    — extract into the typed AST and substitute a sentinel.
1290            //    Parse-time deprecation warnings (e.g. legacy `---` dividers
1291            //    in grid, body-image fallback in hero) are threaded back via
1292            //    the warnings vector.
1293            //
1294            // 3. Anything else — render as a `moss-unknown-shortcode` div
1295            //    around the body markdown and emit a build warning.
1296            if name.is_empty() {
1297                // CssRegion (Task D). Recurse into the body so typed
1298                // shortcodes nested inside the styling wrapper (the
1299                // common SoCiviC pattern of `:::{.support-band}` around
1300                // `::::buttons`) also get extracted into sentinels.
1301                // Higher-arity inner blocks survive because the outer
1302                // closer-search only matches the outer's exact arity;
1303                // the recursive call then handles the inner.
1304                let parsed = super::attrs::parse_attrs(args).unwrap_or_default();
1305                let body_processed = extract_with_state(&body, nonce, extracted, warnings, config);
1306                output.push_str(&render_div_open(&parsed.classes, parsed.id.as_deref(), None));
1307                output.push_str("\n\n");
1308                output.push_str(&body_processed);
1309                if !body_processed.is_empty() && !body_processed.ends_with('\n') {
1310                    output.push('\n');
1311                }
1312                output.push_str("\n</div>\n");
1313                i = j + 1;
1314                continue;
1315            }
1316
1317            if is_typed_known(name) {
1318                if let (Some(sc), parse_warnings) = parse_shortcode_block(name, args, &body, config) {
1319                    warnings.extend(parse_warnings);
1320                    let index = extracted.len();
1321                    output.push_str(&placeholder_for(&nonce, index));
1322                    output.push('\n');
1323                    // Preserve the block's original line count. The block spanned
1324                    // lines i..=j (opener..closer); the sentinel is a single line,
1325                    // so pad with (j - i) blank lines. This keeps the post-
1326                    // extraction LineLookup (parser.rs) line-accurate: without it,
1327                    // a multi-line shortcode (grid/hero) collapses to one line and
1328                    // every data-source-line AFTER it drifts, breaking editor↔
1329                    // preview scroll sync (the home page grid scrolled the preview
1330                    // to the bottom). Trailing blank lines after the sentinel HTML
1331                    // comment produce no pulldown-cmark events, so the AST is
1332                    // unchanged. See docs/reference/editor-preview-sync.md.
1333                    for _ in 0..(j - i) {
1334                        output.push('\n');
1335                    }
1336                    extracted.push(ExtractedShortcode {
1337                        index,
1338                        shortcode: sc,
1339                    });
1340                    i = j + 1;
1341                    continue;
1342                }
1343                // Should not happen — typed-known is a closed set of
1344                // names handled by parse_shortcode_block. Fall through
1345                // to verbatim emission as defense-in-depth.
1346                output.push_str(line);
1347                output.push('\n');
1348                i += 1;
1349                continue;
1350            }
1351
1352            // Unknown name (Task E): wrap the body in a fallback div and
1353            // emit a build warning so authors see misspellings. Recurse
1354            // into the body so a misspelled outer doesn't strand any
1355            // valid typed shortcodes nested inside it.
1356            let parsed = super::attrs::parse_attrs(args).unwrap_or_default();
1357            warnings.push(format!("unknown shortcode `:::{}`", name));
1358            let mut classes = vec!["moss-unknown-shortcode".to_string()];
1359            classes.extend(parsed.classes.iter().cloned());
1360            let extra_attrs = format!(r#" data-name="{}""#, html_escape_attr(name));
1361            let body_processed = extract_with_state(&body, nonce, extracted, warnings, config);
1362            output.push_str(&render_div_open(&classes, parsed.id.as_deref(), Some(&extra_attrs)));
1363            output.push_str("\n\n");
1364            output.push_str(&body_processed);
1365            if !body_processed.is_empty() && !body_processed.ends_with('\n') {
1366                output.push('\n');
1367            }
1368            output.push_str("\n</div>\n");
1369            i = j + 1;
1370            continue;
1371        }
1372
1373        // Regular content line.
1374        output.push_str(line);
1375        output.push('\n');
1376        i += 1;
1377    }
1378
1379    output
1380}
1381
1382/// Word the "this block ended at someone else's fence" warning (#1014).
1383///
1384/// `arity` and `outer_line` describe the OUTER opener; `inner_line` is the
1385/// trimmed text of the nested opener that has the same colon count. Says what
1386/// went wrong in plain words first, then exactly what to type: one more colon
1387/// on the outer fence, nested fences left alone.
1388///
1389/// The suggestion is built by prefixing one `:` to the author's own opener
1390/// line, so it reads back in their words (`:::grid 2` → `::::grid 2`) and
1391/// works for a nameless CSS region (`:::{.band}` → `::::{.band}`) too.
1392fn nested_arity_warning(arity: usize, outer_line: &str, inner_line: &str) -> String {
1393    let colons = ":".repeat(arity);
1394    let wider = ":".repeat(arity + 1);
1395    let outer_short = clip_for_warning(outer_line);
1396    let inner_short = clip_for_warning(inner_line);
1397    format!(
1398        "shortcode `{outer_short}` ends at the nested `{inner_short}` block's closing fence \
1399         instead of its own, so everything after that point falls outside the block.\n\
1400         A nested fence needs FEWER colons than the block around it: write `:{outer_short}` … \
1401         `{wider}` for the outer block and leave the nested one as `{colons}`."
1402    )
1403}
1404
1405/// Opener lines are short; cap anyway so a pathological one can't flood the
1406/// build log.
1407fn clip_for_warning(line: &str) -> String {
1408    let mut s: String = line.chars().take(48).collect();
1409    if line.chars().count() > 48 {
1410        s.push('…');
1411    }
1412    s
1413}
1414
1415/// Render the opening `<div>` tag for a CssRegion or Unknown wrapper.
1416///
1417/// `extra_attrs` (already with leading space) is appended before `>`,
1418/// used by the unknown-name renderer to add `data-name="..."`.
1419fn render_div_open(classes: &[String], id: Option<&str>, extra_attrs: Option<&str>) -> String {
1420    let mut out = String::from("<div");
1421    if !classes.is_empty() {
1422        out.push_str(" class=\"");
1423        for (i, c) in classes.iter().enumerate() {
1424            if i > 0 {
1425                out.push(' ');
1426            }
1427            out.push_str(&html_escape_attr(c));
1428        }
1429        out.push('"');
1430    }
1431    if let Some(id_val) = id {
1432        out.push_str(" id=\"");
1433        out.push_str(&html_escape_attr(id_val));
1434        out.push('"');
1435    }
1436    if let Some(extra) = extra_attrs {
1437        out.push_str(extra);
1438    }
1439    out.push('>');
1440    out
1441}
1442
1443/// HTML-attribute-safe escape. Replaces the five XML special characters
1444/// so attribute values can't break out of `"..."` or close the tag.
1445fn html_escape_attr(s: &str) -> String {
1446    let mut out = String::with_capacity(s.len());
1447    for c in s.chars() {
1448        match c {
1449            '&' => out.push_str("&amp;"),
1450            '<' => out.push_str("&lt;"),
1451            '>' => out.push_str("&gt;"),
1452            '"' => out.push_str("&quot;"),
1453            '\'' => out.push_str("&#39;"),
1454            _ => out.push(c),
1455        }
1456    }
1457    out
1458}
1459
1460/// Parse an opening fence line into (colon_count, name, args). Returns
1461/// `None` if the line is not an opener.
1462///
1463/// Accepts any colon count >= 3 (`:::name`, `::::name`, `:::::name`, ...).
1464/// The colon count is preserved so the closer must match the same arity
1465/// (allows nested shortcodes like `::::buttons` inside `:::grid`).
1466///
1467/// **Pure-CSS region opener** — `:::{.class}` (no name, attrs only) is
1468/// also recognized. The returned `name` is empty, signaling the caller
1469/// to render the block as a plain styling wrapper. Empty name without
1470/// a following `{` is rejected (just colons followed by content is not
1471/// an opener).
1472pub(crate) fn parse_shortcode_opener(trimmed: &str) -> Option<(usize, &str, &str)> {
1473    let colons = trimmed.chars().take_while(|&c| c == ':').count();
1474    if colons < 3 {
1475        return None;
1476    }
1477    // char-aligned: `colons` is a count of ASCII ':' chars (each 1 byte in
1478    // UTF-8), so the byte offset equals the char count and lands on a
1479    // char boundary.
1480    #[allow(clippy::string_slice)]
1481    let rest = &trimmed[colons..];
1482    // Name = letters/digits/underscores/hyphens; rest of line is args.
1483    let name_end = rest
1484        .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '-'))
1485        .unwrap_or(rest.len());
1486    if name_end == 0 {
1487        // No name. Pure-CSS region grammar requires the rest to start
1488        // with `{` (after whitespace).
1489        let after_ws = rest.trim_start();
1490        if !after_ws.starts_with('{') {
1491            return None;
1492        }
1493        return Some((colons, "", rest.trim()));
1494    }
1495    // char-aligned: name_end is a byte index returned by str::find with a
1496    // char predicate, which is guaranteed to be a char boundary (or rest.len()).
1497    #[allow(clippy::string_slice)]
1498    let name = &rest[..name_end];
1499    #[allow(clippy::string_slice)]
1500    let args = rest[name_end..].trim();
1501    Some((colons, name, args))
1502}
1503
1504/// True if `trimmed` is a closing fence with the specified arity (`:::`
1505/// for arity 3, `::::` for arity 4, etc.).
1506///
1507/// Closer semantics: N colons followed by optional whitespace only. A
1508/// line like `::: extra` is NOT a closer (it's body content). This was
1509/// the legacy `parse_fence_close` contract; the typed extractor preserves
1510/// it so author content with trailing text after `:::` still parses the
1511/// same way.
1512///
1513/// Implemented via char iteration (NOT `split_at(arity)`) because the
1514/// `arity` is a count of `:` characters (always ASCII, 1 byte each), but
1515/// the `trimmed` line might start with multi-byte UTF-8 characters
1516/// (e.g. `[申请测试版](...)` from Chinese-language buttons). `split_at`
1517/// is byte-indexed and would panic mid-character on such lines. Char
1518/// iteration sidesteps the issue and is also slightly faster — we early-exit
1519/// on the first non-`:` character.
1520fn is_close_fence(trimmed: &str, arity: usize) -> bool {
1521    let mut chars = trimmed.chars();
1522    for _ in 0..arity {
1523        match chars.next() {
1524            Some(':') => {}
1525            _ => return false,
1526        }
1527    }
1528    // Remaining chars (if any) must all be whitespace.
1529    chars.all(char::is_whitespace)
1530}
1531
1532#[cfg(test)]
1533#[path = "shortcode_extract_tests.rs"]
1534mod tests;