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