moss_core/contract/describe.rs
1//! Serializable payload for `moss describe --json`.
2//!
3//! The JSON shape is itself a contract — version it independently of
4//! `moss_html_version`. Bumping `describe_schema_version` is required for
5//! any breaking change to the JSON envelope.
6
7use serde::Serialize;
8use std::collections::BTreeMap;
9
10use crate::ast::shortcode::ShortcodeKind;
11use crate::contract::components::{COMPONENTS, Status};
12use crate::contract::frontmatter::{FrontmatterFieldJson, frontmatter_fields};
13use crate::contract::tokens::Tokens;
14
15pub const DESCRIBE_SCHEMA_VERSION: u32 = 6;
16pub const MOSS_HTML_VERSION: u32 = 1;
17
18#[derive(Serialize)]
19pub struct DescribePayload<'a> {
20 pub describe_schema_version: u32,
21 pub moss_html_version: u32,
22 pub moss_binary_version: &'static str,
23 pub tokens: BTreeMap<&'a str, Vec<TokenJson<'a>>>,
24 pub components: Vec<ComponentJson>,
25 /// Escape-hatch custom properties: the `var(--moss-*, fallback)` hooks
26 /// moss's stylesheets read but never declare. Schema v6 added this — the
27 /// theming API both flagship sites actually used, previously undiscoverable.
28 pub custom_properties: Vec<CustomPropJson>,
29 /// Structural `data-*` attributes on elements that carry no `moss-*` class
30 /// — `<body data-page>`, `<html data-theme>`. Schema v6.
31 pub scope_attributes: Vec<ScopeAttrJson>,
32 pub frontmatter: Vec<FrontmatterFieldJson>,
33 /// Every language code that makes a directory or filename suffix a
34 /// language edition (`zh-hant/about.md`, `about.zh-hans.md`).
35 ///
36 /// Additive, so no `describe_schema_version` bump: the envelope's rule
37 /// requires one for breaking changes, and a new key breaks no reader.
38 ///
39 /// Here because the failure it prevents is silent. An unrecognized
40 /// directory name is not an error — the tree is treated as ordinary
41 /// content — so an agent that invents `english/` gets a build that
42 /// succeeds, a page that publishes, and no switcher, with nothing
43 /// anywhere saying why. The allowlist has to be readable *before* the
44 /// directory is created, and this is the only place it is published.
45 pub languages: &'static [&'static str],
46 /// Plugin hook contract: each capability moss supports, with arity and context type.
47 pub plugin_hooks: Vec<PluginHookInfo>,
48 /// Plugin manifest fields: each field in PluginManifest, with type and required flag.
49 pub manifest_fields: Vec<ManifestFieldInfo>,
50 /// Template injection slots: each named slot in the build pipeline.
51 pub slots: Vec<SlotInfo>,
52 /// CLI commands: each subcommand moss exposes.
53 // hand-maintained: keep in sync with run_mode.rs
54 pub cli_commands: Vec<CliCommandInfo>,
55}
56
57/// Plugin hook entry emitted in `plugin_hooks`.
58///
59/// Describes one capability a plugin may implement. Populated by
60/// `src-tauri/src/describe.rs` from the Tauri-layer `Capability` enum.
61#[derive(Serialize)]
62pub struct PluginHookInfo {
63 /// Lowercase hook name (e.g. "process"). Matches the JS function name.
64 pub name: &'static str,
65 /// One-line description of what this hook does.
66 pub description: &'static str,
67 /// "single" if at most one plugin may register this hook; "multiple" if many may.
68 pub arity: &'static str,
69 /// The name of the context struct passed to the hook function.
70 pub context: &'static str,
71}
72
73/// Plugin manifest field entry emitted in `manifest_fields`.
74///
75/// Describes one field of `PluginManifest`. Populated by
76/// `src-tauri/src/describe.rs` from the Rust struct definition.
77#[derive(Serialize)]
78pub struct ManifestFieldInfo {
79 /// Field name as it appears in the JSON manifest (snake_case).
80 pub name: &'static str,
81 /// JSON type or Rust-type description (e.g. "string", "string[]", "object").
82 pub r#type: &'static str,
83 /// Whether this field must be present in a valid manifest.
84 pub required: bool,
85 /// One-line description.
86 pub description: &'static str,
87}
88
89/// Template slot entry emitted in `slots`.
90///
91/// Describes one named injection point in the moss HTML templates. Populated
92/// by `src-tauri/src/describe.rs` from `SLOT_NAMES` and the `Slot` enum.
93#[derive(Serialize)]
94pub struct SlotInfo {
95 /// Slot name (e.g. "head-end"). Matches the `<!-- slot:NAME -->` marker.
96 pub name: &'static str,
97 /// Human-readable description of the slot's position in the page.
98 pub position: &'static str,
99 /// Whether markdown authors may target this slot via the `slot:` frontmatter field.
100 pub authorable: bool,
101}
102
103/// CLI command entry emitted in `cli_commands`.
104///
105/// Describes one moss CLI subcommand.
106// hand-maintained: keep in sync with run_mode.rs
107#[derive(Serialize)]
108pub struct CliCommandInfo {
109 /// Subcommand name (e.g. "build").
110 pub name: &'static str,
111 /// Argument signature (e.g. "<folder> [--serve] [--watch] [--no-plugins]").
112 pub args: &'static str,
113 /// One-line description.
114 pub description: &'static str,
115}
116
117#[derive(Serialize)]
118pub struct TokenJson<'a> {
119 pub name: &'a str,
120 pub value: &'a str,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub dark_value: Option<&'a str>,
123 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
124 pub type_hint: Option<&'a str>,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub description: Option<&'a str>,
127}
128
129#[derive(Serialize)]
130pub struct ComponentJson {
131 pub class: &'static str,
132 pub kind: &'static str,
133 pub parent: &'static str,
134 pub data_attrs: Vec<DataAttrJson>,
135 pub example_html: &'static str,
136 pub example_markdown: &'static str,
137 pub status: &'static str,
138 pub since: &'static str,
139 pub description: &'static str,
140 /// True iff this class is the root class of an authorable shortcode
141 /// (i.e. it appears in `ShortcodeKind::all().map(|k| k.root_class())`).
142 /// Agents can use this flag to distinguish the author-facing shortcodes
143 /// from the broader theme vocabulary. Deliberately not stated as a count:
144 /// this said "6" while `ShortcodeKind` had 7, and a number here is a second
145 /// source of truth for something the flag itself already answers.
146 pub authorable: bool,
147}
148
149#[derive(Serialize)]
150pub struct DataAttrJson {
151 pub name: &'static str,
152 pub values: &'static [&'static str],
153 pub default: &'static str,
154 pub description: &'static str,
155}
156
157/// A structural attribute on a classless element. Top-level for the same reason
158/// as [`CustomPropJson`]: it has no component to nest under.
159#[derive(Serialize)]
160pub struct ScopeAttrJson {
161 pub selector: &'static str,
162 pub name: &'static str,
163 pub values: &'static [&'static str],
164 pub description: &'static str,
165}
166
167/// An escape-hatch custom property, emitted as its own top-level section rather
168/// than nested under a component: an agent looking for "how do I change the hero
169/// crop" greps for the property name, and several of these are not owned by a
170/// single class anyway.
171#[derive(Serialize)]
172pub struct CustomPropJson {
173 pub name: &'static str,
174 pub owner: &'static str,
175 pub default: &'static str,
176 pub description: &'static str,
177}
178
179impl<'a> DescribePayload<'a> {
180 pub fn new(tokens: &'a Tokens) -> Self {
181 let mut tokens_map: BTreeMap<&str, Vec<TokenJson>> = BTreeMap::new();
182 for group in &tokens.groups {
183 let entries: Vec<TokenJson> = group
184 .entries
185 .iter()
186 .map(|t| TokenJson {
187 name: &t.name,
188 value: &t.value,
189 dark_value: t.dark_value.as_deref(),
190 type_hint: t.type_hint.as_deref(),
191 description: t.description.as_deref(),
192 })
193 .collect();
194 tokens_map.insert(&group.name, entries);
195 }
196
197 let authorable: std::collections::HashSet<&'static str> =
198 ShortcodeKind::all().map(|k| k.root_class()).collect();
199
200 let components: Vec<ComponentJson> = COMPONENTS
201 .iter()
202 .filter(|c| c.is_public())
203 .map(|c| ComponentJson {
204 class: c.class,
205 kind: c.kind,
206 parent: c.parent,
207 data_attrs: c
208 .data_attrs
209 .iter()
210 .map(|a| DataAttrJson {
211 name: a.name,
212 values: a.values,
213 default: a.default,
214 description: a.description,
215 })
216 .collect(),
217 example_html: c.example_html,
218 example_markdown: c.example_markdown,
219 status: match c.status {
220 Status::Confirmed => "confirmed",
221 Status::Emerging => "emerging",
222 Status::Retired => "retired",
223 },
224 since: c.since,
225 description: c.description,
226 authorable: authorable.contains(c.class),
227 })
228 .collect();
229
230 DescribePayload {
231 describe_schema_version: DESCRIBE_SCHEMA_VERSION,
232 moss_html_version: MOSS_HTML_VERSION,
233 moss_binary_version: env!("CARGO_PKG_VERSION"),
234 tokens: tokens_map,
235 components,
236 custom_properties: crate::contract::custom_props::CUSTOM_PROPS
237 .iter()
238 .map(|p| CustomPropJson {
239 name: p.name,
240 owner: p.owner,
241 default: p.default,
242 description: p.description,
243 })
244 .collect(),
245 scope_attributes: crate::contract::custom_props::SCOPE_ATTRS
246 .iter()
247 .map(|a| ScopeAttrJson {
248 selector: a.selector,
249 name: a.name,
250 values: a.values,
251 description: a.description,
252 })
253 .collect(),
254 frontmatter: frontmatter_fields(),
255 languages: crate::home::known_language_codes(),
256 // Populated by the Tauri layer (src-tauri/src/describe.rs) which
257 // has access to the Tauri-layer plugin types. Callers using
258 // DescribePayload::new() directly (e.g. moss-core unit tests) get
259 // empty vecs here; the CLI path fills them via with_plugin_contract().
260 plugin_hooks: Vec::new(),
261 manifest_fields: Vec::new(),
262 slots: Vec::new(),
263 cli_commands: Vec::new(),
264 }
265 }
266
267 /// Builder method: report the version of the moss binary that is answering,
268 /// rather than the version of this crate.
269 ///
270 /// `env!("CARGO_PKG_VERSION")` expands where it is *written*, so the default
271 /// set in [`DescribePayload::new`] is moss-core's version — a different
272 /// number from the app's, on its own release cadence. `describe --json`
273 /// therefore reported `0.4.0` while `moss --version` reported `0.8.0`,
274 /// under a field named `moss_binary_version`. An agent keying a vocabulary
275 /// cache on it saw a version that never matched the binary and did not move
276 /// when the app was upgraded.
277 ///
278 /// Only the host crate can answer this, so it has to be passed in.
279 pub fn with_binary_version(mut self, version: &'static str) -> Self {
280 self.moss_binary_version = version;
281 self
282 }
283
284 /// Builder method: attach plugin contract data (hooks, manifest fields,
285 /// slots, CLI commands). Called by the Tauri-layer describe.rs after
286 /// constructing the base payload, since those types live outside moss-core.
287 pub fn with_plugin_contract(
288 mut self,
289 plugin_hooks: Vec<PluginHookInfo>,
290 manifest_fields: Vec<ManifestFieldInfo>,
291 slots: Vec<SlotInfo>,
292 cli_commands: Vec<CliCommandInfo>,
293 ) -> Self {
294 self.plugin_hooks = plugin_hooks;
295 self.manifest_fields = manifest_fields;
296 self.slots = slots;
297 self.cli_commands = cli_commands;
298 self
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use crate::contract::tokens::load_tokens;
306
307 #[test]
308 fn apply_is_absent_from_public_contract() {
309 let tokens = load_tokens().expect("tokens");
310 let payload = DescribePayload::new(&tokens);
311 assert!(
312 payload.components.iter().all(|c| !c.class.starts_with("moss-apply")),
313 "apply classes must be demoted from the public contract"
314 );
315 assert!(
316 !payload.components.iter().any(|c| c.class == "moss-apply" && c.authorable),
317 ":::apply must not be marked authorable"
318 );
319 }
320}