1use std::collections::BTreeMap;
7use std::sync::OnceLock;
8
9use serde::{Deserialize, Serialize};
10
11mod generated {
12 include!("generated/metadata.rs");
13}
14
15pub use generated::{CATALOG_REVISION, CATALOG_VERSION};
16
17const CATALOG_JSON: &str = include_str!("generated/catalog.json");
18const CATALOG_SCHEMA_JSON: &str = include_str!("../schema/catalog.schema.json");
19const PROVIDER_PACK_SCHEMA_JSON: &str = include_str!("../schema/provider-pack.schema.json");
20static CATALOG: OnceLock<Catalog> = OnceLock::new();
21
22#[must_use]
24pub fn catalog() -> &'static Catalog {
25 CATALOG.get_or_init(|| {
26 serde_json::from_str(CATALOG_JSON).expect("generated catalog must match the Rust contract")
27 })
28}
29
30#[must_use]
32pub const fn catalog_json() -> &'static str {
33 CATALOG_JSON
34}
35
36#[must_use]
38pub const fn catalog_schema_json() -> &'static str {
39 CATALOG_SCHEMA_JSON
40}
41
42#[must_use]
44pub const fn provider_pack_schema_json() -> &'static str {
45 PROVIDER_PACK_SCHEMA_JSON
46}
47
48#[must_use]
53pub fn icon_svg(asset_path: &str) -> Option<&'static str> {
54 generated::icon_svg(asset_path)
55}
56
57#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
59#[serde(rename_all = "camelCase")]
60pub struct Catalog {
61 #[serde(rename = "$schema")]
63 pub schema: String,
64 pub schema_version: String,
66 pub catalog_version: String,
68 pub reserved_theme_ids: Vec<String>,
70 pub fallbacks: CatalogFallbacks,
72 pub font_metrics: Vec<FontMetrics>,
74 pub themes: Vec<Theme>,
76}
77
78#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
80#[serde(rename_all = "camelCase")]
81pub struct CatalogFallbacks {
82 pub missing_theme_id: String,
84 pub missing_icon_id: String,
86}
87
88#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
90#[serde(rename_all = "camelCase")]
91pub struct FontMetrics {
92 pub id: String,
94 pub family: String,
96 pub version: String,
98 pub units_per_em: u32,
100 pub ascent: i32,
102 pub descent: i32,
104 pub line_gap: u32,
106 pub default_advance: u32,
108 pub wide_advance: u32,
110 pub wide_ranges: Vec<UnicodeRange>,
112 pub glyph_advances: BTreeMap<String, u32>,
114 pub provenance: Provenance,
116}
117
118#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
120#[serde(rename_all = "camelCase")]
121pub struct UnicodeRange {
122 pub start: String,
123 pub end: String,
124}
125
126#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
128#[serde(rename_all = "camelCase")]
129pub struct Theme {
130 pub id: String,
132 pub name: String,
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub description: Option<String>,
137 pub palette: Palette,
139 pub typography: Typography,
141 pub node_kind_fallbacks: NodeKindFallbacks,
143 pub connector: ConnectorStyle,
145 pub icons: Vec<Icon>,
147}
148
149#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
151#[serde(rename_all = "camelCase")]
152pub struct Palette {
153 pub canvas: String,
154 pub surface: String,
155 pub surface_muted: String,
156 pub text: String,
157 pub text_muted: String,
158 pub border: String,
159 pub accent: String,
160 pub danger: String,
161 pub connector: String,
162}
163
164#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
166#[serde(rename_all = "camelCase")]
167pub struct Typography {
168 pub font_metrics_id: String,
169 pub node_label_size_milli_px: u32,
170 pub node_detail_size_milli_px: u32,
171 pub group_label_size_milli_px: u32,
172 pub edge_label_size_milli_px: u32,
173 pub line_height_permille: u32,
174 pub label_weight: u16,
175 pub detail_weight: u16,
176}
177
178#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
180#[serde(rename_all = "camelCase")]
181pub struct NodeKindFallbacks {
182 pub actor: NodeVisual,
183 pub client: NodeVisual,
184 pub service: NodeVisual,
185 #[serde(rename = "function")]
186 pub function_: NodeVisual,
187 pub worker: NodeVisual,
188 pub database: NodeVisual,
189 pub cache: NodeVisual,
190 pub queue: NodeVisual,
191 pub storage: NodeVisual,
192 pub external: NodeVisual,
193}
194
195#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
197#[serde(rename_all = "camelCase")]
198pub struct NodeVisual {
199 pub shape: NodeShape,
200 pub fill: PaletteToken,
201 pub stroke: PaletteToken,
202 pub text: PaletteToken,
203 pub accent: PaletteToken,
204 pub corner_radius_milli_px: u32,
205 pub fallback_icon_id: String,
206}
207
208#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
210#[serde(rename_all = "kebab-case")]
211pub enum NodeShape {
212 RoundedRectangle,
213 Capsule,
214 Circle,
215 Cylinder,
216 Hexagon,
217}
218
219#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
221#[serde(rename_all = "camelCase")]
222pub enum PaletteToken {
223 Canvas,
224 Surface,
225 SurfaceMuted,
226 Text,
227 TextMuted,
228 Border,
229 Accent,
230 Danger,
231 Connector,
232}
233
234#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
236#[serde(rename_all = "camelCase")]
237pub struct ConnectorStyle {
238 pub stroke: PaletteToken,
239 pub text: PaletteToken,
240 pub label_background: PaletteToken,
241 pub width_milli_px: u32,
242 pub arrow_size_milli_px: u32,
243 #[serde(skip_serializing_if = "Option::is_none")]
244 pub dash_milli_px: Option<Vec<u32>>,
245}
246
247#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
249#[serde(rename_all = "camelCase")]
250pub struct Icon {
251 pub id: String,
252 pub subject: String,
253 #[serde(skip_serializing_if = "Option::is_none")]
254 pub description: Option<String>,
255 pub asset: IconAsset,
256}
257
258#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
260#[serde(rename_all = "camelCase")]
261pub struct IconAsset {
262 pub path: String,
263 pub view_box: [i32; 4],
264 pub provenance: Provenance,
265}
266
267#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
269#[serde(rename_all = "camelCase")]
270pub struct Provenance {
271 pub source_url: String,
272 pub source_revision: String,
273 pub copyright: String,
274 pub license_spdx: String,
275 pub license_file: String,
276 pub modified: bool,
277 pub redistribution: Redistribution,
278}
279
280#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
282#[serde(rename_all = "camelCase")]
283pub struct Redistribution {
284 pub cargo: bool,
285 pub npm: bool,
286 pub wasm: bool,
287 pub commercial_applications: bool,
288}
289
290#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
292#[serde(rename_all = "camelCase")]
293pub struct ProviderPack {
294 #[serde(rename = "$schema")]
295 pub schema: String,
296 pub schema_version: String,
297 pub pack_version: String,
298 pub provider: ProviderPackIdentity,
299 pub distribution_mode: ProviderPackDistributionMode,
300 pub source: ProviderPackSource,
301 #[serde(default, skip_serializing_if = "Vec::is_empty")]
302 pub additional_sources: Vec<ProviderPackAdditionalSource>,
303 pub rights: ProviderPackRights,
304 pub notice: ProviderPackNotice,
305 pub icons: Vec<ProviderIcon>,
306}
307
308#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
310#[serde(rename_all = "camelCase")]
311pub struct ProviderPackIdentity {
312 pub id: String,
313 pub name: String,
314}
315
316#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
318#[serde(rename_all = "kebab-case")]
319pub enum ProviderPackDistributionMode {
320 UserImported,
321}
322
323#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
325#[serde(rename_all = "camelCase")]
326pub struct ProviderPackSource {
327 pub page_url: String,
328 pub archive_url: String,
329 pub archive_sha256: String,
330 pub release: String,
331 pub retrieved_at: String,
332 pub terms_url: String,
333 pub terms_reviewed_at: String,
334 pub review_after: String,
335 pub copyright: String,
336 pub license_id: String,
337 pub archive_license_included: bool,
338}
339
340#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
342#[serde(rename_all = "camelCase")]
343pub struct ProviderPackAdditionalSource {
344 pub id: String,
345 #[serde(flatten)]
346 pub source: ProviderPackSource,
347}
348
349#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
351#[serde(rename_all = "camelCase")]
352pub struct ProviderPackRights {
353 pub terms_acceptance_required: bool,
354 pub permitted_outputs: Vec<ProviderPackPermittedOutput>,
355 pub redistribution: ProviderPackRedistribution,
356 pub processing: ProviderPackProcessing,
357 pub modification_policy: ProviderPackModificationPolicy,
358}
359
360#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
362#[serde(rename_all = "kebab-case")]
363pub enum ProviderPackPermittedOutput {
364 ArchitectureDiagram,
365 TrainingMaterial,
366 Documentation,
367 Whitepaper,
368 Presentation,
369 DataSheet,
370 Poster,
371}
372
373#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
375#[serde(rename_all = "camelCase")]
376pub struct ProviderPackRedistribution {
377 pub cargo: bool,
378 pub npm: bool,
379 pub wasm: bool,
380 pub web_asset: bool,
381 pub native_binary: bool,
382 pub generated_output: bool,
383}
384
385#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
387#[serde(rename_all = "camelCase")]
388pub struct ProviderPackProcessing {
389 pub local_only: bool,
390 pub automatic_download: bool,
391 pub server_upload: bool,
392 pub preserve_colors: bool,
393 pub preserve_geometry: bool,
394 pub product_name_nearby: bool,
395}
396
397#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
399#[serde(rename_all = "kebab-case")]
400pub enum ProviderPackModificationPolicy {
401 VisualPreservationOnly,
402}
403
404#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
406#[serde(rename_all = "camelCase")]
407pub struct ProviderPackNotice {
408 pub attribution: String,
409 pub terms_summary: String,
410 pub non_endorsement: String,
411}
412
413#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
415#[serde(rename_all = "camelCase")]
416pub struct ProviderIcon {
417 pub id: String,
418 pub subject: String,
419 pub product_name: String,
420 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub brand_source_url: Option<String>,
422 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub brand_guidelines_url: Option<String>,
424 pub recommended_node_kind: ProviderNodeKind,
425 pub asset: ProviderIconAsset,
426}
427
428#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
430#[serde(rename_all = "kebab-case")]
431pub enum ProviderNodeKind {
432 Actor,
433 Client,
434 Service,
435 Function,
436 Worker,
437 Database,
438 Cache,
439 Queue,
440 Storage,
441 External,
442}
443
444#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
446#[serde(rename_all = "camelCase")]
447pub struct ProviderIconAsset {
448 #[serde(default, skip_serializing_if = "Option::is_none")]
449 pub source_id: Option<String>,
450 pub path: String,
451 pub original_path: String,
452 pub view_box: [i32; 4],
453 pub original_sha256: String,
454 pub processed_sha256: String,
455 pub transformations: Vec<ProviderPackTransformation>,
456}
457
458#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
460#[serde(rename_all = "kebab-case")]
461pub enum ProviderPackTransformation {
462 RemoveMetadata,
463 InlineStyles,
464 RemoveUnusedIdentifiers,
465 NamespaceIdentifiers,
466 ScaleViewBoxToIntegers,
467 NormalizeXml,
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 #[test]
475 fn embedded_catalog_matches_public_metadata() {
476 let catalog = catalog();
477
478 assert_eq!(catalog.schema_version, "1.0");
479 assert_eq!(catalog.catalog_version, CATALOG_VERSION);
480 assert!(CATALOG_REVISION.starts_with("sha256:"));
481 assert_eq!(CATALOG_REVISION.len(), 71);
482 assert_eq!(icon_svg("assets/missing.svg"), None);
483 assert_eq!(
484 catalog
485 .themes
486 .iter()
487 .map(|theme| theme.id.as_str())
488 .collect::<Vec<_>>(),
489 ["default", "light", "dark"]
490 );
491 assert!(
492 catalog
493 .themes
494 .iter()
495 .flat_map(|theme| &theme.icons)
496 .all(|icon| icon_svg(&icon.asset.path).is_some())
497 );
498 }
499
500 #[test]
501 fn embedded_catalog_round_trips_semantically() {
502 let reparsed: Catalog = serde_json::from_str(catalog_json()).unwrap();
503 let serialized = serde_json::to_value(&reparsed).unwrap();
504 let source: serde_json::Value = serde_json::from_str(catalog_json()).unwrap();
505 let schema: serde_json::Value = serde_json::from_str(catalog_schema_json()).unwrap();
506
507 assert_eq!(serialized, source);
508 assert_eq!(
509 schema["$schema"],
510 "https://json-schema.org/draft/2020-12/schema"
511 );
512 let provider_schema: serde_json::Value =
513 serde_json::from_str(provider_pack_schema_json()).unwrap();
514 assert_eq!(
515 provider_schema["$id"],
516 "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/provider-pack.schema.json"
517 );
518 }
519
520 #[test]
521 fn multi_source_provider_pack_round_trips_semantically() {
522 let source = include_str!("../../../tests/fixtures/provider-pack/multi-source.json");
523 let pack: ProviderPack = serde_json::from_str(source).unwrap();
524
525 assert_eq!(pack.schema_version, "1.1");
526 assert_eq!(pack.additional_sources.len(), 1);
527 assert_eq!(pack.additional_sources[0].id, "categories");
528 assert_eq!(pack.icons[0].asset.source_id.as_deref(), Some("categories"));
529 assert_eq!(
530 pack.icons[0].brand_guidelines_url.as_deref(),
531 Some("https://example.com/acme/brand-guidelines")
532 );
533 assert_eq!(
534 serde_json::to_value(&pack).unwrap(),
535 serde_json::from_str::<serde_json::Value>(source).unwrap()
536 );
537 }
538}