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