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: ``, 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]]`, ``, 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 `` 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}