Skip to main content

moss_core/ast/
shortcode.rs

1//! Typed shortcode AST nodes.
2//!
3//! Each shortcode is a closed enum variant with fully-typed arguments.
4//! Variants land per-shortcode in Phase B (one variant per migration
5//! commit) of the typed-AST migration.
6//!
7//! Migration order (Phase B): Subscribe, Buttons, Gallery, Hero, Grid, Recent.
8
9use serde::{Deserialize, Serialize};
10
11use super::node::Block;
12use super::url::Url;
13
14/// A typed shortcode block.
15///
16/// Variants:
17/// - [`Shortcode::Subscribe`] — inline subscribe form (description + button)
18/// - [`Shortcode::Buttons`] — list of action buttons with markdown links
19/// - [`Shortcode::Gallery`] — image gallery with optional column count
20/// - [`Shortcode::Hero`] — full-width hero section with media + overlay
21/// - [`Shortcode::Grid`] — flexible multi-cell layout
22/// - [`Shortcode::Recent`] — recent-posts query with fallback markdown
23///
24/// Phase B migrations add one variant per commit.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case", tag = "kind")]
27pub enum Shortcode {
28    /// `:::subscribe` — inline newsletter signup form.
29    ///
30    /// Configuration is via attributes (`placeholder`, `button`); body
31    /// must be empty under the unified grammar. Description text and
32    /// any framing prose live in the surrounding markdown.
33    Subscribe(SubscribeShortcode),
34    /// `:::buttons {.classname}` — list of action buttons.
35    ///
36    /// Body is one markdown link per line (`[text](url)`). The first
37    /// button gets the primary class; subsequent buttons get secondary.
38    /// Optional `{.classname}` extra classes attach to the wrapping div.
39    ///
40    /// URLs flow through [`Url::Unresolved`] at parse time;
41    /// [`crate::ast::visit::visit_urls_mut`] (or src-tauri's
42    /// `apply_typed_shortcodes`) classifies them into [`Url::Resolved`]
43    /// before rendering. The resolver-bypass class is closed by
44    /// construction: `RenderHooks::render_shortcode` reads `Url::Resolved`,
45    /// so a missing visitor is a debug-time crash.
46    Buttons(ButtonsShortcode),
47    /// `:::gallery N {.classname}` — image gallery with optional columns.
48    ///
49    /// `N` (positional integer) sets `--gallery-columns` CSS variable.
50    /// Body is one image reference per line: `![alt](path)`, bare
51    /// `path.jpg`, or `path|attrs` for media attributes (passed through
52    /// to the renderer's inline style).
53    Gallery(GalleryShortcode),
54    /// `:::hero {image=path}` — full-width hero section with media + overlay.
55    ///
56    /// New grammar: `image` attribute carries the path. Backward-compat:
57    /// when `image` is absent, the extractor scans the first non-empty
58    /// body line for a media reference (`![[path]]`, `![alt](path)`, or
59    /// bare media filename).
60    ///
61    /// The pipeline hoists the rendered hero HTML into the article
62    /// template's hero slot — it does NOT render inline.
63    Hero(HeroShortcode),
64    /// `:::grid {cols=N}` or `:::grid N` — flexible multi-cell layout.
65    ///
66    /// Cells are split on `+++` (new grammar) or `---` (legacy moss-releases
67    /// backward-compat — Step 3 of #613 rewrites these to `+++`). Each cell
68    /// stores its raw markdown source; the renderer is responsible for any
69    /// nested-shortcode extraction and markdown processing per cell.
70    Grid(GridShortcode),
71    /// `:::recent since=... last=... count=...` — list of recent posts
72    /// scoped to the page's top-level folder (its scope).
73    ///
74    /// Body (between the opening and closing `:::`) is reserved for
75    /// fallback content rendered when the query returns zero matches.
76    /// Empty body means no fallback (the shortcode renders nothing).
77    Recent(RecentShortcode),
78}
79
80/// Arguments for [`Shortcode::Subscribe`].
81#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
82pub struct SubscribeShortcode {
83    /// Optional override for the email input's placeholder text.
84    pub placeholder: Option<String>,
85    /// Optional override for the submit button label.
86    pub button: Option<String>,
87}
88
89/// Arguments for [`Shortcode::Buttons`].
90#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
91pub struct ButtonsShortcode {
92    /// Extra CSS classes for the wrapping `<div>` (from `{.foo .bar}`).
93    pub classes: String,
94    /// Each button's text + URL. The first item renders as primary, the
95    /// rest as secondary. Empty list = the shortcode renders nothing.
96    pub items: Vec<ButtonItem>,
97}
98
99/// One button in a [`ButtonsShortcode`].
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct ButtonItem {
102    /// Display text inside the `<a>` tag.
103    pub text: String,
104    /// Click target. Author input as parsed; flows through
105    /// [`crate::ast::visit::visit_urls_mut`] before reaching the renderer.
106    pub url: Url,
107}
108
109/// Arguments for [`Shortcode::Gallery`].
110#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
111pub struct GalleryShortcode {
112    /// Optional column count for `--gallery-columns` CSS variable.
113    pub columns: Option<u32>,
114    /// Extra CSS classes for the wrapping `<div>` (from `{.foo .bar}`).
115    pub classes: String,
116    /// Each gallery image's src + alt + media attrs.
117    pub items: Vec<GalleryItem>,
118    /// Spec § P9 width attribute: `body | wide | page | screen` (with
119    /// `full` aliased to `screen`). `None` means the author did not set
120    /// a width — the emitter omits `data-width` so the HTML stays sparse.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub width: Option<String>,
123}
124
125/// One image in a [`GalleryShortcode`].
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct GalleryItem {
128    /// Image source URL. Flows through resolver before rendering.
129    pub src: Url,
130    /// Alt text (from `![alt](...)` syntax). Empty if author used bare path.
131    pub alt: String,
132    /// Pipe-suffix media attributes verbatim (e.g. "cover top",
133    /// "1.5:1 contain"). Empty if no pipe in the source.
134    /// The renderer parses this via `moss_core::media::parse_media_attrs`.
135    pub attrs: String,
136}
137
138/// Arguments for [`Shortcode::Grid`].
139#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
140pub struct GridShortcode {
141    /// Column count. Defaults to 1 when neither positional nor `cols=`
142    /// attribute is provided.
143    pub columns: u32,
144    /// Optional ratio string like `"1:2"` or `"1:1:2"`. When present,
145    /// the renderer emits `style="grid-template-columns:1fr 2fr"` etc.
146    /// `cols=1:2:3` is equivalent to setting both `columns` (count = 3)
147    /// and `ratio` to `"1:2:3"`.
148    pub ratio: Option<String>,
149    /// Extra CSS classes for the wrapping `<div>` (from `{.foo .bar}`).
150    pub classes: String,
151    /// Each cell's parsed block content. Phase 4 PR4.5 (2026-05-28)
152    /// promoted this from `Vec<String>` (raw markdown source) to
153    /// `Vec<Vec<Block>>` (fully-typed AST). Nested shortcodes inside a
154    /// cell (`::::buttons` in `:::grid`) extract through
155    /// [`crate::ast::parser::parse`] recursion in
156    /// [`crate::ast::shortcode_extract::parse_grid`].
157    ///
158    /// Compound-link cells (the SoCiviC `[![[poster]] ### Title ...](/url)`
159    /// pattern, where the entire cell is wrapped in a markdown link that
160    /// spans block-level inner content) are represented as a single-element
161    /// `vec![Block::LinkCard { url, children }]`. See
162    /// [`Block::LinkCard`](crate::ast::Block::LinkCard) for the rationale.
163    pub cells: Vec<Vec<Block>>,
164    /// Spec § P9 width attribute: `body | wide | page | screen` (with
165    /// `full` aliased to `screen`). `None` means the author did not set
166    /// a width — the emitter omits `data-width` so the HTML stays sparse.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub width: Option<String>,
169}
170
171/// Arguments for [`Shortcode::Hero`].
172#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
173pub struct HeroShortcode {
174    /// Image source URL. `None` if neither the `image` attribute nor the
175    /// first body line provided one — the renderer emits a section with
176    /// no `<img>` in that case. Flows through resolver before rendering.
177    pub image: Option<Url>,
178    /// Pipe-suffix media attributes verbatim (e.g. "cover top",
179    /// "1.5:1 contain"). Empty if no pipe in the source.
180    pub attrs: String,
181    /// Extra CSS classes for the wrapping `<section>` (from `{.foo .bar}`).
182    pub classes: String,
183    /// Parsed block content for the overlay. Phase 4 PR4.5 (2026-05-28)
184    /// promoted this from `overlay_markdown: String` to `overlay: Vec<Block>`
185    /// (fully-typed AST). Nested shortcodes inside the overlay
186    /// (`::::buttons` inside `:::hero`) extract through
187    /// [`crate::ast::parser::parse`] recursion in
188    /// [`crate::ast::shortcode_extract::parse_hero`].
189    pub overlay: Vec<Block>,
190    /// Plain-text overlay source for downstream OG-fallback extraction.
191    ///
192    /// PR4.5 (2026-05-28): captured at parse time alongside the typed
193    /// `overlay` because `crate::build::page::meta::extract_description`
194    /// (the homepage-hero rung in the description chain) operates on
195    /// markdown source — round-tripping `Vec<Block>` to markdown would
196    /// invite drift. The renderer uses `overlay` for HTML; downstream
197    /// consumers read `overlay_text` for description-chain extraction.
198    ///
199    /// Empty when the author wrote no overlay body.
200    ///
201    /// TODO(phase4-cleanup): replace with a Vec<Block>-walking
202    /// `to_plain_text(blocks: &[Block]) -> String` helper in moss-core
203    /// + consume `overlay` directly in `meta.rs::extract_description`,
204    /// deleting this field. Carrying both `overlay: Vec<Block>` AND
205    /// `overlay_text: String` makes the AST non-canonical (which is the
206    /// source of truth?); per cross-SSG research, lossy or duplicate
207    /// state is Gatsby's mistake. This is transitional — flag if it
208    /// survives past PR7a. (Architecture review caveat 2026-05-28.)
209    pub overlay_text: String,
210    /// Spec § P9 width attribute: `body | wide | page | screen` (with
211    /// `full` aliased to `screen`). `None` means the author did not set
212    /// a width — the emitter omits `data-width` so the HTML stays sparse.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub width: Option<String>,
215}
216
217/// Arguments for [`Shortcode::Recent`].
218///
219/// Parameters parsed at shortcode-extract time; the query runs at render
220/// time against the full post set. Renderer lives in
221/// `src-tauri/src/build/markdown/recent.rs`.
222#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
223pub struct RecentShortcode {
224    /// `since="YYYY-MM-DD"` — posts on or after this date. Stored as the
225    /// raw string here; the rendering layer parses it into a DateTime.
226    /// Mutually compatible with `last` — both set the cutoff, later wins.
227    pub since: Option<String>,
228    /// `last="week" | "month" | "Nd"` — relative window. The renderer
229    /// converts this to a duration and subtracts from now.
230    pub last: Option<String>,
231    /// `count="N"` — cap at N most recent posts. The renderer applies a
232    /// default of 10 when unset.
233    pub count: Option<u32>,
234    /// Body content rendered as fallback when zero posts match. Empty
235    /// string means no fallback. Lives in the AST so the renderer doesn't
236    /// need to re-read the source.
237    pub fallback_markdown: String,
238}
239
240/// Identifier for a shortcode kind, used for AST queries (e.g.
241/// `has_shortcode(&doc, ShortcodeKind::Subscribe)` to gate feature
242/// detection without scanning source files).
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
244#[serde(rename_all = "snake_case")]
245pub enum ShortcodeKind {
246    Subscribe,
247    Buttons,
248    Gallery,
249    Hero,
250    Grid,
251    Recent,
252}
253
254impl Shortcode {
255    /// Return the [`ShortcodeKind`] of this shortcode.
256    pub fn kind(&self) -> ShortcodeKind {
257        match self {
258            Shortcode::Subscribe(_) => ShortcodeKind::Subscribe,
259            Shortcode::Buttons(_) => ShortcodeKind::Buttons,
260            Shortcode::Gallery(_) => ShortcodeKind::Gallery,
261            Shortcode::Hero(_) => ShortcodeKind::Hero,
262            Shortcode::Grid(_) => ShortcodeKind::Grid,
263            Shortcode::Recent(_) => ShortcodeKind::Recent,
264        }
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn shortcode_kind_variants_are_distinct() {
274        let kinds = [
275            ShortcodeKind::Subscribe,
276            ShortcodeKind::Buttons,
277            ShortcodeKind::Gallery,
278            ShortcodeKind::Hero,
279            ShortcodeKind::Grid,
280            ShortcodeKind::Recent,
281        ];
282        let unique: std::collections::HashSet<_> = kinds.iter().collect();
283        assert_eq!(unique.len(), kinds.len());
284    }
285
286    #[test]
287    fn shortcode_kind_round_trips_through_serde() {
288        for kind in [
289            ShortcodeKind::Subscribe,
290            ShortcodeKind::Buttons,
291            ShortcodeKind::Gallery,
292            ShortcodeKind::Hero,
293            ShortcodeKind::Grid,
294            ShortcodeKind::Recent,
295        ] {
296            let s = serde_json::to_string(&kind).expect("serialize");
297            let back: ShortcodeKind = serde_json::from_str(&s).expect("deserialize");
298            assert_eq!(kind, back);
299        }
300    }
301
302    #[test]
303    fn subscribe_kind_method_returns_subscribe() {
304        let sc = Shortcode::Subscribe(SubscribeShortcode::default());
305        assert_eq!(sc.kind(), ShortcodeKind::Subscribe);
306    }
307
308    #[test]
309    fn subscribe_with_placeholder_and_button() {
310        let sc = Shortcode::Subscribe(SubscribeShortcode {
311            placeholder: Some("you@example.com".to_string()),
312            button: Some("Subscribe".to_string()),
313        });
314        match &sc {
315            Shortcode::Subscribe(args) => {
316                assert_eq!(args.placeholder.as_deref(), Some("you@example.com"));
317                assert_eq!(args.button.as_deref(), Some("Subscribe"));
318            }
319            other => panic!("expected Subscribe, got {other:?}"),
320        }
321    }
322
323    #[test]
324    fn subscribe_default_has_none_placeholder_and_button() {
325        let args = SubscribeShortcode::default();
326        assert!(args.placeholder.is_none());
327        assert!(args.button.is_none());
328    }
329
330    #[test]
331    fn subscribe_round_trips_through_serde() {
332        let sc = Shortcode::Subscribe(SubscribeShortcode {
333            placeholder: Some("p".to_string()),
334            button: Some("b".to_string()),
335        });
336        let s = serde_json::to_string(&sc).expect("serialize");
337        let back: Shortcode = serde_json::from_str(&s).expect("deserialize");
338        assert_eq!(sc, back);
339    }
340
341    // ---- Buttons ----
342
343    #[test]
344    fn buttons_kind_method_returns_buttons() {
345        let sc = Shortcode::Buttons(ButtonsShortcode::default());
346        assert_eq!(sc.kind(), ShortcodeKind::Buttons);
347    }
348
349    #[test]
350    fn buttons_items_carry_unresolved_urls() {
351        let sc = Shortcode::Buttons(ButtonsShortcode {
352            classes: String::new(),
353            items: vec![
354                ButtonItem {
355                    text: "Docs".to_string(),
356                    url: Url::unresolved("docs/"),
357                },
358                ButtonItem {
359                    text: "GitHub".to_string(),
360                    url: Url::unresolved("https://github.com"),
361                },
362            ],
363        });
364        match &sc {
365            Shortcode::Buttons(args) => {
366                assert_eq!(args.items.len(), 2);
367                assert!(args.items[0].url.is_unresolved());
368                assert!(args.items[1].url.is_unresolved());
369            }
370            _ => unreachable!(),
371        }
372    }
373
374    #[test]
375    fn buttons_default_has_no_items() {
376        let args = ButtonsShortcode::default();
377        assert!(args.items.is_empty());
378        assert!(args.classes.is_empty());
379    }
380
381    #[test]
382    fn buttons_round_trips_through_serde() {
383        let sc = Shortcode::Buttons(ButtonsShortcode {
384            classes: "primary".to_string(),
385            items: vec![ButtonItem {
386                text: "Go".to_string(),
387                url: Url::unresolved("/x"),
388            }],
389        });
390        let s = serde_json::to_string(&sc).expect("serialize");
391        let back: Shortcode = serde_json::from_str(&s).expect("deserialize");
392        assert_eq!(sc, back);
393    }
394
395    // ---- Gallery ----
396
397    #[test]
398    fn gallery_kind_method_returns_gallery() {
399        let sc = Shortcode::Gallery(GalleryShortcode::default());
400        assert_eq!(sc.kind(), ShortcodeKind::Gallery);
401    }
402
403    #[test]
404    fn gallery_items_carry_unresolved_urls() {
405        let sc = Shortcode::Gallery(GalleryShortcode {
406            columns: Some(3),
407            classes: String::new(),
408            items: vec![
409                GalleryItem {
410                    src: Url::unresolved("a.jpg"),
411                    alt: "A".to_string(),
412                    attrs: String::new(),
413                },
414                GalleryItem {
415                    src: Url::unresolved("b.jpg"),
416                    alt: "B".to_string(),
417                    attrs: "cover top".to_string(),
418                },
419            ],
420            width: None,
421        });
422        match &sc {
423            Shortcode::Gallery(args) => {
424                assert_eq!(args.columns, Some(3));
425                assert_eq!(args.items.len(), 2);
426                assert!(args.items[0].src.is_unresolved());
427                assert_eq!(args.items[1].attrs, "cover top");
428            }
429            _ => unreachable!(),
430        }
431    }
432
433    #[test]
434    fn gallery_default_no_columns_no_items() {
435        let args = GalleryShortcode::default();
436        assert!(args.columns.is_none());
437        assert!(args.items.is_empty());
438        assert!(args.classes.is_empty());
439    }
440
441    #[test]
442    fn gallery_round_trips_through_serde() {
443        let sc = Shortcode::Gallery(GalleryShortcode {
444            columns: Some(4),
445            classes: "showcase".to_string(),
446            items: vec![GalleryItem {
447                src: Url::unresolved("p.png"),
448                alt: "Photo".to_string(),
449                attrs: "1:1 contain".to_string(),
450            }],
451            width: None,
452        });
453        let s = serde_json::to_string(&sc).expect("serialize");
454        let back: Shortcode = serde_json::from_str(&s).expect("deserialize");
455        assert_eq!(sc, back);
456    }
457
458    // ---- Recent ----
459
460    #[test]
461    fn recent_kind_method_returns_recent() {
462        let sc = Shortcode::Recent(RecentShortcode::default());
463        assert_eq!(sc.kind(), ShortcodeKind::Recent);
464    }
465
466    #[test]
467    fn recent_default_has_none_params_empty_fallback() {
468        let args = RecentShortcode::default();
469        assert!(args.since.is_none());
470        assert!(args.last.is_none());
471        assert!(args.count.is_none());
472        assert!(args.fallback_markdown.is_empty());
473    }
474
475    #[test]
476    fn recent_round_trips_through_serde() {
477        let sc = Shortcode::Recent(RecentShortcode {
478            since: Some("2026-04-01".to_string()),
479            last: None,
480            count: Some(5),
481            fallback_markdown: "_No posts yet._".to_string(),
482        });
483        let s = serde_json::to_string(&sc).expect("serialize");
484        let back: Shortcode = serde_json::from_str(&s).expect("deserialize");
485        assert_eq!(sc, back);
486    }
487}