From c571e6cea7901706799abab70d8ff961d72db418 Mon Sep 17 00:00:00 2001 From: konojunya Date: Fri, 4 Sep 2026 21:11:15 +0900 Subject: [PATCH 1/3] Support multi-source provider icon packs --- Cargo.lock | 2 +- Cargo.toml | 2 +- PROVIDER_PACKS.md | 8 +- README.md | 2 +- catalog/catalog.json | 2 +- .../schema/provider-pack.schema.json | 33 +++++++- crates/stack-theme/src/generated/catalog.json | 2 +- crates/stack-theme/src/generated/metadata.rs | 4 +- crates/stack-theme/src/lib.rs | 31 +++++++ package-lock.json | 6 +- package.json | 2 +- packages/theme/catalog-metadata.json | 4 +- packages/theme/catalog.generated.js | 56 +++++++++++-- packages/theme/catalog.json | 2 +- packages/theme/index.d.ts | 8 +- packages/theme/package.json | 2 +- .../theme/schema/provider-pack.schema.json | 33 +++++++- schemas/provider-pack.schema.json | 33 +++++++- scripts/catalog-lib.mjs | 12 +++ scripts/validate.mjs | 16 ++-- tests/catalog.test.mjs | 29 +++++++ .../fixtures/provider-pack/multi-source.json | 82 +++++++++++++++++++ 22 files changed, 332 insertions(+), 39 deletions(-) create mode 100644 tests/fixtures/provider-pack/multi-source.json diff --git a/Cargo.lock b/Cargo.lock index 7dec4ff..d97eb4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,7 +77,7 @@ dependencies = [ [[package]] name = "stack-theme" -version = "0.4.0" +version = "0.5.0" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index fe2a4a3..17e3ded 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ edition = "2024" license = "Apache-2.0" repository = "https://github.com/stack-sh/theme" rust-version = "1.85" -version = "0.4.0" +version = "0.5.0" [workspace.dependencies] serde = { version = "1.0", features = ["derive"] } diff --git a/PROVIDER_PACKS.md b/PROVIDER_PACKS.md index 6564cd9..a7d4592 100644 --- a/PROVIDER_PACKS.md +++ b/PROVIDER_PACKS.md @@ -2,7 +2,7 @@ ## Status and boundary -This document defines the draft `1.0` manifest for provider icons that a user imports from an official archive. It is separate from the core theme catalog because current AWS, Google Cloud, and Azure terms permit architecture-diagram use but do not provide a clear license for Stack to redistribute the asset bytes in Cargo, npm, Web, WebAssembly, or native binary packages. +This document defines the backward-compatible `1.0` and `1.1` manifests for provider icons that a user imports from official archives. It is separate from the core theme catalog because current vendor terms may permit diagram use without providing a clear license for Stack to redistribute the asset bytes in Cargo, npm, Web, WebAssembly, or native binary packages. The Stack repository and packages contain only the Apache-2.0 manifest schema, types, and validation logic. They never contain, mirror, proxy, automatically download, or upload provider asset bytes. A host application must ask the user to select a local archive and accept the linked provider terms before creating a pack. @@ -13,16 +13,18 @@ Every manifest requires: - `schemaVersion` and a semantically versioned `packVersion`; - one lowercase provider `id`, human-readable name, and matching icon namespace; - `distributionMode: "user-imported"`; -- the official source page, archive URL, archive SHA-256, upstream release, retrieval date, terms URL, terms-review date, copyright statement, and `LicenseRef-*` identifier; +- the primary official source page, archive URL, archive SHA-256, upstream release, retrieval date, terms URL, terms-review date, copyright statement, and `LicenseRef-*` identifier; - a rights record that fixes package redistribution, automatic download, and server upload to `false`, generated diagram embedding to `true`, and artwork preservation to `true`; - user-visible attribution, terms summary, and non-endorsement notice; - one or more provider-prefixed icon records. The schema records `archiveUrl` as provenance. It is not permission for a runtime consumer to fetch the URL. Import remains an explicit user-selected local operation. +Version `1.1` additionally supports `additionalSources` for a provider catalog split across more than one official archive. Each additional source has a stable pack-local `id` and the same immutable provenance fields as the primary `source`. An icon from an additional archive records that `id` in `asset.sourceId`; omitting `sourceId` means the primary source. Source IDs must be unique and every reference must resolve. Version `1.0` single-source packs remain valid without changes. + ## Icon records -An icon ID has the form `:`, such as `aws:s3`, `gcp:cloud-run`, or `azure:storage-accounts`. The prefix must equal `provider.id`. Each icon preserves a stable subject, official product name, recommended Stack node kind, upstream archive path, processed local SVG path, integer view box, original and processed SHA-256 hashes, and an ordered transformation log. +An icon ID has the form `:`, such as `aws:s3`, `gcp:cloud-run`, or `azure:storage-accounts`. The prefix must equal `provider.id`. Each icon preserves a stable subject, official product name, recommended Stack node kind, source reference, upstream archive path, processed local SVG path, integer view box, original and processed SHA-256 hashes, and an ordered transformation log. The importer may perform only visual-preservation transformations needed for safe standalone SVG, such as removing metadata, converting stylesheet declarations to equivalent presentation attributes, removing unused identifiers, namespacing referenced gradient identifiers to prevent collisions, or normalizing XML. Recoloring, cropping, flipping, rotation, distortion, product substitution, or aspect-ratio changes are outside the contract. diff --git a/README.md b/README.md index b5020db..5d37331 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ The draft core catalog contract is defined by [`CONTRACT.md`](./CONTRACT.md) and [`schemas/catalog.schema.json`](./schemas/catalog.schema.json). The separate local-only provider-pack contract is defined by [`PROVIDER_PACKS.md`](./PROVIDER_PACKS.md) and [`schemas/provider-pack.schema.json`](./schemas/provider-pack.schema.json). The canonical core source is [`catalog/catalog.json`](./catalog/catalog.json); Cargo and npm artifacts are generated from that source with one content revision. -The current `0.4.0` catalog contains the core `default`, `light`, and `dark` themes, repository-authored fallbacks for every Stack 1.0 node kind, 30 provider-neutral explicit icons, and versioned host-independent font metrics. The explicit icon catalog covers clients and compute, networking and delivery, data and events, development workflows, security, general collaboration tools, and AI systems. Its identifiers are `api`, `web`, `mobile`, `desktop`, `server`, `container`, `cluster`, `cloud`, `scheduler`, `webhook`, `identity`, `observability`, `gateway`, `load-balancer`, `dns`, `cdn`, `firewall`, `network`, `event`, `stream`, `search`, `analytics`, `repository`, `pipeline`, `secret`, `document`, `task`, `chat`, `email`, and `ai`. Neither package has been released to a registry yet. +The current `0.5.0` catalog contains the core `default`, `light`, and `dark` themes, repository-authored fallbacks for every Stack 1.0 node kind, 30 provider-neutral explicit icons, and versioned host-independent font metrics. The explicit icon catalog covers clients and compute, networking and delivery, data and events, development workflows, security, general collaboration tools, and AI systems. Its identifiers are `api`, `web`, `mobile`, `desktop`, `server`, `container`, `cluster`, `cloud`, `scheduler`, `webhook`, `identity`, `observability`, `gateway`, `load-balancer`, `dns`, `cdn`, `firewall`, `network`, `event`, `stream`, `search`, `analytics`, `repository`, `pipeline`, `secret`, `document`, `task`, `chat`, `email`, and `ai`. Provider-specific assets are not bundled. The provider-pack contract lets a CLI or browser validate an archive that the user explicitly selected from the provider's official source, keep it local, preserve the artwork, and carry source and terms notices into diagram output. diff --git a/catalog/catalog.json b/catalog/catalog.json index f412644..8a92f8d 100644 --- a/catalog/catalog.json +++ b/catalog/catalog.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/catalog.schema.json", "schemaVersion": "1.0", - "catalogVersion": "0.4.0", + "catalogVersion": "0.5.0", "reservedThemeIds": [], "fallbacks": { "missingThemeId": "default", diff --git a/crates/stack-theme/schema/provider-pack.schema.json b/crates/stack-theme/schema/provider-pack.schema.json index 85659ed..0d5dad8 100644 --- a/crates/stack-theme/schema/provider-pack.schema.json +++ b/crates/stack-theme/schema/provider-pack.schema.json @@ -18,11 +18,17 @@ ], "properties": { "$schema": { "type": "string" }, - "schemaVersion": { "const": "1.0" }, + "schemaVersion": { "enum": ["1.0", "1.1"] }, "packVersion": { "$ref": "#/$defs/semanticVersion" }, "provider": { "$ref": "#/$defs/provider" }, "distributionMode": { "const": "user-imported" }, "source": { "$ref": "#/$defs/source" }, + "additionalSources": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { "$ref": "#/$defs/additionalSource" } + }, "rights": { "$ref": "#/$defs/rights" }, "notice": { "$ref": "#/$defs/notice" }, "icons": { @@ -41,6 +47,10 @@ "type": "string", "pattern": "^[a-z][a-z0-9-]{1,31}$" }, + "sourceId": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{1,31}$" + }, "namespacedIconId": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,31}:[a-z0-9][a-z0-9-]{0,63}$" @@ -83,7 +93,11 @@ }, "source": { "type": "object", - "additionalProperties": false, + "allOf": [{ "$ref": "#/$defs/sourceData" }], + "unevaluatedProperties": false + }, + "sourceData": { + "type": "object", "required": [ "pageUrl", "archiveUrl", @@ -118,6 +132,20 @@ "archiveLicenseIncluded": { "type": "boolean" } } }, + "additionalSource": { + "type": "object", + "allOf": [ + { "$ref": "#/$defs/sourceData" }, + { + "type": "object", + "required": ["id"], + "properties": { + "id": { "$ref": "#/$defs/sourceId" } + } + } + ], + "unevaluatedProperties": false + }, "redistribution": { "type": "object", "additionalProperties": false, @@ -248,6 +276,7 @@ "transformations" ], "properties": { + "sourceId": { "$ref": "#/$defs/sourceId" }, "path": { "$ref": "#/$defs/relativePath" }, "originalPath": { "$ref": "#/$defs/relativePath" }, "viewBox": { "$ref": "#/$defs/viewBox" }, diff --git a/crates/stack-theme/src/generated/catalog.json b/crates/stack-theme/src/generated/catalog.json index 82d8186..7d4785d 100644 --- a/crates/stack-theme/src/generated/catalog.json +++ b/crates/stack-theme/src/generated/catalog.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/catalog.schema.json", "schemaVersion": "1.0", - "catalogVersion": "0.4.0", + "catalogVersion": "0.5.0", "reservedThemeIds": [], "fallbacks": { "missingThemeId": "default", diff --git a/crates/stack-theme/src/generated/metadata.rs b/crates/stack-theme/src/generated/metadata.rs index 1fb3a51..039d538 100644 --- a/crates/stack-theme/src/generated/metadata.rs +++ b/crates/stack-theme/src/generated/metadata.rs @@ -1,6 +1,6 @@ // Generated by scripts/generate.mjs. Do not edit. -pub const CATALOG_VERSION: &str = "0.4.0"; -pub const CATALOG_REVISION: &str = "sha256:9cb3de8b504acbf22c93cea5fbea66be50f38734dc1dee18b9cab7084082cc1f"; +pub const CATALOG_VERSION: &str = "0.5.0"; +pub const CATALOG_REVISION: &str = "sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53"; pub fn icon_svg(asset_path: &str) -> Option<&'static str> { match asset_path { diff --git a/crates/stack-theme/src/lib.rs b/crates/stack-theme/src/lib.rs index 3a406df..3fb448d 100644 --- a/crates/stack-theme/src/lib.rs +++ b/crates/stack-theme/src/lib.rs @@ -298,6 +298,8 @@ pub struct ProviderPack { pub provider: ProviderPackIdentity, pub distribution_mode: ProviderPackDistributionMode, pub source: ProviderPackSource, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub additional_sources: Vec, pub rights: ProviderPackRights, pub notice: ProviderPackNotice, pub icons: Vec, @@ -335,6 +337,15 @@ pub struct ProviderPackSource { pub archive_license_included: bool, } +/// An additional audited archive used by a multi-source provider pack. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderPackAdditionalSource { + pub id: String, + #[serde(flatten)] + pub source: ProviderPackSource, +} + /// Provider-specific usage boundary retained with every imported pack. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -430,6 +441,8 @@ pub enum ProviderNodeKind { #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProviderIconAsset { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_id: Option, pub path: String, pub original_path: String, pub view_box: [i32; 4], @@ -498,4 +511,22 @@ mod tests { "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/provider-pack.schema.json" ); } + + #[test] + fn multi_source_provider_pack_round_trips_semantically() { + let source = include_str!("../../../tests/fixtures/provider-pack/multi-source.json"); + let pack: ProviderPack = serde_json::from_str(source).unwrap(); + + assert_eq!(pack.schema_version, "1.1"); + assert_eq!(pack.additional_sources.len(), 1); + assert_eq!(pack.additional_sources[0].id, "categories"); + assert_eq!( + pack.icons[0].asset.source_id.as_deref(), + Some("categories") + ); + assert_eq!( + serde_json::to_value(&pack).unwrap(), + serde_json::from_str::(source).unwrap() + ); + } } diff --git a/package-lock.json b/package-lock.json index 836586c..84428ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stack-theme-workspace", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stack-theme-workspace", - "version": "0.4.0", + "version": "0.5.0", "workspaces": [ "packages/theme" ], @@ -475,7 +475,7 @@ }, "packages/theme": { "name": "@stack-sh/theme", - "version": "0.4.0", + "version": "0.5.0", "license": "Apache-2.0" } } diff --git a/package.json b/package.json index 152696b..42b8f4d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stack-theme-workspace", "private": true, - "version": "0.4.0", + "version": "0.5.0", "workspaces": [ "packages/theme" ], diff --git a/packages/theme/catalog-metadata.json b/packages/theme/catalog-metadata.json index ba3a986..5c7670c 100644 --- a/packages/theme/catalog-metadata.json +++ b/packages/theme/catalog-metadata.json @@ -1,4 +1,4 @@ { - "catalogVersion": "0.4.0", - "catalogRevision": "sha256:9cb3de8b504acbf22c93cea5fbea66be50f38734dc1dee18b9cab7084082cc1f" + "catalogVersion": "0.5.0", + "catalogRevision": "sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53" } diff --git a/packages/theme/catalog.generated.js b/packages/theme/catalog.generated.js index bfc060e..1bd7279 100644 --- a/packages/theme/catalog.generated.js +++ b/packages/theme/catalog.generated.js @@ -2,7 +2,7 @@ const catalogData = { "$schema": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/catalog.schema.json", "schemaVersion": "1.0", - "catalogVersion": "0.4.0", + "catalogVersion": "0.5.0", "reservedThemeIds": [], "fallbacks": { "missingThemeId": "default", @@ -3754,7 +3754,10 @@ const providerPackSchemaData = { "type": "string" }, "schemaVersion": { - "const": "1.0" + "enum": [ + "1.0", + "1.1" + ] }, "packVersion": { "$ref": "#/$defs/semanticVersion" @@ -3768,6 +3771,14 @@ const providerPackSchemaData = { "source": { "$ref": "#/$defs/source" }, + "additionalSources": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { + "$ref": "#/$defs/additionalSource" + } + }, "rights": { "$ref": "#/$defs/rights" }, @@ -3792,6 +3803,10 @@ const providerPackSchemaData = { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,31}$" }, + "sourceId": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{1,31}$" + }, "namespacedIconId": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,31}:[a-z0-9][a-z0-9-]{0,63}$" @@ -3839,7 +3854,15 @@ const providerPackSchemaData = { }, "source": { "type": "object", - "additionalProperties": false, + "allOf": [ + { + "$ref": "#/$defs/sourceData" + } + ], + "unevaluatedProperties": false + }, + "sourceData": { + "type": "object", "required": [ "pageUrl", "archiveUrl", @@ -3892,6 +3915,26 @@ const providerPackSchemaData = { } } }, + "additionalSource": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/sourceData" + }, + { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "$ref": "#/$defs/sourceId" + } + } + } + ], + "unevaluatedProperties": false + }, "redistribution": { "type": "object", "additionalProperties": false, @@ -4074,6 +4117,9 @@ const providerPackSchemaData = { "transformations" ], "properties": { + "sourceId": { + "$ref": "#/$defs/sourceId" + }, "path": { "$ref": "#/$defs/relativePath" }, @@ -4207,8 +4253,8 @@ function deepFreeze(value) { export const catalog = deepFreeze(catalogData); export const providerPackSchema = deepFreeze(providerPackSchemaData); -export const catalogVersion = "0.4.0"; -export const catalogRevision = "sha256:9cb3de8b504acbf22c93cea5fbea66be50f38734dc1dee18b9cab7084082cc1f"; +export const catalogVersion = "0.5.0"; +export const catalogRevision = "sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53"; export const iconAssets = deepFreeze(iconAssetsData); export function iconSvg(assetPath) { return iconAssets[assetPath]; diff --git a/packages/theme/catalog.json b/packages/theme/catalog.json index 82d8186..7d4785d 100644 --- a/packages/theme/catalog.json +++ b/packages/theme/catalog.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/catalog.schema.json", "schemaVersion": "1.0", - "catalogVersion": "0.4.0", + "catalogVersion": "0.5.0", "reservedThemeIds": [], "fallbacks": { "missingThemeId": "default", diff --git a/packages/theme/index.d.ts b/packages/theme/index.d.ts index a5e1ac7..a185759 100644 --- a/packages/theme/index.d.ts +++ b/packages/theme/index.d.ts @@ -167,11 +167,12 @@ export type ProviderPackTransformation = export interface ProviderPack { readonly $schema: string; - readonly schemaVersion: "1.0"; + readonly schemaVersion: "1.0" | "1.1"; readonly packVersion: string; readonly provider: ProviderPackIdentity; readonly distributionMode: ProviderPackDistributionMode; readonly source: ProviderPackSource; + readonly additionalSources?: readonly ProviderPackAdditionalSource[]; readonly rights: ProviderPackRights; readonly notice: ProviderPackNotice; readonly icons: readonly ProviderIcon[]; @@ -196,6 +197,10 @@ export interface ProviderPackSource { readonly archiveLicenseIncluded: boolean; } +export interface ProviderPackAdditionalSource extends ProviderPackSource { + readonly id: string; +} + export interface ProviderPackRights { readonly termsAcceptanceRequired: true; readonly permittedOutputs: readonly ProviderPackPermittedOutput[]; @@ -237,6 +242,7 @@ export interface ProviderIcon { } export interface ProviderIconAsset { + readonly sourceId?: string; readonly path: string; readonly originalPath: string; readonly viewBox: readonly [number, number, number, number]; diff --git a/packages/theme/package.json b/packages/theme/package.json index fdf195d..570d9dc 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,6 +1,6 @@ { "name": "@stack-sh/theme", - "version": "0.4.0", + "version": "0.5.0", "description": "Canonical Stack theme catalog contract and embedded catalog data", "type": "module", "license": "Apache-2.0", diff --git a/packages/theme/schema/provider-pack.schema.json b/packages/theme/schema/provider-pack.schema.json index 85659ed..0d5dad8 100644 --- a/packages/theme/schema/provider-pack.schema.json +++ b/packages/theme/schema/provider-pack.schema.json @@ -18,11 +18,17 @@ ], "properties": { "$schema": { "type": "string" }, - "schemaVersion": { "const": "1.0" }, + "schemaVersion": { "enum": ["1.0", "1.1"] }, "packVersion": { "$ref": "#/$defs/semanticVersion" }, "provider": { "$ref": "#/$defs/provider" }, "distributionMode": { "const": "user-imported" }, "source": { "$ref": "#/$defs/source" }, + "additionalSources": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { "$ref": "#/$defs/additionalSource" } + }, "rights": { "$ref": "#/$defs/rights" }, "notice": { "$ref": "#/$defs/notice" }, "icons": { @@ -41,6 +47,10 @@ "type": "string", "pattern": "^[a-z][a-z0-9-]{1,31}$" }, + "sourceId": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{1,31}$" + }, "namespacedIconId": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,31}:[a-z0-9][a-z0-9-]{0,63}$" @@ -83,7 +93,11 @@ }, "source": { "type": "object", - "additionalProperties": false, + "allOf": [{ "$ref": "#/$defs/sourceData" }], + "unevaluatedProperties": false + }, + "sourceData": { + "type": "object", "required": [ "pageUrl", "archiveUrl", @@ -118,6 +132,20 @@ "archiveLicenseIncluded": { "type": "boolean" } } }, + "additionalSource": { + "type": "object", + "allOf": [ + { "$ref": "#/$defs/sourceData" }, + { + "type": "object", + "required": ["id"], + "properties": { + "id": { "$ref": "#/$defs/sourceId" } + } + } + ], + "unevaluatedProperties": false + }, "redistribution": { "type": "object", "additionalProperties": false, @@ -248,6 +276,7 @@ "transformations" ], "properties": { + "sourceId": { "$ref": "#/$defs/sourceId" }, "path": { "$ref": "#/$defs/relativePath" }, "originalPath": { "$ref": "#/$defs/relativePath" }, "viewBox": { "$ref": "#/$defs/viewBox" }, diff --git a/schemas/provider-pack.schema.json b/schemas/provider-pack.schema.json index 85659ed..0d5dad8 100644 --- a/schemas/provider-pack.schema.json +++ b/schemas/provider-pack.schema.json @@ -18,11 +18,17 @@ ], "properties": { "$schema": { "type": "string" }, - "schemaVersion": { "const": "1.0" }, + "schemaVersion": { "enum": ["1.0", "1.1"] }, "packVersion": { "$ref": "#/$defs/semanticVersion" }, "provider": { "$ref": "#/$defs/provider" }, "distributionMode": { "const": "user-imported" }, "source": { "$ref": "#/$defs/source" }, + "additionalSources": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { "$ref": "#/$defs/additionalSource" } + }, "rights": { "$ref": "#/$defs/rights" }, "notice": { "$ref": "#/$defs/notice" }, "icons": { @@ -41,6 +47,10 @@ "type": "string", "pattern": "^[a-z][a-z0-9-]{1,31}$" }, + "sourceId": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{1,31}$" + }, "namespacedIconId": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,31}:[a-z0-9][a-z0-9-]{0,63}$" @@ -83,7 +93,11 @@ }, "source": { "type": "object", - "additionalProperties": false, + "allOf": [{ "$ref": "#/$defs/sourceData" }], + "unevaluatedProperties": false + }, + "sourceData": { + "type": "object", "required": [ "pageUrl", "archiveUrl", @@ -118,6 +132,20 @@ "archiveLicenseIncluded": { "type": "boolean" } } }, + "additionalSource": { + "type": "object", + "allOf": [ + { "$ref": "#/$defs/sourceData" }, + { + "type": "object", + "required": ["id"], + "properties": { + "id": { "$ref": "#/$defs/sourceId" } + } + } + ], + "unevaluatedProperties": false + }, "redistribution": { "type": "object", "additionalProperties": false, @@ -248,6 +276,7 @@ "transformations" ], "properties": { + "sourceId": { "$ref": "#/$defs/sourceId" }, "path": { "$ref": "#/$defs/relativePath" }, "originalPath": { "$ref": "#/$defs/relativePath" }, "viewBox": { "$ref": "#/$defs/viewBox" }, diff --git a/scripts/catalog-lib.mjs b/scripts/catalog-lib.mjs index edbb386..8d3b0fc 100644 --- a/scripts/catalog-lib.mjs +++ b/scripts/catalog-lib.mjs @@ -447,9 +447,21 @@ export async function validateProviderPack( } const expectedPrefix = `${providerPack.provider.id}:`; + const additionalSourceIds = new Set(); + for (const source of providerPack.additionalSources ?? []) { + if (additionalSourceIds.has(source.id)) { + fail(`duplicate provider pack source id: ${source.id}`); + } + additionalSourceIds.add(source.id); + } const iconIds = new Set(); const assetPaths = new Set(); for (const icon of providerPack.icons) { + if (icon.asset.sourceId && !additionalSourceIds.has(icon.asset.sourceId)) { + fail( + `provider icon ${icon.id} references unknown source ${icon.asset.sourceId}`, + ); + } if (!icon.id.startsWith(expectedPrefix)) { fail( `provider icon ${icon.id} must use the ${providerPack.provider.id} namespace`, diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 27165e4..9903302 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -21,12 +21,10 @@ for (const entry of catalogs) { console.log(`validated ${entry.path}`); } -const providerPackRoot = path.join( - repositoryRoot, - "tests/fixtures/provider-pack", -); -await validateProviderPack( - await readJson(path.join(providerPackRoot, "valid.json")), - { root: providerPackRoot }, -); -console.log("validated tests/fixtures/provider-pack/valid.json"); +const providerPackRoot = path.join(repositoryRoot, "tests/fixtures/provider-pack"); +for (const fixture of ["valid.json", "multi-source.json"]) { + await validateProviderPack(await readJson(path.join(providerPackRoot, fixture)), { + root: providerPackRoot, + }); + console.log(`validated tests/fixtures/provider-pack/${fixture}`); +} diff --git a/tests/catalog.test.mjs b/tests/catalog.test.mjs index 6812669..947a5b8 100644 --- a/tests/catalog.test.mjs +++ b/tests/catalog.test.mjs @@ -35,12 +35,41 @@ async function providerPackFixture() { return readJson(path.join(providerPackRoot, "valid.json")); } +async function multiSourceProviderPackFixture() { + return readJson(path.join(providerPackRoot, "multi-source.json")); +} + test("the user-imported provider pack fixture is valid", async () => { await validateProviderPack(await providerPackFixture(), { root: providerPackRoot, }); }); +test("multi-source packs require unique declared source references", async () => { + const valid = await multiSourceProviderPackFixture(); + await validateProviderPack(valid, { root: providerPackRoot }); + + const duplicate = structuredClone(valid); + duplicate.additionalSources.push(structuredClone(duplicate.additionalSources[0])); + await assert.rejects( + validateProviderPack(duplicate, { + root: providerPackRoot, + validateAssets: false, + }), + /duplicate provider pack source id/, + ); + + const unknown = structuredClone(valid); + unknown.icons[0].asset.sourceId = "unknown"; + await assert.rejects( + validateProviderPack(unknown, { + root: providerPackRoot, + validateAssets: false, + }), + /references unknown source unknown/, + ); +}); + test("provider icon IDs must match the declared namespace", async () => { const fixture = await providerPackFixture(); fixture.icons[0].id = "other:object-storage"; diff --git a/tests/fixtures/provider-pack/multi-source.json b/tests/fixtures/provider-pack/multi-source.json new file mode 100644 index 0000000..624e8e4 --- /dev/null +++ b/tests/fixtures/provider-pack/multi-source.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/provider-pack.schema.json", + "schemaVersion": "1.1", + "packVersion": "0.2.0", + "provider": { + "id": "acme", + "name": "Acme Cloud" + }, + "distributionMode": "user-imported", + "source": { + "pageUrl": "https://example.com/acme/icons", + "archiveUrl": "https://example.com/acme/core-icons.zip", + "archiveSha256": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "release": "fixture-core-1", + "retrievedAt": "2026-09-04", + "termsUrl": "https://example.com/acme/icon-terms", + "termsReviewedAt": "2026-09-04", + "reviewAfter": "2026-12-03", + "copyright": "Copyright 2026 Acme Cloud", + "licenseId": "LicenseRef-Acme-Cloud-Icons", + "archiveLicenseIncluded": false + }, + "additionalSources": [ + { + "id": "categories", + "pageUrl": "https://example.com/acme/icons", + "archiveUrl": "https://example.com/acme/category-icons.zip", + "archiveSha256": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "release": "fixture-categories-1", + "retrievedAt": "2026-09-04", + "termsUrl": "https://example.com/acme/icon-terms", + "termsReviewedAt": "2026-09-04", + "reviewAfter": "2026-12-03", + "copyright": "Copyright 2026 Acme Cloud", + "licenseId": "LicenseRef-Acme-Cloud-Icons", + "archiveLicenseIncluded": false + } + ], + "rights": { + "termsAcceptanceRequired": true, + "permittedOutputs": ["architecture-diagram", "documentation"], + "redistribution": { + "cargo": false, + "npm": false, + "wasm": false, + "webAsset": false, + "nativeBinary": false, + "generatedOutput": true + }, + "processing": { + "localOnly": true, + "automaticDownload": false, + "serverUpload": false, + "preserveColors": true, + "preserveGeometry": true, + "productNameNearby": true + }, + "modificationPolicy": "visual-preservation-only" + }, + "notice": { + "attribution": "Acme Cloud icons are owned by Acme Cloud.", + "termsSummary": "Use is limited to architecture diagrams and documentation.", + "nonEndorsement": "Acme Cloud does not sponsor or endorse this diagram or Stack." + }, + "icons": [ + { + "id": "acme:object-storage", + "subject": "Object storage service", + "productName": "Acme Object Storage", + "recommendedNodeKind": "storage", + "asset": { + "sourceId": "categories", + "path": "assets/example.svg", + "originalPath": "Official Icons/Acme Object Storage.svg", + "viewBox": [0, 0, 24, 24], + "originalSha256": "sha256:bb77c20b241b47db4ca268ebee418f0f74e19bdb1a2c2e2fa271b7f65c2d447d", + "processedSha256": "sha256:bb77c20b241b47db4ca268ebee418f0f74e19bdb1a2c2e2fa271b7f65c2d447d", + "transformations": [] + } + } + ] +} From 0fe5e7dfdf26801d75549f34db32ec5c0d48e7e3 Mon Sep 17 00:00:00 2001 From: konojunya Date: Fri, 4 Sep 2026 21:19:51 +0900 Subject: [PATCH 2/3] Record per-icon brand guidance --- PROVIDER_PACKS.md | 4 ++-- crates/stack-theme/schema/provider-pack.schema.json | 2 ++ crates/stack-theme/src/lib.rs | 8 ++++++++ packages/theme/catalog.generated.js | 6 ++++++ packages/theme/index.d.ts | 2 ++ packages/theme/schema/provider-pack.schema.json | 2 ++ schemas/provider-pack.schema.json | 2 ++ tests/fixtures/provider-pack/multi-source.json | 2 ++ 8 files changed, 26 insertions(+), 2 deletions(-) diff --git a/PROVIDER_PACKS.md b/PROVIDER_PACKS.md index a7d4592..e3b50e0 100644 --- a/PROVIDER_PACKS.md +++ b/PROVIDER_PACKS.md @@ -20,11 +20,11 @@ Every manifest requires: The schema records `archiveUrl` as provenance. It is not permission for a runtime consumer to fetch the URL. Import remains an explicit user-selected local operation. -Version `1.1` additionally supports `additionalSources` for a provider catalog split across more than one official archive. Each additional source has a stable pack-local `id` and the same immutable provenance fields as the primary `source`. An icon from an additional archive records that `id` in `asset.sourceId`; omitting `sourceId` means the primary source. Source IDs must be unique and every reference must resolve. Version `1.0` single-source packs remain valid without changes. +Version `1.1` additionally supports `additionalSources` for a provider catalog split across more than one official archive. Each additional source has a stable pack-local `id` and the same immutable provenance fields as the primary `source`. An icon from an additional archive records that `id` in `asset.sourceId`; omitting `sourceId` means the primary source. Source IDs must be unique and every reference must resolve. Optional `brandSourceUrl` and `brandGuidelinesUrl` fields preserve the rights-owner references for a curated multi-brand archive. Version `1.0` single-source packs remain valid without changes. ## Icon records -An icon ID has the form `:`, such as `aws:s3`, `gcp:cloud-run`, or `azure:storage-accounts`. The prefix must equal `provider.id`. Each icon preserves a stable subject, official product name, recommended Stack node kind, source reference, upstream archive path, processed local SVG path, integer view box, original and processed SHA-256 hashes, and an ordered transformation log. +An icon ID has the form `:`, such as `aws:s3`, `gcp:cloud-run`, or `azure:storage-accounts`. The prefix must equal `provider.id`. Each icon preserves a stable subject, official product name, recommended Stack node kind, source reference, optional brand source and guideline URLs, upstream archive path, processed local SVG path, integer view box, original and processed SHA-256 hashes, and an ordered transformation log. The importer may perform only visual-preservation transformations needed for safe standalone SVG, such as removing metadata, converting stylesheet declarations to equivalent presentation attributes, removing unused identifiers, namespacing referenced gradient identifiers to prevent collisions, or normalizing XML. Recoloring, cropping, flipping, rotation, distortion, product substitution, or aspect-ratio changes are outside the contract. diff --git a/crates/stack-theme/schema/provider-pack.schema.json b/crates/stack-theme/schema/provider-pack.schema.json index 0d5dad8..e549435 100644 --- a/crates/stack-theme/schema/provider-pack.schema.json +++ b/crates/stack-theme/schema/provider-pack.schema.json @@ -311,6 +311,8 @@ "minLength": 1, "maxLength": 160 }, + "brandSourceUrl": { "$ref": "#/$defs/httpsUrl" }, + "brandGuidelinesUrl": { "$ref": "#/$defs/httpsUrl" }, "recommendedNodeKind": { "$ref": "#/$defs/nodeKind" }, "asset": { "$ref": "#/$defs/iconAsset" } } diff --git a/crates/stack-theme/src/lib.rs b/crates/stack-theme/src/lib.rs index 3fb448d..dac0a14 100644 --- a/crates/stack-theme/src/lib.rs +++ b/crates/stack-theme/src/lib.rs @@ -417,6 +417,10 @@ pub struct ProviderIcon { pub id: String, pub subject: String, pub product_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub brand_source_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub brand_guidelines_url: Option, pub recommended_node_kind: ProviderNodeKind, pub asset: ProviderIconAsset, } @@ -524,6 +528,10 @@ mod tests { pack.icons[0].asset.source_id.as_deref(), Some("categories") ); + assert_eq!( + pack.icons[0].brand_guidelines_url.as_deref(), + Some("https://example.com/acme/brand-guidelines") + ); assert_eq!( serde_json::to_value(&pack).unwrap(), serde_json::from_str::(source).unwrap() diff --git a/packages/theme/catalog.generated.js b/packages/theme/catalog.generated.js index 1bd7279..dc247d9 100644 --- a/packages/theme/catalog.generated.js +++ b/packages/theme/catalog.generated.js @@ -4168,6 +4168,12 @@ const providerPackSchemaData = { "minLength": 1, "maxLength": 160 }, + "brandSourceUrl": { + "$ref": "#/$defs/httpsUrl" + }, + "brandGuidelinesUrl": { + "$ref": "#/$defs/httpsUrl" + }, "recommendedNodeKind": { "$ref": "#/$defs/nodeKind" }, diff --git a/packages/theme/index.d.ts b/packages/theme/index.d.ts index a185759..10d39f4 100644 --- a/packages/theme/index.d.ts +++ b/packages/theme/index.d.ts @@ -237,6 +237,8 @@ export interface ProviderIcon { readonly id: `${string}:${string}`; readonly subject: string; readonly productName: string; + readonly brandSourceUrl?: `https://${string}`; + readonly brandGuidelinesUrl?: `https://${string}`; readonly recommendedNodeKind: keyof NodeKindFallbacks; readonly asset: ProviderIconAsset; } diff --git a/packages/theme/schema/provider-pack.schema.json b/packages/theme/schema/provider-pack.schema.json index 0d5dad8..e549435 100644 --- a/packages/theme/schema/provider-pack.schema.json +++ b/packages/theme/schema/provider-pack.schema.json @@ -311,6 +311,8 @@ "minLength": 1, "maxLength": 160 }, + "brandSourceUrl": { "$ref": "#/$defs/httpsUrl" }, + "brandGuidelinesUrl": { "$ref": "#/$defs/httpsUrl" }, "recommendedNodeKind": { "$ref": "#/$defs/nodeKind" }, "asset": { "$ref": "#/$defs/iconAsset" } } diff --git a/schemas/provider-pack.schema.json b/schemas/provider-pack.schema.json index 0d5dad8..e549435 100644 --- a/schemas/provider-pack.schema.json +++ b/schemas/provider-pack.schema.json @@ -311,6 +311,8 @@ "minLength": 1, "maxLength": 160 }, + "brandSourceUrl": { "$ref": "#/$defs/httpsUrl" }, + "brandGuidelinesUrl": { "$ref": "#/$defs/httpsUrl" }, "recommendedNodeKind": { "$ref": "#/$defs/nodeKind" }, "asset": { "$ref": "#/$defs/iconAsset" } } diff --git a/tests/fixtures/provider-pack/multi-source.json b/tests/fixtures/provider-pack/multi-source.json index 624e8e4..9d59ff1 100644 --- a/tests/fixtures/provider-pack/multi-source.json +++ b/tests/fixtures/provider-pack/multi-source.json @@ -67,6 +67,8 @@ "id": "acme:object-storage", "subject": "Object storage service", "productName": "Acme Object Storage", + "brandSourceUrl": "https://example.com/acme/storage", + "brandGuidelinesUrl": "https://example.com/acme/brand-guidelines", "recommendedNodeKind": "storage", "asset": { "sourceId": "categories", From 13e41b84167e810c36c3ab87fff2e1fda84a2b8f Mon Sep 17 00:00:00 2001 From: konojunya Date: Fri, 4 Sep 2026 21:48:25 +0900 Subject: [PATCH 3/3] Document integer view-box normalization --- PROVIDER_PACKS.md | 4 ++-- crates/stack-theme/schema/provider-pack.schema.json | 1 + crates/stack-theme/src/lib.rs | 6 ++---- packages/theme/catalog.generated.js | 1 + packages/theme/index.d.ts | 1 + packages/theme/schema/provider-pack.schema.json | 1 + schemas/provider-pack.schema.json | 1 + 7 files changed, 9 insertions(+), 6 deletions(-) diff --git a/PROVIDER_PACKS.md b/PROVIDER_PACKS.md index e3b50e0..048d573 100644 --- a/PROVIDER_PACKS.md +++ b/PROVIDER_PACKS.md @@ -26,9 +26,9 @@ Version `1.1` additionally supports `additionalSources` for a provider catalog s An icon ID has the form `:`, such as `aws:s3`, `gcp:cloud-run`, or `azure:storage-accounts`. The prefix must equal `provider.id`. Each icon preserves a stable subject, official product name, recommended Stack node kind, source reference, optional brand source and guideline URLs, upstream archive path, processed local SVG path, integer view box, original and processed SHA-256 hashes, and an ordered transformation log. -The importer may perform only visual-preservation transformations needed for safe standalone SVG, such as removing metadata, converting stylesheet declarations to equivalent presentation attributes, removing unused identifiers, namespacing referenced gradient identifiers to prevent collisions, or normalizing XML. Recoloring, cropping, flipping, rotation, distortion, product substitution, or aspect-ratio changes are outside the contract. +The importer may perform only visual-preservation transformations needed for safe standalone SVG, such as removing metadata, converting stylesheet declarations to equivalent presentation attributes, removing unused identifiers, namespacing referenced local resource identifiers to prevent collisions, scaling a finite decimal view box and all coordinates by the same power of ten to obtain the contract's integer view box, or normalizing XML. Recoloring, cropping, flipping, rotation, distortion, product substitution, or aspect-ratio changes are outside the contract. -An empty transformation list requires identical original and processed hashes. A changed hash requires at least one declared transformation. The processed SVG must pass the same script, event-handler, external-reference, executable URL, and viewport safety checks as core assets. Gradients may use only locally declared, `stack-`-namespaced identifiers; stylesheets and external references remain forbidden. +An empty transformation list requires identical original and processed hashes. A changed hash requires at least one declared transformation. The processed SVG must pass the same script, event-handler, external-reference, executable URL, and viewport safety checks as core assets. Gradients, clip paths, and masks may use only locally declared, `stack-`-namespaced identifiers; stylesheets and external references remain forbidden. ## Terms and output diff --git a/crates/stack-theme/schema/provider-pack.schema.json b/crates/stack-theme/schema/provider-pack.schema.json index e549435..fb1a99b 100644 --- a/crates/stack-theme/schema/provider-pack.schema.json +++ b/crates/stack-theme/schema/provider-pack.schema.json @@ -261,6 +261,7 @@ "inline-styles", "remove-unused-identifiers", "namespace-identifiers", + "scale-view-box-to-integers", "normalize-xml" ] }, diff --git a/crates/stack-theme/src/lib.rs b/crates/stack-theme/src/lib.rs index dac0a14..1d73a0f 100644 --- a/crates/stack-theme/src/lib.rs +++ b/crates/stack-theme/src/lib.rs @@ -463,6 +463,7 @@ pub enum ProviderPackTransformation { InlineStyles, RemoveUnusedIdentifiers, NamespaceIdentifiers, + ScaleViewBoxToIntegers, NormalizeXml, } @@ -524,10 +525,7 @@ mod tests { assert_eq!(pack.schema_version, "1.1"); assert_eq!(pack.additional_sources.len(), 1); assert_eq!(pack.additional_sources[0].id, "categories"); - assert_eq!( - pack.icons[0].asset.source_id.as_deref(), - Some("categories") - ); + assert_eq!(pack.icons[0].asset.source_id.as_deref(), Some("categories")); assert_eq!( pack.icons[0].brand_guidelines_url.as_deref(), Some("https://example.com/acme/brand-guidelines") diff --git a/packages/theme/catalog.generated.js b/packages/theme/catalog.generated.js index dc247d9..05ae691 100644 --- a/packages/theme/catalog.generated.js +++ b/packages/theme/catalog.generated.js @@ -4102,6 +4102,7 @@ const providerPackSchemaData = { "inline-styles", "remove-unused-identifiers", "namespace-identifiers", + "scale-view-box-to-integers", "normalize-xml" ] }, diff --git a/packages/theme/index.d.ts b/packages/theme/index.d.ts index 10d39f4..784ea12 100644 --- a/packages/theme/index.d.ts +++ b/packages/theme/index.d.ts @@ -163,6 +163,7 @@ export type ProviderPackTransformation = | "inline-styles" | "remove-unused-identifiers" | "namespace-identifiers" + | "scale-view-box-to-integers" | "normalize-xml"; export interface ProviderPack { diff --git a/packages/theme/schema/provider-pack.schema.json b/packages/theme/schema/provider-pack.schema.json index e549435..fb1a99b 100644 --- a/packages/theme/schema/provider-pack.schema.json +++ b/packages/theme/schema/provider-pack.schema.json @@ -261,6 +261,7 @@ "inline-styles", "remove-unused-identifiers", "namespace-identifiers", + "scale-view-box-to-integers", "normalize-xml" ] }, diff --git a/schemas/provider-pack.schema.json b/schemas/provider-pack.schema.json index e549435..fb1a99b 100644 --- a/schemas/provider-pack.schema.json +++ b/schemas/provider-pack.schema.json @@ -261,6 +261,7 @@ "inline-styles", "remove-unused-identifiers", "namespace-identifiers", + "scale-view-box-to-integers", "normalize-xml" ] },