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