Skip to main content

moss_core/contract/
shortcodes.rs

1//! Shortcode authoring catalog — the single source for "what shortcodes
2//! exist, what attributes they take, and which of those name assets".
3//!
4//! Plainly: the editor's slash menu and fence autocomplete used to hand-copy
5//! this knowledge in TypeScript (`SHORTCODE_CATALOG`, `ASSET_ATTR_BY_SHORTCODE`)
6//! and the copies drifted (`apply` was missing). This table is generated into
7//! `frontend/app/editor/shortcodes.generated.ts` by the `shortcode-catalog`
8//! emitter in `src-tauri/dev-bin/generate-artifacts.rs`, CI-diff-gated like
9//! `bindings.ts` — so the fact lives in Rust, once.
10//!
11//! Presentation (labels, hints, translations) is deliberately NOT here: hosts
12//! overlay their own i18n on the structural catalog (design:
13//! docs/archive/2026-08-11-cm6-extraction-design.md §4).
14//!
15//! The `entry()` match is total over [`ShortcodeKind`] — adding a variant
16//! fails compilation HERE until the catalog describes it.
17
18use crate::ast::shortcode::ShortcodeKind;
19use crate::resolve::ext_kind::ExtKind;
20
21/// One attribute a shortcode accepts on its opening fence line.
22pub struct ShortcodeAttrSpec {
23    /// Attribute name as written in `{name=…}`.
24    pub name: &'static str,
25    /// Non-empty when the attribute's VALUE names an asset file: editors
26    /// scope asset search to these kinds. Empty for plain attrs
27    /// (`cols=`, `button=`, …).
28    pub asset_kinds: &'static [ExtKind],
29}
30
31/// The full authoring contract for one shortcode.
32pub struct ShortcodeCatalogEntry {
33    pub kind: ShortcodeKind,
34    /// Fence name (`:::name`), equal to `kind.name()`.
35    pub name: &'static str,
36    pub attrs: &'static [ShortcodeAttrSpec],
37    /// The opening-line attribute that names this shortcode's asset, if any
38    /// (`hero` → `image`). `gallery` is deliberately absent: its assets are
39    /// markdown embeds in the BODY, which editors already see as embeds.
40    pub asset_attr: Option<&'static str>,
41    /// Canonical English insertion template, CM6 snippet syntax
42    /// (`${n:placeholder}` tab stops). Hosts may localise placeholders;
43    /// the structure here is the one insertion grammar.
44    pub canonical_template: &'static str,
45    /// Whether editors offer this shortcode to authors — `kind.authorable()`.
46    pub authorable: bool,
47}
48
49/// The catalog, in [`ShortcodeKind::all`] order (stable, deterministic).
50pub fn catalog() -> Vec<ShortcodeCatalogEntry> {
51    ShortcodeKind::all().map(entry).collect()
52}
53
54fn entry(kind: ShortcodeKind) -> ShortcodeCatalogEntry {
55    let (attrs, asset_attr, canonical_template): (
56        &'static [ShortcodeAttrSpec],
57        Option<&'static str>,
58        &'static str,
59    ) = match kind {
60        ShortcodeKind::Subscribe => (
61            &[
62                ShortcodeAttrSpec { name: "button", asset_kinds: &[] },
63                ShortcodeAttrSpec { name: "placeholder", asset_kinds: &[] },
64            ],
65            None,
66            "subscribe {button=\"${1:Subscribe}\"}\n:::",
67        ),
68        ShortcodeKind::Buttons => (
69            &[],
70            None,
71            "buttons\n[${1:Get started}](${2:/})\n:::",
72        ),
73        ShortcodeKind::Gallery => (
74            &[ShortcodeAttrSpec { name: "cols", asset_kinds: &[] }],
75            None,
76            "gallery {cols=${1:3}}\n![](${2:photo.jpg})\n:::",
77        ),
78        ShortcodeKind::Hero => (
79            &[
80                ShortcodeAttrSpec {
81                    name: "image",
82                    asset_kinds: &[ExtKind::Image, ExtKind::Video],
83                },
84                ShortcodeAttrSpec { name: "wide", asset_kinds: &[] },
85            ],
86            Some("image"),
87            "hero {image=${1:photo.jpg}}\n# ${2:Title}\n${3:Subtitle}\n:::",
88        ),
89        ShortcodeKind::Grid => (
90            &[
91                ShortcodeAttrSpec { name: "cols", asset_kinds: &[] },
92                ShortcodeAttrSpec { name: "wide", asset_kinds: &[] },
93            ],
94            None,
95            "grid {cols=${1:2}}\n${2:cell one}\n+++\n${3:cell two}\n:::",
96        ),
97        ShortcodeKind::Recent => (
98            &[
99                ShortcodeAttrSpec { name: "count", asset_kinds: &[] },
100                ShortcodeAttrSpec { name: "since", asset_kinds: &[] },
101                ShortcodeAttrSpec { name: "last", asset_kinds: &[] },
102            ],
103            None,
104            "recent {count=${1:5}}\n${2:No posts yet.}\n:::",
105        ),
106        ShortcodeKind::Apply => (
107            &[
108                ShortcodeAttrSpec { name: "placeholder", asset_kinds: &[] },
109                ShortcodeAttrSpec { name: "button", asset_kinds: &[] },
110            ],
111            None,
112            "apply {button=\"${1:Apply}\"}\n:::",
113        ),
114    };
115    ShortcodeCatalogEntry {
116        kind,
117        name: kind.name(),
118        attrs,
119        asset_attr,
120        canonical_template,
121        authorable: kind.authorable(),
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn covers_every_kind_exactly_once() {
131        let cat = catalog();
132        assert_eq!(cat.len(), ShortcodeKind::all().count());
133        let names: std::collections::HashSet<_> = cat.iter().map(|e| e.name).collect();
134        assert_eq!(names.len(), cat.len(), "duplicate fence names");
135    }
136
137    #[test]
138    fn name_matches_serde_snake_case() {
139        for e in catalog() {
140            let serde_name = serde_json::to_string(&e.kind).expect("serialize");
141            assert_eq!(serde_name, format!("\"{}\"", e.name));
142        }
143    }
144
145    #[test]
146    fn asset_attr_names_a_declared_asset_attr() {
147        for e in catalog() {
148            if let Some(attr) = e.asset_attr {
149                let hit = e.attrs.iter().find(|a| a.name == attr);
150                let hit = hit.unwrap_or_else(|| {
151                    panic!("{}: asset_attr `{attr}` is not in attrs", e.name)
152                });
153                assert!(
154                    !hit.asset_kinds.is_empty(),
155                    "{}: asset_attr `{attr}` declares no asset kinds",
156                    e.name
157                );
158            }
159            // And the inverse: an opening-line attr with asset kinds must be
160            // reachable — i.e. be THE asset_attr — or editors could never
161            // search assets for it.
162            for a in e.attrs.iter().filter(|a| !a.asset_kinds.is_empty()) {
163                assert_eq!(
164                    e.asset_attr,
165                    Some(a.name),
166                    "{}: attr `{}` carries asset kinds but is not the asset_attr",
167                    e.name,
168                    a.name
169                );
170            }
171        }
172    }
173
174    #[test]
175    fn template_opens_with_the_fence_name_and_closes_the_fence() {
176        for e in catalog() {
177            assert!(
178                e.canonical_template.starts_with(e.name),
179                "{}: template must start with the fence name (inserted after `:::`)",
180                e.name
181            );
182            assert!(
183                e.canonical_template.ends_with(":::"),
184                "{}: template must close its fence",
185                e.name
186            );
187        }
188    }
189
190    #[test]
191    fn only_apply_is_hidden() {
192        for e in catalog() {
193            assert_eq!(
194                e.authorable,
195                e.kind != ShortcodeKind::Apply,
196                "{}: authorable flag drifted from the §7.1 decision",
197                e.name
198            );
199        }
200    }
201}