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 /// All authorable shortcode variants, in a stable order.
324 ///
325 /// Used for enforcement and round-trip tests in `components_test.rs`.
326 pub fn all() -> impl Iterator<Item = ShortcodeKind> {
327 [
328 ShortcodeKind::Subscribe,
329 ShortcodeKind::Buttons,
330 ShortcodeKind::Gallery,
331 ShortcodeKind::Hero,
332 ShortcodeKind::Grid,
333 ShortcodeKind::Recent,
334 ShortcodeKind::Apply,
335 ]
336 .into_iter()
337 }
338}
339
340impl Shortcode {
341 /// Return the [`ShortcodeKind`] of this shortcode.
342 pub fn kind(&self) -> ShortcodeKind {
343 match self {
344 Shortcode::Subscribe(_) => ShortcodeKind::Subscribe,
345 Shortcode::Buttons(_) => ShortcodeKind::Buttons,
346 Shortcode::Gallery(_) => ShortcodeKind::Gallery,
347 Shortcode::Hero(_) => ShortcodeKind::Hero,
348 Shortcode::Grid(_) => ShortcodeKind::Grid,
349 Shortcode::Recent(_) => ShortcodeKind::Recent,
350 Shortcode::Apply(_) => ShortcodeKind::Apply,
351 }
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn shortcode_kind_variants_are_distinct() {
361 let kinds = [
362 ShortcodeKind::Subscribe,
363 ShortcodeKind::Buttons,
364 ShortcodeKind::Gallery,
365 ShortcodeKind::Hero,
366 ShortcodeKind::Grid,
367 ShortcodeKind::Recent,
368 ];
369 let unique: std::collections::HashSet<_> = kinds.iter().collect();
370 assert_eq!(unique.len(), kinds.len());
371 }
372
373 #[test]
374 fn shortcode_kind_round_trips_through_serde() {
375 for kind in [
376 ShortcodeKind::Subscribe,
377 ShortcodeKind::Buttons,
378 ShortcodeKind::Gallery,
379 ShortcodeKind::Hero,
380 ShortcodeKind::Grid,
381 ShortcodeKind::Recent,
382 ] {
383 let s = serde_json::to_string(&kind).expect("serialize");
384 let back: ShortcodeKind = serde_json::from_str(&s).expect("deserialize");
385 assert_eq!(kind, back);
386 }
387 }
388
389 #[test]
390 fn subscribe_kind_method_returns_subscribe() {
391 let sc = Shortcode::Subscribe(SubscribeShortcode::default());
392 assert_eq!(sc.kind(), ShortcodeKind::Subscribe);
393 }
394
395 #[test]
396 fn subscribe_with_placeholder_and_button() {
397 let sc = Shortcode::Subscribe(SubscribeShortcode {
398 placeholder: Some("you@example.com".to_string()),
399 button: Some("Subscribe".to_string()),
400 });
401 match &sc {
402 Shortcode::Subscribe(args) => {
403 assert_eq!(args.placeholder.as_deref(), Some("you@example.com"));
404 assert_eq!(args.button.as_deref(), Some("Subscribe"));
405 }
406 other => panic!("expected Subscribe, got {other:?}"),
407 }
408 }
409
410 #[test]
411 fn subscribe_default_has_none_placeholder_and_button() {
412 let args = SubscribeShortcode::default();
413 assert!(args.placeholder.is_none());
414 assert!(args.button.is_none());
415 }
416
417 #[test]
418 fn subscribe_round_trips_through_serde() {
419 let sc = Shortcode::Subscribe(SubscribeShortcode {
420 placeholder: Some("p".to_string()),
421 button: Some("b".to_string()),
422 });
423 let s = serde_json::to_string(&sc).expect("serialize");
424 let back: Shortcode = serde_json::from_str(&s).expect("deserialize");
425 assert_eq!(sc, back);
426 }
427
428 // ---- Buttons ----
429
430 #[test]
431 fn buttons_kind_method_returns_buttons() {
432 let sc = Shortcode::Buttons(ButtonsShortcode::default());
433 assert_eq!(sc.kind(), ShortcodeKind::Buttons);
434 }
435
436 #[test]
437 fn buttons_items_carry_unresolved_urls() {
438 let sc = Shortcode::Buttons(ButtonsShortcode {
439 classes: String::new(),
440 items: vec![
441 ButtonItem {
442 text: "Docs".to_string(),
443 url: Url::unresolved("docs/"),
444 },
445 ButtonItem {
446 text: "GitHub".to_string(),
447 url: Url::unresolved("https://github.com"),
448 },
449 ],
450 });
451 match &sc {
452 Shortcode::Buttons(args) => {
453 assert_eq!(args.items.len(), 2);
454 assert!(args.items[0].url.is_unresolved());
455 assert!(args.items[1].url.is_unresolved());
456 }
457 _ => unreachable!(),
458 }
459 }
460
461 #[test]
462 fn buttons_default_has_no_items() {
463 let args = ButtonsShortcode::default();
464 assert!(args.items.is_empty());
465 assert!(args.classes.is_empty());
466 }
467
468 #[test]
469 fn buttons_round_trips_through_serde() {
470 let sc = Shortcode::Buttons(ButtonsShortcode {
471 classes: "primary".to_string(),
472 items: vec![ButtonItem {
473 text: "Go".to_string(),
474 url: Url::unresolved("/x"),
475 }],
476 });
477 let s = serde_json::to_string(&sc).expect("serialize");
478 let back: Shortcode = serde_json::from_str(&s).expect("deserialize");
479 assert_eq!(sc, back);
480 }
481
482 // ---- Gallery ----
483
484 #[test]
485 fn gallery_kind_method_returns_gallery() {
486 let sc = Shortcode::Gallery(GalleryShortcode::default());
487 assert_eq!(sc.kind(), ShortcodeKind::Gallery);
488 }
489
490 #[test]
491 fn gallery_items_carry_unresolved_urls() {
492 let sc = Shortcode::Gallery(GalleryShortcode {
493 columns: Some(3),
494 classes: String::new(),
495 items: vec![
496 GalleryItem {
497 src: Url::unresolved("a.jpg"),
498 alt: "A".to_string(),
499 attrs: String::new(),
500 },
501 GalleryItem {
502 src: Url::unresolved("b.jpg"),
503 alt: "B".to_string(),
504 attrs: "cover top".to_string(),
505 },
506 ],
507 width: None,
508 });
509 match &sc {
510 Shortcode::Gallery(args) => {
511 assert_eq!(args.columns, Some(3));
512 assert_eq!(args.items.len(), 2);
513 assert!(args.items[0].src.is_unresolved());
514 assert_eq!(args.items[1].attrs, "cover top");
515 }
516 _ => unreachable!(),
517 }
518 }
519
520 #[test]
521 fn gallery_default_no_columns_no_items() {
522 let args = GalleryShortcode::default();
523 assert!(args.columns.is_none());
524 assert!(args.items.is_empty());
525 assert!(args.classes.is_empty());
526 }
527
528 #[test]
529 fn gallery_round_trips_through_serde() {
530 let sc = Shortcode::Gallery(GalleryShortcode {
531 columns: Some(4),
532 classes: "showcase".to_string(),
533 items: vec![GalleryItem {
534 src: Url::unresolved("p.png"),
535 alt: "Photo".to_string(),
536 attrs: "1:1 contain".to_string(),
537 }],
538 width: None,
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 // ---- Recent ----
546
547 #[test]
548 fn recent_kind_method_returns_recent() {
549 let sc = Shortcode::Recent(RecentShortcode::default());
550 assert_eq!(sc.kind(), ShortcodeKind::Recent);
551 }
552
553 #[test]
554 fn recent_default_has_none_params_empty_fallback() {
555 let args = RecentShortcode::default();
556 assert!(args.since.is_none());
557 assert!(args.last.is_none());
558 assert!(args.count.is_none());
559 assert!(args.fallback_markdown.is_empty());
560 }
561
562 #[test]
563 fn recent_round_trips_through_serde() {
564 let sc = Shortcode::Recent(RecentShortcode {
565 since: Some("2026-04-01".to_string()),
566 last: None,
567 count: Some(5),
568 fallback_markdown: "_No posts yet._".to_string(),
569 });
570 let s = serde_json::to_string(&sc).expect("serialize");
571 let back: Shortcode = serde_json::from_str(&s).expect("deserialize");
572 assert_eq!(sc, back);
573 }
574
575 // ---- Hero ----
576
577 #[test]
578 fn hero_mobile_field_defaults_to_none() {
579 let args = HeroShortcode::default();
580 assert!(args.mobile.is_none());
581 }
582
583 #[test]
584 fn hero_with_mobile_overlay_round_trips_serde() {
585 let sc = Shortcode::Hero(HeroShortcode {
586 mobile: Some("overlay".to_string()),
587 ..Default::default()
588 });
589 let s = serde_json::to_string(&sc).expect("serialize");
590 let back: Shortcode = serde_json::from_str(&s).expect("deserialize");
591 assert_eq!(sc, back);
592 }
593}