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 (``) is deliberately excluded:
390/// pulldown-cmark parses `` fine on its own, so
391/// `[](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/// (``) 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 `` 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 (``
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 // `|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 `` 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("?;
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("&"),
1450 '<' => out.push_str("<"),
1451 '>' => out.push_str(">"),
1452 '"' => out.push_str("""),
1453 '\'' => out.push_str("'"),
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;