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..048d573 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,20 +13,22 @@ 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. 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, 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. +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/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..fb1a99b 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, @@ -233,6 +261,7 @@ "inline-styles", "remove-unused-identifiers", "namespace-identifiers", + "scale-view-box-to-integers", "normalize-xml" ] }, @@ -248,6 +277,7 @@ "transformations" ], "properties": { + "sourceId": { "$ref": "#/$defs/sourceId" }, "path": { "$ref": "#/$defs/relativePath" }, "originalPath": { "$ref": "#/$defs/relativePath" }, "viewBox": { "$ref": "#/$defs/viewBox" }, @@ -282,6 +312,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/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..1d73a0f 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")] @@ -406,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, } @@ -430,6 +445,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], @@ -446,6 +463,7 @@ pub enum ProviderPackTransformation { InlineStyles, RemoveUnusedIdentifiers, NamespaceIdentifiers, + ScaleViewBoxToIntegers, NormalizeXml, } @@ -498,4 +516,23 @@ 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!( + 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/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..05ae691 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, @@ -4059,6 +4102,7 @@ const providerPackSchemaData = { "inline-styles", "remove-unused-identifiers", "namespace-identifiers", + "scale-view-box-to-integers", "normalize-xml" ] }, @@ -4074,6 +4118,9 @@ const providerPackSchemaData = { "transformations" ], "properties": { + "sourceId": { + "$ref": "#/$defs/sourceId" + }, "path": { "$ref": "#/$defs/relativePath" }, @@ -4122,6 +4169,12 @@ const providerPackSchemaData = { "minLength": 1, "maxLength": 160 }, + "brandSourceUrl": { + "$ref": "#/$defs/httpsUrl" + }, + "brandGuidelinesUrl": { + "$ref": "#/$defs/httpsUrl" + }, "recommendedNodeKind": { "$ref": "#/$defs/nodeKind" }, @@ -4207,8 +4260,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..784ea12 100644 --- a/packages/theme/index.d.ts +++ b/packages/theme/index.d.ts @@ -163,15 +163,17 @@ export type ProviderPackTransformation = | "inline-styles" | "remove-unused-identifiers" | "namespace-identifiers" + | "scale-view-box-to-integers" | "normalize-xml"; 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 +198,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[]; @@ -232,11 +238,14 @@ 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; } 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..fb1a99b 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, @@ -233,6 +261,7 @@ "inline-styles", "remove-unused-identifiers", "namespace-identifiers", + "scale-view-box-to-integers", "normalize-xml" ] }, @@ -248,6 +277,7 @@ "transformations" ], "properties": { + "sourceId": { "$ref": "#/$defs/sourceId" }, "path": { "$ref": "#/$defs/relativePath" }, "originalPath": { "$ref": "#/$defs/relativePath" }, "viewBox": { "$ref": "#/$defs/viewBox" }, @@ -282,6 +312,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 85659ed..fb1a99b 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, @@ -233,6 +261,7 @@ "inline-styles", "remove-unused-identifiers", "namespace-identifiers", + "scale-view-box-to-integers", "normalize-xml" ] }, @@ -248,6 +277,7 @@ "transformations" ], "properties": { + "sourceId": { "$ref": "#/$defs/sourceId" }, "path": { "$ref": "#/$defs/relativePath" }, "originalPath": { "$ref": "#/$defs/relativePath" }, "viewBox": { "$ref": "#/$defs/viewBox" }, @@ -282,6 +312,8 @@ "minLength": 1, "maxLength": 160 }, + "brandSourceUrl": { "$ref": "#/$defs/httpsUrl" }, + "brandGuidelinesUrl": { "$ref": "#/$defs/httpsUrl" }, "recommendedNodeKind": { "$ref": "#/$defs/nodeKind" }, "asset": { "$ref": "#/$defs/iconAsset" } } 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..9d59ff1 --- /dev/null +++ b/tests/fixtures/provider-pack/multi-source.json @@ -0,0 +1,84 @@ +{ + "$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", + "brandSourceUrl": "https://example.com/acme/storage", + "brandGuidelinesUrl": "https://example.com/acme/brand-guidelines", + "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": [] + } + } + ] +}