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 `--moss-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 `--moss-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, the
154    /// renderer emits it as a custom property —
155    /// `style="--moss-grid-ratio:minmax(0, 1fr) minmax(0, 2fr)"` — which the
156    /// stylesheet reads. Never as an inline `grid-template-columns`: that
157    /// outranks every rule, so the mobile single-column collapse could not
158    /// reach a ratio grid.
159    /// `cols=1:2:3` is equivalent to setting both `columns` (count = 3)
160    /// and `ratio` to `"1:2:3"`.
161    pub ratio: Option<String>,
162    /// Extra CSS classes for the wrapping `<div>` (from `{.foo .bar}`).
163    pub classes: String,
164    /// Each cell's parsed block content. Phase 4 PR4.5 (2026-05-28)
165    /// promoted this from `Vec<String>` (raw markdown source) to
166    /// `Vec<Vec<Block>>` (fully-typed AST). Nested shortcodes inside a
167    /// cell (`::::buttons` in `:::grid`) extract through
168    /// [`crate::ast::parser::parse`] recursion in
169    /// [`crate::ast::shortcode_extract::parse_grid`].
170    ///
171    /// Compound-link cells (the SoCiviC `[![[poster]] ### Title ...](/url)`
172    /// pattern, where the entire cell is wrapped in a markdown link that
173    /// spans block-level inner content) are represented as a single-element
174    /// `vec![Block::LinkCard { url, children }]`. See
175    /// [`Block::LinkCard`](crate::ast::Block::LinkCard) for the rationale.
176    pub cells: Vec<Vec<Block>>,
177    /// Spec § P9 width attribute: `body | wide | page | screen` (with
178    /// `full` aliased to `screen`). `None` means the author did not set
179    /// a width — the emitter omits `data-width` so the HTML stays sparse.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub width: Option<String>,
182}
183
184/// Arguments for [`Shortcode::Hero`].
185#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
186pub struct HeroShortcode {
187    /// Primary image source URL. `None` if neither the `image` attribute
188    /// nor a leading body media line provided one — the renderer emits a
189    /// section with no `<img>` in that case. Flows through resolver before
190    /// rendering. With `extra_images`, this is the first slide and the
191    /// reduced-motion/static fallback.
192    pub image: Option<Url>,
193    /// Remaining background slides (2026-07-27 multi-image hero): every
194    /// consecutive leading body media line after the first. Non-empty →
195    /// the hero renders an ambient crossfade (one slide visible at a
196    /// time, no controls — no slide may carry information the others
197    /// don't; design: docs/archive/2026-07-27-import-conventions-engine-design.md).
198    /// Empty for `image=`-attribute and directive-line heroes.
199    pub extra_images: Vec<Url>,
200    /// Pipe-suffix media attributes verbatim (e.g. "cover top",
201    /// "1.5:1 contain"). Empty if no pipe in the source.
202    pub attrs: String,
203    /// Extra CSS classes for the wrapping `<section>` (from `{.foo .bar}`).
204    pub classes: String,
205    /// Parsed block content for the overlay. Phase 4 PR4.5 (2026-05-28)
206    /// promoted this from `overlay_markdown: String` to `overlay: Vec<Block>`
207    /// (fully-typed AST). Nested shortcodes inside the overlay
208    /// (`::::buttons` inside `:::hero`) extract through
209    /// [`crate::ast::parser::parse`] recursion in
210    /// [`crate::ast::shortcode_extract::parse_hero`].
211    pub overlay: Vec<Block>,
212    /// Plain-text overlay source for downstream OG-fallback extraction.
213    ///
214    /// PR4.5 (2026-05-28): captured at parse time alongside the typed
215    /// `overlay` because `crate::build::page::meta::extract_description`
216    /// (the homepage-hero rung in the description chain) operates on
217    /// markdown source — round-tripping `Vec<Block>` to markdown would
218    /// invite drift. The renderer uses `overlay` for HTML; downstream
219    /// consumers read `overlay_text` for description-chain extraction.
220    ///
221    /// Empty when the author wrote no overlay body.
222    ///
223    /// TODO(phase4-cleanup): replace with a Vec<Block>-walking
224    /// `to_plain_text(blocks: &[Block]) -> String` helper in moss-core
225    /// + consume `overlay` directly in `meta.rs::extract_description`,
226    /// deleting this field. Carrying both `overlay: Vec<Block>` AND
227    /// `overlay_text: String` makes the AST non-canonical (which is the
228    /// source of truth?); per cross-SSG research, lossy or duplicate
229    /// state is Gatsby's mistake. This is transitional — flag if it
230    /// survives past PR7a. (Architecture review caveat 2026-05-28.)
231    pub overlay_text: String,
232    /// Spec § P9 width attribute: `body | wide | page | screen` (with
233    /// `full` aliased to `screen`). `None` means the author did not set
234    /// a width — the emitter omits `data-width` so the HTML stays sparse.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub width: Option<String>,
237    /// Mobile layout override. `Some("overlay")` keeps text overlaid on a
238    /// taller cropped image on mobile. `None` = default stacking behavior
239    /// (image full-width at natural ratio, text block below).
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub mobile: Option<String>,
242    /// Caption or credit for the image, from `caption="…"`. Rendered as a
243    /// line of text BELOW the hero, never over it.
244    ///
245    /// The overlay and the caption answer different questions. The overlay is
246    /// text laid *on* the photograph — a title, a standfirst — and it is
247    /// styled to be read against the image. A caption says what the
248    /// photograph is and who took it, and printing that across someone's
249    /// picture is both unreadable and, for a credit, wrong: a photographer's
250    /// name has to survive as text, not as part of the composition. So a hero
251    /// carrying a cover credit («封面:…(拍攝:…)») has somewhere to put it
252    /// that is not on top of the subject.
253    ///
254    /// A display string, rendered as inline markdown by the host — the same
255    /// treatment `byline:` / `colophon:` rows get — so a credit can be a link.
256    /// Empty when the author wrote no caption.
257    #[serde(default, skip_serializing_if = "String::is_empty")]
258    pub caption: String,
259}
260
261/// Arguments for [`Shortcode::Apply`].
262#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
263pub struct ApplyShortcode {
264    /// Optional override for the email input's placeholder text.
265    pub placeholder: Option<String>,
266    /// Optional override for the submit button label.
267    pub button: Option<String>,
268}
269
270/// Arguments for [`Shortcode::Recent`].
271///
272/// Parameters parsed at shortcode-extract time; the query runs at render
273/// time against the full post set. Renderer lives in
274/// `src-tauri/src/build/markdown/recent.rs`.
275#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
276pub struct RecentShortcode {
277    /// `since="YYYY-MM-DD"` — posts on or after this date. Stored as the
278    /// raw string here; the rendering layer parses it into a DateTime.
279    /// Mutually compatible with `last` — both set the cutoff, later wins.
280    pub since: Option<String>,
281    /// `last="week" | "month" | "Nd"` — relative window. The renderer
282    /// converts this to a duration and subtracts from now.
283    pub last: Option<String>,
284    /// `count="N"` — cap at N most recent posts. The renderer applies a
285    /// default of 10 when unset.
286    pub count: Option<u32>,
287    /// Body content rendered as fallback when zero posts match. Empty
288    /// string means no fallback. Lives in the AST so the renderer doesn't
289    /// need to re-read the source.
290    pub fallback_markdown: String,
291}
292
293/// Identifier for a shortcode kind, used for AST queries (e.g.
294/// `has_shortcode(&doc, ShortcodeKind::Subscribe)` to gate feature
295/// detection without scanning source files).
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
297#[serde(rename_all = "snake_case")]
298pub enum ShortcodeKind {
299    Subscribe,
300    Buttons,
301    Gallery,
302    Hero,
303    Grid,
304    Recent,
305    Apply,
306}
307
308impl ShortcodeKind {
309    /// Root `moss-*` class this shortcode emits — bridge between the
310    /// parser's authorable set and the COMPONENTS contract.
311    pub fn root_class(self) -> &'static str {
312        match self {
313            ShortcodeKind::Hero => "moss-hero",
314            ShortcodeKind::Grid => "moss-grid",
315            ShortcodeKind::Gallery => "moss-gallery",
316            ShortcodeKind::Buttons => "moss-buttons",
317            ShortcodeKind::Subscribe => "moss-subscribe",
318            ShortcodeKind::Recent => "moss-recent",
319            ShortcodeKind::Apply => "moss-apply",
320        }
321    }
322
323    /// The fence name authors write after `:::` — also the serde
324    /// `snake_case` form (a unit test in `contract::shortcodes` pins the
325    /// two together).
326    pub fn name(self) -> &'static str {
327        match self {
328            ShortcodeKind::Subscribe => "subscribe",
329            ShortcodeKind::Buttons => "buttons",
330            ShortcodeKind::Gallery => "gallery",
331            ShortcodeKind::Hero => "hero",
332            ShortcodeKind::Grid => "grid",
333            ShortcodeKind::Recent => "recent",
334            ShortcodeKind::Apply => "apply",
335        }
336    }
337
338    /// Whether editors offer this shortcode to authors (slash menu, fence
339    /// autocomplete). Deliberate — keep hidden: `apply` is the membership
340    /// application form, meaningful only on sites configured for it, so it
341    /// parses and renders but is never suggested. The decision lives here,
342    /// in Rust, once — the generated catalog
343    /// (`frontend/app/editor/shortcodes.generated.ts`) carries it as the
344    /// `authorable` flag (design: docs/archive/2026-08-11-cm6-extraction-design.md §4, §7.1).
345    pub fn authorable(self) -> bool {
346        !matches!(self, ShortcodeKind::Apply)
347    }
348
349    /// All shortcode variants, in a stable order.
350    ///
351    /// Used for enforcement and round-trip tests in `components_test.rs`.
352    pub fn all() -> impl Iterator<Item = ShortcodeKind> {
353        [
354            ShortcodeKind::Subscribe,
355            ShortcodeKind::Buttons,
356            ShortcodeKind::Gallery,
357            ShortcodeKind::Hero,
358            ShortcodeKind::Grid,
359            ShortcodeKind::Recent,
360            ShortcodeKind::Apply,
361        ]
362        .into_iter()
363    }
364}
365
366impl Shortcode {
367    /// Return the [`ShortcodeKind`] of this shortcode.
368    pub fn kind(&self) -> ShortcodeKind {
369        match self {
370            Shortcode::Subscribe(_) => ShortcodeKind::Subscribe,
371            Shortcode::Buttons(_) => ShortcodeKind::Buttons,
372            Shortcode::Gallery(_) => ShortcodeKind::Gallery,
373            Shortcode::Hero(_) => ShortcodeKind::Hero,
374            Shortcode::Grid(_) => ShortcodeKind::Grid,
375            Shortcode::Recent(_) => ShortcodeKind::Recent,
376            Shortcode::Apply(_) => ShortcodeKind::Apply,
377        }
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn shortcode_kind_variants_are_distinct() {
387        let kinds = [
388            ShortcodeKind::Subscribe,
389            ShortcodeKind::Buttons,
390            ShortcodeKind::Gallery,
391            ShortcodeKind::Hero,
392            ShortcodeKind::Grid,
393            ShortcodeKind::Recent,
394        ];
395        let unique: std::collections::HashSet<_> = kinds.iter().collect();
396        assert_eq!(unique.len(), kinds.len());
397    }
398
399    #[test]
400    fn shortcode_kind_round_trips_through_serde() {
401        for kind in [
402            ShortcodeKind::Subscribe,
403            ShortcodeKind::Buttons,
404            ShortcodeKind::Gallery,
405            ShortcodeKind::Hero,
406            ShortcodeKind::Grid,
407            ShortcodeKind::Recent,
408        ] {
409            let s = serde_json::to_string(&kind).expect("serialize");
410            let back: ShortcodeKind = serde_json::from_str(&s).expect("deserialize");
411            assert_eq!(kind, back);
412        }
413    }
414
415    #[test]
416    fn subscribe_kind_method_returns_subscribe() {
417        let sc = Shortcode::Subscribe(SubscribeShortcode::default());
418        assert_eq!(sc.kind(), ShortcodeKind::Subscribe);
419    }
420
421    #[test]
422    fn subscribe_with_placeholder_and_button() {
423        let sc = Shortcode::Subscribe(SubscribeShortcode {
424            placeholder: Some("you@example.com".to_string()),
425            button: Some("Subscribe".to_string()),
426        });
427        match &sc {
428            Shortcode::Subscribe(args) => {
429                assert_eq!(args.placeholder.as_deref(), Some("you@example.com"));
430                assert_eq!(args.button.as_deref(), Some("Subscribe"));
431            }
432            other => panic!("expected Subscribe, got {other:?}"),
433        }
434    }
435
436    #[test]
437    fn subscribe_default_has_none_placeholder_and_button() {
438        let args = SubscribeShortcode::default();
439        assert!(args.placeholder.is_none());
440        assert!(args.button.is_none());
441    }
442
443    #[test]
444    fn subscribe_round_trips_through_serde() {
445        let sc = Shortcode::Subscribe(SubscribeShortcode {
446            placeholder: Some("p".to_string()),
447            button: Some("b".to_string()),
448        });
449        let s = serde_json::to_string(&sc).expect("serialize");
450        let back: Shortcode = serde_json::from_str(&s).expect("deserialize");
451        assert_eq!(sc, back);
452    }
453
454    // ---- Buttons ----
455
456    #[test]
457    fn buttons_kind_method_returns_buttons() {
458        let sc = Shortcode::Buttons(ButtonsShortcode::default());
459        assert_eq!(sc.kind(), ShortcodeKind::Buttons);
460    }
461
462    #[test]
463    fn buttons_items_carry_unresolved_urls() {
464        let sc = Shortcode::Buttons(ButtonsShortcode {
465            classes: String::new(),
466            items: vec![
467                ButtonItem {
468                    text: "Docs".to_string(),
469                    url: Url::unresolved("docs/"),
470                },
471                ButtonItem {
472                    text: "GitHub".to_string(),
473                    url: Url::unresolved("https://github.com"),
474                },
475            ],
476        });
477        match &sc {
478            Shortcode::Buttons(args) => {
479                assert_eq!(args.items.len(), 2);
480                assert!(args.items[0].url.is_unresolved());
481                assert!(args.items[1].url.is_unresolved());
482            }
483            _ => unreachable!(),
484        }
485    }
486
487    #[test]
488    fn buttons_default_has_no_items() {
489        let args = ButtonsShortcode::default();
490        assert!(args.items.is_empty());
491        assert!(args.classes.is_empty());
492    }
493
494    #[test]
495    fn buttons_round_trips_through_serde() {
496        let sc = Shortcode::Buttons(ButtonsShortcode {
497            classes: "primary".to_string(),
498            items: vec![ButtonItem {
499                text: "Go".to_string(),
500                url: Url::unresolved("/x"),
501            }],
502        });
503        let s = serde_json::to_string(&sc).expect("serialize");
504        let back: Shortcode = serde_json::from_str(&s).expect("deserialize");
505        assert_eq!(sc, back);
506    }
507
508    // ---- Gallery ----
509
510    #[test]
511    fn gallery_kind_method_returns_gallery() {
512        let sc = Shortcode::Gallery(GalleryShortcode::default());
513        assert_eq!(sc.kind(), ShortcodeKind::Gallery);
514    }
515
516    #[test]
517    fn gallery_items_carry_unresolved_urls() {
518        let sc = Shortcode::Gallery(GalleryShortcode {
519            columns: Some(3),
520            classes: String::new(),
521            items: vec![
522                GalleryItem {
523                    src: Url::unresolved("a.jpg"),
524                    alt: "A".to_string(),
525                    attrs: String::new(),
526                },
527                GalleryItem {
528                    src: Url::unresolved("b.jpg"),
529                    alt: "B".to_string(),
530                    attrs: "cover top".to_string(),
531                },
532            ],
533            width: None,
534        });
535        match &sc {
536            Shortcode::Gallery(args) => {
537                assert_eq!(args.columns, Some(3));
538                assert_eq!(args.items.len(), 2);
539                assert!(args.items[0].src.is_unresolved());
540                assert_eq!(args.items[1].attrs, "cover top");
541            }
542            _ => unreachable!(),
543        }
544    }
545
546    #[test]
547    fn gallery_default_no_columns_no_items() {
548        let args = GalleryShortcode::default();
549        assert!(args.columns.is_none());
550        assert!(args.items.is_empty());
551        assert!(args.classes.is_empty());
552    }
553
554    #[test]
555    fn gallery_round_trips_through_serde() {
556        let sc = Shortcode::Gallery(GalleryShortcode {
557            columns: Some(4),
558            classes: "showcase".to_string(),
559            items: vec![GalleryItem {
560                src: Url::unresolved("p.png"),
561                alt: "Photo".to_string(),
562                attrs: "1:1 contain".to_string(),
563            }],
564            width: None,
565        });
566        let s = serde_json::to_string(&sc).expect("serialize");
567        let back: Shortcode = serde_json::from_str(&s).expect("deserialize");
568        assert_eq!(sc, back);
569    }
570
571    // ---- Recent ----
572
573    #[test]
574    fn recent_kind_method_returns_recent() {
575        let sc = Shortcode::Recent(RecentShortcode::default());
576        assert_eq!(sc.kind(), ShortcodeKind::Recent);
577    }
578
579    #[test]
580    fn recent_default_has_none_params_empty_fallback() {
581        let args = RecentShortcode::default();
582        assert!(args.since.is_none());
583        assert!(args.last.is_none());
584        assert!(args.count.is_none());
585        assert!(args.fallback_markdown.is_empty());
586    }
587
588    #[test]
589    fn recent_round_trips_through_serde() {
590        let sc = Shortcode::Recent(RecentShortcode {
591            since: Some("2026-04-01".to_string()),
592            last: None,
593            count: Some(5),
594            fallback_markdown: "_No posts yet._".to_string(),
595        });
596        let s = serde_json::to_string(&sc).expect("serialize");
597        let back: Shortcode = serde_json::from_str(&s).expect("deserialize");
598        assert_eq!(sc, back);
599    }
600
601    // ---- Hero ----
602
603    #[test]
604    fn hero_mobile_field_defaults_to_none() {
605        let args = HeroShortcode::default();
606        assert!(args.mobile.is_none());
607    }
608
609    #[test]
610    fn hero_with_mobile_overlay_round_trips_serde() {
611        let sc = Shortcode::Hero(HeroShortcode {
612            mobile: Some("overlay".to_string()),
613            ..Default::default()
614        });
615        let s = serde_json::to_string(&sc).expect("serialize");
616        let back: Shortcode = serde_json::from_str(&s).expect("deserialize");
617        assert_eq!(sc, back);
618    }
619}