use serde::{Deserialize, Serialize};
use super::node::Block;
use super::url::Url;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum Shortcode {
Subscribe(SubscribeShortcode),
Buttons(ButtonsShortcode),
Gallery(GalleryShortcode),
Hero(HeroShortcode),
Grid(GridShortcode),
Recent(RecentShortcode),
Apply(ApplyShortcode),
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubscribeShortcode {
pub placeholder: Option<String>,
pub button: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ButtonsShortcode {
pub classes: String,
pub items: Vec<ButtonItem>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ButtonItem {
pub text: String,
pub url: Url,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GalleryShortcode {
pub columns: Option<u32>,
pub classes: String,
pub items: Vec<GalleryItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GalleryItem {
pub src: Url,
pub alt: String,
pub attrs: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GridShortcode {
pub columns: u32,
pub ratio: Option<String>,
pub classes: String,
pub cells: Vec<Vec<Block>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct HeroShortcode {
pub image: Option<Url>,
pub attrs: String,
pub classes: String,
pub overlay: Vec<Block>,
pub overlay_text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mobile: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApplyShortcode {
pub placeholder: Option<String>,
pub button: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecentShortcode {
pub since: Option<String>,
pub last: Option<String>,
pub count: Option<u32>,
pub fallback_markdown: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ShortcodeKind {
Subscribe,
Buttons,
Gallery,
Hero,
Grid,
Recent,
Apply,
}
impl ShortcodeKind {
pub fn root_class(self) -> &'static str {
match self {
ShortcodeKind::Hero => "moss-hero",
ShortcodeKind::Grid => "moss-grid",
ShortcodeKind::Gallery => "moss-gallery",
ShortcodeKind::Buttons => "moss-buttons",
ShortcodeKind::Subscribe => "moss-subscribe",
ShortcodeKind::Recent => "moss-recent",
ShortcodeKind::Apply => "moss-apply",
}
}
pub fn all() -> impl Iterator<Item = ShortcodeKind> {
[
ShortcodeKind::Subscribe,
ShortcodeKind::Buttons,
ShortcodeKind::Gallery,
ShortcodeKind::Hero,
ShortcodeKind::Grid,
ShortcodeKind::Recent,
ShortcodeKind::Apply,
]
.into_iter()
}
}
impl Shortcode {
pub fn kind(&self) -> ShortcodeKind {
match self {
Shortcode::Subscribe(_) => ShortcodeKind::Subscribe,
Shortcode::Buttons(_) => ShortcodeKind::Buttons,
Shortcode::Gallery(_) => ShortcodeKind::Gallery,
Shortcode::Hero(_) => ShortcodeKind::Hero,
Shortcode::Grid(_) => ShortcodeKind::Grid,
Shortcode::Recent(_) => ShortcodeKind::Recent,
Shortcode::Apply(_) => ShortcodeKind::Apply,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shortcode_kind_variants_are_distinct() {
let kinds = [
ShortcodeKind::Subscribe,
ShortcodeKind::Buttons,
ShortcodeKind::Gallery,
ShortcodeKind::Hero,
ShortcodeKind::Grid,
ShortcodeKind::Recent,
];
let unique: std::collections::HashSet<_> = kinds.iter().collect();
assert_eq!(unique.len(), kinds.len());
}
#[test]
fn shortcode_kind_round_trips_through_serde() {
for kind in [
ShortcodeKind::Subscribe,
ShortcodeKind::Buttons,
ShortcodeKind::Gallery,
ShortcodeKind::Hero,
ShortcodeKind::Grid,
ShortcodeKind::Recent,
] {
let s = serde_json::to_string(&kind).expect("serialize");
let back: ShortcodeKind = serde_json::from_str(&s).expect("deserialize");
assert_eq!(kind, back);
}
}
#[test]
fn subscribe_kind_method_returns_subscribe() {
let sc = Shortcode::Subscribe(SubscribeShortcode::default());
assert_eq!(sc.kind(), ShortcodeKind::Subscribe);
}
#[test]
fn subscribe_with_placeholder_and_button() {
let sc = Shortcode::Subscribe(SubscribeShortcode {
placeholder: Some("you@example.com".to_string()),
button: Some("Subscribe".to_string()),
});
match &sc {
Shortcode::Subscribe(args) => {
assert_eq!(args.placeholder.as_deref(), Some("you@example.com"));
assert_eq!(args.button.as_deref(), Some("Subscribe"));
}
other => panic!("expected Subscribe, got {other:?}"),
}
}
#[test]
fn subscribe_default_has_none_placeholder_and_button() {
let args = SubscribeShortcode::default();
assert!(args.placeholder.is_none());
assert!(args.button.is_none());
}
#[test]
fn subscribe_round_trips_through_serde() {
let sc = Shortcode::Subscribe(SubscribeShortcode {
placeholder: Some("p".to_string()),
button: Some("b".to_string()),
});
let s = serde_json::to_string(&sc).expect("serialize");
let back: Shortcode = serde_json::from_str(&s).expect("deserialize");
assert_eq!(sc, back);
}
#[test]
fn buttons_kind_method_returns_buttons() {
let sc = Shortcode::Buttons(ButtonsShortcode::default());
assert_eq!(sc.kind(), ShortcodeKind::Buttons);
}
#[test]
fn buttons_items_carry_unresolved_urls() {
let sc = Shortcode::Buttons(ButtonsShortcode {
classes: String::new(),
items: vec![
ButtonItem {
text: "Docs".to_string(),
url: Url::unresolved("docs/"),
},
ButtonItem {
text: "GitHub".to_string(),
url: Url::unresolved("https://github.com"),
},
],
});
match &sc {
Shortcode::Buttons(args) => {
assert_eq!(args.items.len(), 2);
assert!(args.items[0].url.is_unresolved());
assert!(args.items[1].url.is_unresolved());
}
_ => unreachable!(),
}
}
#[test]
fn buttons_default_has_no_items() {
let args = ButtonsShortcode::default();
assert!(args.items.is_empty());
assert!(args.classes.is_empty());
}
#[test]
fn buttons_round_trips_through_serde() {
let sc = Shortcode::Buttons(ButtonsShortcode {
classes: "primary".to_string(),
items: vec![ButtonItem {
text: "Go".to_string(),
url: Url::unresolved("/x"),
}],
});
let s = serde_json::to_string(&sc).expect("serialize");
let back: Shortcode = serde_json::from_str(&s).expect("deserialize");
assert_eq!(sc, back);
}
#[test]
fn gallery_kind_method_returns_gallery() {
let sc = Shortcode::Gallery(GalleryShortcode::default());
assert_eq!(sc.kind(), ShortcodeKind::Gallery);
}
#[test]
fn gallery_items_carry_unresolved_urls() {
let sc = Shortcode::Gallery(GalleryShortcode {
columns: Some(3),
classes: String::new(),
items: vec![
GalleryItem {
src: Url::unresolved("a.jpg"),
alt: "A".to_string(),
attrs: String::new(),
},
GalleryItem {
src: Url::unresolved("b.jpg"),
alt: "B".to_string(),
attrs: "cover top".to_string(),
},
],
width: None,
});
match &sc {
Shortcode::Gallery(args) => {
assert_eq!(args.columns, Some(3));
assert_eq!(args.items.len(), 2);
assert!(args.items[0].src.is_unresolved());
assert_eq!(args.items[1].attrs, "cover top");
}
_ => unreachable!(),
}
}
#[test]
fn gallery_default_no_columns_no_items() {
let args = GalleryShortcode::default();
assert!(args.columns.is_none());
assert!(args.items.is_empty());
assert!(args.classes.is_empty());
}
#[test]
fn gallery_round_trips_through_serde() {
let sc = Shortcode::Gallery(GalleryShortcode {
columns: Some(4),
classes: "showcase".to_string(),
items: vec![GalleryItem {
src: Url::unresolved("p.png"),
alt: "Photo".to_string(),
attrs: "1:1 contain".to_string(),
}],
width: None,
});
let s = serde_json::to_string(&sc).expect("serialize");
let back: Shortcode = serde_json::from_str(&s).expect("deserialize");
assert_eq!(sc, back);
}
#[test]
fn recent_kind_method_returns_recent() {
let sc = Shortcode::Recent(RecentShortcode::default());
assert_eq!(sc.kind(), ShortcodeKind::Recent);
}
#[test]
fn recent_default_has_none_params_empty_fallback() {
let args = RecentShortcode::default();
assert!(args.since.is_none());
assert!(args.last.is_none());
assert!(args.count.is_none());
assert!(args.fallback_markdown.is_empty());
}
#[test]
fn recent_round_trips_through_serde() {
let sc = Shortcode::Recent(RecentShortcode {
since: Some("2026-04-01".to_string()),
last: None,
count: Some(5),
fallback_markdown: "_No posts yet._".to_string(),
});
let s = serde_json::to_string(&sc).expect("serialize");
let back: Shortcode = serde_json::from_str(&s).expect("deserialize");
assert_eq!(sc, back);
}
#[test]
fn hero_mobile_field_defaults_to_none() {
let args = HeroShortcode::default();
assert!(args.mobile.is_none());
}
#[test]
fn hero_with_mobile_overlay_round_trips_serde() {
let sc = Shortcode::Hero(HeroShortcode {
mobile: Some("overlay".to_string()),
..Default::default()
});
let s = serde_json::to_string(&sc).expect("serialize");
let back: Shortcode = serde_json::from_str(&s).expect("deserialize");
assert_eq!(sc, back);
}
}