1use super::Kind;
2
3#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4pub struct ComponentSpec {
5 pub name: &'static str,
6 pub summary: &'static str,
7 pub children: Children,
8 pub props: &'static [PropSpec],
9 pub feature: Option<&'static str>,
11}
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum Children {
15 None,
16 Any,
17 FormParts,
19 RowContent,
21}
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub struct PropSpec {
25 pub name: &'static str,
26 pub kind: PropKind,
27 pub required: bool,
28 pub summary: &'static str,
29}
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum PropKind {
33 Text,
35 Data,
37 Literal,
39 Names,
41 Post,
43 Value,
45 Flag,
47 Id,
49 Fields,
51}
52
53impl PropKind {
54 #[must_use]
55 pub fn describe(self) -> &'static str {
56 match self {
57 Self::Text => "text, `{…}` or text with `{…}` parts",
58 Self::Data => "a data path such as `{data.items}`",
59 Self::Literal => "quoted text without `{…}`",
60 Self::Names => "quoted field names separated by spaces",
61 Self::Post => "a URL, quoted, with `{…}` parts for its parameters",
62 Self::Value => "one value: `{…}` or quoted text",
63 Self::Flag => "written bare, without a value",
64 Self::Id => "an element id",
65 Self::Fields => "`{{name: value, …}}`, each value a path, a string or an integer",
66 }
67 }
68}
69
70const fn prop(name: &'static str, kind: PropKind, summary: &'static str) -> PropSpec {
71 PropSpec {
72 name,
73 kind,
74 required: false,
75 summary,
76 }
77}
78
79const fn required(name: &'static str, kind: PropKind, summary: &'static str) -> PropSpec {
80 PropSpec {
81 name,
82 kind,
83 required: true,
84 summary,
85 }
86}
87
88const ID: PropSpec = prop(
89 "id",
90 PropKind::Id,
91 "Names the element, so a fragment request or a target finds it.",
92);
93const TARGET: PropSpec = prop(
94 "target",
95 PropKind::Id,
96 "The id of the element the answer replaces; without it the whole page is shown.",
97);
98
99const SECTION: ComponentSpec = ComponentSpec {
100 name: "Section",
101 summary: "A titled part of a page holding any markup and components.",
102 children: Children::Any,
103 props: &[required("title", PropKind::Text, "The heading."), ID],
104 feature: None,
105};
106
107const TABLE: ComponentSpec = ComponentSpec {
108 name: "Table",
109 summary: "Rows of a list, one column per field; money fields are formatted. Its content, \
110 if any, is rendered in a last cell of every row, such as an `Action`.",
111 children: Children::RowContent,
112 props: &[
113 required(
114 "id",
115 PropKind::Id,
116 "Names the table, so actions and listings replace it.",
117 ),
118 required("of", PropKind::Data, "The list of rows."),
119 required(
120 "columns",
121 PropKind::Names,
122 "The fields shown, in order; each header is the field's label.",
123 ),
124 prop(
125 "as",
126 PropKind::Literal,
127 "Names the row for the content, such as `as=\"order\"` and then `{order.id}`.",
128 ),
129 prop(
130 "link",
131 PropKind::Text,
132 "Where each row links to, with `{…}` parts naming the row, such as \
133 `link=\"/orders/{order.id}\"` with `as=\"order\"`; the first column's cell becomes \
134 the link.",
135 ),
136 prop(
137 "sort",
138 PropKind::Names,
139 "Columns the viewer may sort by (needs the `listing` feature and a listing loader).",
140 ),
141 prop(
142 "filter",
143 PropKind::Names,
144 "Fields the viewer may filter by (needs the `listing` feature and a listing loader).",
145 ),
146 ],
147 feature: None,
148};
149
150const MONEY: ComponentSpec = ComponentSpec {
151 name: "Money",
152 summary: "An amount, `{minor, currency}`, formatted in the viewer's language.",
153 children: Children::None,
154 props: &[required("value", PropKind::Data, "The amount."), ID],
155 feature: None,
156};
157
158const ACTION: ComponentSpec = ComponentSpec {
159 name: "Action",
160 summary: "A button posting to an action route, such as cancelling or booking the row it \
161 sits in.",
162 children: Children::None,
163 props: &[
164 required("post", PropKind::Post, "The action's URL."),
165 required("label", PropKind::Text, "The button's text."),
166 TARGET,
167 prop(
168 "when",
169 PropKind::Value,
170 "Shown only when this is truthy, such as `{order.status_to.CANCELLED}`.",
171 ),
172 prop(
173 "with",
174 PropKind::Fields,
175 "Fields it posts, read by the route's input like a form's, such as \
176 `{{slot: slot.id}}` for the row it sits in.",
177 ),
178 ID,
179 ],
180 feature: None,
181};
182
183const FORM: ComponentSpec = ComponentSpec {
184 name: "Form",
185 summary: "A form posting to an action route; its fields come from the route's input type \
186 unless `Field`s name them.",
187 children: Children::FormParts,
188 props: &[
189 required(
190 "id",
191 PropKind::Literal,
192 "The form's element id, so errors are placed under its fields.",
193 ),
194 required("post", PropKind::Post, "The action's URL."),
195 TARGET,
196 ],
197 feature: None,
198};
199
200const FIELD: ComponentSpec = ComponentSpec {
201 name: "Field",
202 summary: "One field of a `Form`, its control chosen from the input type's field.",
203 children: Children::None,
204 props: &[
205 required("name", PropKind::Literal, "The input field it edits."),
206 prop("label", PropKind::Text, "Replaces the field's label."),
207 prop("value", PropKind::Value, "The value it starts with."),
208 prop(
209 "options",
210 PropKind::Value,
211 "A select's options: a list from the data, or a quoted registered enum name.",
212 ),
213 prop("currency", PropKind::Value, "A money field's currency."),
214 prop(
215 "required",
216 PropKind::Flag,
217 "Marks it required when the input type does not say so.",
218 ),
219 ],
220 feature: None,
221};
222
223const SUBMIT: ComponentSpec = ComponentSpec {
224 name: "Submit",
225 summary: "The button sending a `Form`.",
226 children: Children::None,
227 props: &[required("label", PropKind::Text, "The button's text.")],
228 feature: None,
229};
230
231const BADGE: ComponentSpec = ComponentSpec {
232 name: "Badge",
233 summary: "An enum value shown as its translated variant with the variant's tone.",
234 children: Children::None,
235 props: &[
236 required("value", PropKind::Data, "The enum value."),
237 required(
238 "enum",
239 PropKind::Literal,
240 "The registered enum, such as `order_status`.",
241 ),
242 ID,
243 ],
244 feature: Some("i18n"),
245};
246
247const GRID: ComponentSpec = ComponentSpec {
248 name: "Grid",
249 summary: "Lays its children out in responsive columns.",
250 children: Children::Any,
251 props: &[ID],
252 feature: Some("layout"),
253};
254
255const CARD: ComponentSpec = ComponentSpec {
256 name: "Card",
257 summary: "A boxed group of content, a link as a whole when it has `href`.",
258 children: Children::Any,
259 props: &[prop("href", PropKind::Text, "Where the card links to."), ID],
260 feature: Some("layout"),
261};
262
263const IMAGE: ComponentSpec = ComponentSpec {
264 name: "Image",
265 summary: "An image, loaded lazily.",
266 children: Children::None,
267 props: &[
268 required("src", PropKind::Text, "The image's URL."),
269 required(
270 "alt",
271 PropKind::Text,
272 "What the image shows, for screen readers.",
273 ),
274 ID,
275 ],
276 feature: Some("layout"),
277};
278
279const ISLAND: ComponentSpec = ComponentSpec {
280 name: "Island",
281 summary: "A Svelte component from the module's `islands/<name>.svelte`, mounted as a \
282 custom element with typed props.",
283 children: Children::None,
284 props: &[
285 required(
286 "name",
287 PropKind::Literal,
288 "The island's name, lowercase with a hyphen.",
289 ),
290 required(
291 "props",
292 PropKind::Value,
293 "The props, usually `{data}` or a part of it.",
294 ),
295 ID,
296 ],
297 feature: Some("islands"),
298};
299
300const MARKDOWN: ComponentSpec = ComponentSpec {
301 name: "Markdown",
302 summary: "Markdown rendered without raw HTML.",
303 children: Children::None,
304 props: &[
305 required("text", PropKind::Data, "The markdown, from data."),
306 ID,
307 ],
308 feature: Some("content"),
309};
310
311const HEAD: ComponentSpec = ComponentSpec {
312 name: "Head",
313 summary: "The page's title, description, canonical URL and link-preview tags, moved into \
314 `<head>`.",
315 children: Children::None,
316 props: &[
317 prop("title", PropKind::Text, "The page title."),
318 prop(
319 "description",
320 PropKind::Text,
321 "The description search engines show.",
322 ),
323 prop("image", PropKind::Text, "The link-preview image's URL."),
324 prop("url", PropKind::Text, "The canonical URL."),
325 prop(
326 "type",
327 PropKind::Text,
328 "The Open Graph type, such as `article`.",
329 ),
330 ],
331 feature: Some("content"),
332};
333
334pub const SPECS: &[ComponentSpec] = &[
336 SECTION, TABLE, MONEY, ACTION, FORM, FIELD, SUBMIT, BADGE, GRID, CARD, IMAGE, ISLAND, MARKDOWN,
337 HEAD,
338];
339
340impl Kind {
341 pub(crate) fn spec(self) -> &'static ComponentSpec {
342 SPECS
343 .iter()
344 .find(|spec| spec.name == self.name())
345 .expect("every kind has a spec, as `every_kind_has_a_spec` checks")
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::SPECS;
352 use crate::catalog::Kind;
353
354 #[test]
355 fn every_kind_has_a_spec() {
356 for kind in Kind::ALL {
357 kind.spec();
358 }
359 }
360
361 #[test]
362 fn names_and_props_are_unique() {
363 for (index, spec) in SPECS.iter().enumerate() {
364 assert!(
365 SPECS[..index].iter().all(|other| other.name != spec.name),
366 "{}",
367 spec.name
368 );
369 for (position, prop) in spec.props.iter().enumerate() {
370 assert!(
371 spec.props[..position]
372 .iter()
373 .all(|other| other.name != prop.name),
374 "{}.{}",
375 spec.name,
376 prop.name
377 );
378 }
379 }
380 }
381}