Skip to main content

stack_theme/
lib.rs

1//! Typed access to the canonical Stack theme catalog.
2//!
3//! The embedded data is generated from `catalog/catalog.json`. It performs no
4//! filesystem, network, clock, locale, or host-font access at runtime.
5
6use 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/// Returns the embedded catalog parsed into the public Rust contract.
23#[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/// Returns the exact generated JSON embedded in the crate.
31#[must_use]
32pub const fn catalog_json() -> &'static str {
33    CATALOG_JSON
34}
35
36/// Returns the JSON Schema for the embedded catalog document shape.
37#[must_use]
38pub const fn catalog_schema_json() -> &'static str {
39    CATALOG_SCHEMA_JSON
40}
41
42/// Returns the JSON Schema for local user-imported provider icon packs.
43#[must_use]
44pub const fn provider_pack_schema_json() -> &'static str {
45    PROVIDER_PACK_SCHEMA_JSON
46}
47
48/// Returns one validated SVG asset by its catalog path.
49///
50/// The bytes are embedded at compile time; this function never reads the host
51/// filesystem or performs network access.
52#[must_use]
53pub fn icon_svg(asset_path: &str) -> Option<&'static str> {
54    generated::icon_svg(asset_path)
55}
56
57/// The complete versioned theme catalog.
58#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
59#[serde(rename_all = "camelCase")]
60pub struct Catalog {
61    /// JSON Schema location recorded by the source catalog.
62    #[serde(rename = "$schema")]
63    pub schema: String,
64    /// Major/minor version of the catalog document shape.
65    pub schema_version: String,
66    /// Version shared by the catalog and both distribution packages.
67    pub catalog_version: String,
68    /// Theme identifiers that cannot be registered again.
69    pub reserved_theme_ids: Vec<String>,
70    /// Deterministic recovery choices for unavailable themes and icons.
71    pub fallbacks: CatalogFallbacks,
72    /// Deterministic, versioned font measurement tables.
73    pub font_metrics: Vec<FontMetrics>,
74    /// Theme records in canonical catalog order.
75    pub themes: Vec<Theme>,
76}
77
78/// Catalog-wide recovery choices used after emitting a missing-resource diagnostic.
79#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
80#[serde(rename_all = "camelCase")]
81pub struct CatalogFallbacks {
82    /// Core theme selected when a requested non-core theme is unavailable.
83    pub missing_theme_id: String,
84    /// Logical icon selected when an icon is unavailable in the resolved theme.
85    pub missing_icon_id: String,
86}
87
88/// One deterministic font measurement table.
89#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
90#[serde(rename_all = "camelCase")]
91pub struct FontMetrics {
92    /// Catalog-local metrics identifier.
93    pub id: String,
94    /// Display family name.
95    pub family: String,
96    /// Upstream or repository-authored metrics version.
97    pub version: String,
98    /// Font design units per em.
99    pub units_per_em: u32,
100    /// Ascender in font design units.
101    pub ascent: i32,
102    /// Descender in font design units.
103    pub descent: i32,
104    /// Additional line gap in font design units.
105    pub line_gap: u32,
106    /// Advance used for a scalar absent from `glyph_advances`.
107    pub default_advance: u32,
108    /// Advance used for a scalar covered by `wide_ranges`.
109    pub wide_advance: u32,
110    /// Ordered, non-overlapping Unicode scalar ranges treated as wide.
111    pub wide_ranges: Vec<UnicodeRange>,
112    /// Unicode scalar advances keyed as uppercase `U+XXXX` values.
113    pub glyph_advances: BTreeMap<String, u32>,
114    /// Source, license, and distribution evidence for the metrics.
115    pub provenance: Provenance,
116}
117
118/// An inclusive Unicode scalar range encoded as `U+XXXX` labels.
119#[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/// One theme and its theme-local icon collection.
127#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
128#[serde(rename_all = "camelCase")]
129pub struct Theme {
130    /// Global Stack theme identifier.
131    pub id: String,
132    /// Human-readable theme name.
133    pub name: String,
134    /// Optional contributor-facing description.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub description: Option<String>,
137    /// Named colors available to other theme records.
138    pub palette: Palette,
139    /// Typography sizes, weights, and deterministic metrics reference.
140    pub typography: Typography,
141    /// Visual fallback for every Stack node kind.
142    pub node_kind_fallbacks: NodeKindFallbacks,
143    /// Connector and connector-label treatment.
144    pub connector: ConnectorStyle,
145    /// Theme-local named and fallback icon assets.
146    pub icons: Vec<Icon>,
147}
148
149/// Required semantic color slots.
150#[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/// Typography values expressed without platform font measurement.
165#[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/// Complete fallback mapping for Stack 1.0 node kinds.
179#[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/// Node shape, palette references, and fallback icon.
196#[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/// Renderer-supported node outlines.
209#[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/// A reference to a required palette slot.
220#[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/// Connector line and label treatment.
235#[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/// Theme-local icon metadata and its safe SVG asset.
248#[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/// A repository-relative icon asset and declared viewport.
259#[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/// Source, license, and distribution evidence for an asset.
268#[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/// Supported artifact and application distribution channels.
281#[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/// A local provider icon pack produced from an archive selected by the user.
291#[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/// Stable provider namespace and display name.
309#[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/// Provider packs are always supplied through an explicit local import.
317#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
318#[serde(rename_all = "kebab-case")]
319pub enum ProviderPackDistributionMode {
320    UserImported,
321}
322
323/// Immutable provenance for the official source archive and reviewed terms.
324#[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/// An additional audited archive used by a multi-source provider pack.
341#[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/// Provider-specific usage boundary retained with every imported pack.
350#[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/// Output categories copied from the provider's reviewed terms.
361#[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/// Asset redistribution switches fixed by the user-imported contract.
374#[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/// Local processing and artwork-preservation requirements.
386#[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/// The only modification policy supported by the provider-pack schema.
398#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
399#[serde(rename_all = "kebab-case")]
400pub enum ProviderPackModificationPolicy {
401    VisualPreservationOnly,
402}
403
404/// User-visible source, terms, and non-endorsement text.
405#[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/// One namespaced product icon and its locally processed safe SVG.
414#[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/// Stack node-kind recommendation attached without changing node semantics.
429#[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/// Original and processed identities for one local SVG file.
445#[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/// Auditable, visual-preservation-only transformations applied during import.
459#[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}