From fb1d8fbcd1dc80c4c818c51ac38322298f84b8cf Mon Sep 17 00:00:00 2001 From: konojunya Date: Fri, 4 Sep 2026 09:35:06 +0900 Subject: [PATCH] Add user-imported provider pack contract --- .github/workflows/ci.yaml | 1 + AGENTS.md | 5 +- CONTRACT.md | 4 +- Cargo.lock | 2 +- Cargo.toml | 2 +- PROVIDER_PACKS.md | 51 +++ README.md | 13 +- catalog/catalog.json | 2 +- .../schema/provider-pack.schema.json | 289 +++++++++++++ crates/stack-theme/src/generated/catalog.json | 2 +- crates/stack-theme/src/generated/metadata.rs | 4 +- crates/stack-theme/src/lib.rs | 174 ++++++++ package-lock.json | 6 +- package.json | 2 +- packages/theme/README.md | 6 +- packages/theme/catalog-metadata.json | 4 +- packages/theme/catalog.generated.js | 407 +++++++++++++++++- packages/theme/catalog.json | 2 +- packages/theme/index.d.ts | 98 +++++ packages/theme/index.js | 1 + packages/theme/package.json | 5 +- .../theme/schema/provider-pack.schema.json | 289 +++++++++++++ schemas/provider-pack.schema.json | 289 +++++++++++++ scripts/catalog-lib.mjs | 70 +++ scripts/generate.mjs | 9 +- scripts/validate.mjs | 17 +- tests/catalog.test.mjs | 83 ++++ .../fixtures/provider-pack/assets/example.svg | 3 + tests/fixtures/provider-pack/valid.json | 68 +++ tests/types.test.ts | 7 + 30 files changed, 1884 insertions(+), 31 deletions(-) create mode 100644 PROVIDER_PACKS.md create mode 100644 crates/stack-theme/schema/provider-pack.schema.json create mode 100644 packages/theme/schema/provider-pack.schema.json create mode 100644 schemas/provider-pack.schema.json create mode 100644 tests/fixtures/provider-pack/assets/example.svg create mode 100644 tests/fixtures/provider-pack/valid.json diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c2111b6..2183359 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -68,6 +68,7 @@ jobs: test -s README.md test -s AGENTS.md test -s CONTRACT.md + test -s PROVIDER_PACKS.md test -s LICENSE test -s THIRD_PARTY_LICENSES.md diff --git a/AGENTS.md b/AGENTS.md index eba247d..59bebd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Write repository content, code comments, commit messages, issues, and pull reque ## Scope -- Keep this repository limited to the public core theme catalog, icons, font metrics, and their Rust and npm distribution artifacts. +- Keep this repository limited to the public core theme catalog, icons, font metrics, user-imported provider-pack contract, and their Rust and npm distribution artifacts. - Keep parsing, validation, layout, and SVG rendering in their owning repositories. - Do not add authentication, billing, entitlement, network access, paid themes, or customer data. - Do not publish a theme schema or compatibility promise before its public contract and conformance data are reviewed. @@ -16,7 +16,8 @@ Write repository content, code comments, commit messages, issues, and pull reque - Treat repository-authored work as Apache-2.0 unless a file explicitly states otherwise. - Before adding a third-party font, icon, or asset, record its source, copyright holder, exact license, required license text, attribution, modification status, and redistribution conditions in `THIRD_PARTY_LICENSES.md`. - Do not commit an asset with unclear provenance, incompatible terms, or restrictions that prevent the intended Cargo, npm, WASM, or commercial application distribution. -- Keep paid or proprietary theme bundles outside this public repository. +- Keep vendor assets with custom terms out of the core catalog and generated packages. A user-imported provider pack may reference local assets only when its manifest preserves source, terms, hashes, artwork policy, and notice data. +- Do not add paid or proprietary theme delivery. ## Delivery diff --git a/CONTRACT.md b/CONTRACT.md index 6b1286e..1024fd1 100644 --- a/CONTRACT.md +++ b/CONTRACT.md @@ -2,7 +2,7 @@ ## Status -This document defines the draft `1.0` catalog document shape. Catalog and package version `0.2.0` remain pre-1.0 and may change incompatibly before a stable release. Registry publication is outside this repository change. +This document defines the draft `1.0` core catalog document shape. Catalog and package version `0.3.0` remain pre-1.0 and may change incompatibly before a stable release. Registry publication is outside this repository change. User-imported provider packs use the separate contract in [`PROVIDER_PACKS.md`](./PROVIDER_PACKS.md); their custom-term assets never enter this core catalog. The JSON Schema at [`schemas/catalog.schema.json`](./schemas/catalog.schema.json) is the machine-readable source of truth. [`catalog/catalog.json`](./catalog/catalog.json) is the only source catalog. Generated Cargo and npm copies must not be edited directly. The schema is copied into both packages; `$schema` is an editor-facing canonical repository URL and runtime consumers do not fetch it. @@ -69,7 +69,7 @@ SVG validation uses an element and attribute allowlist. It rejects scripts, even ## Cargo and npm boundary -Cargo `stack-theme` exposes typed Rust records, `catalog()`, `catalog_json()`, `catalog_schema_json()`, `icon_svg()`, `CATALOG_VERSION`, and `CATALOG_REVISION`. npm `@stack-sh/theme` exposes the equivalent frozen `catalog`, `iconAssets`, `iconSvg()`, `catalogVersion`, and `catalogRevision`, plus TypeScript declarations and catalog, metadata, and schema JSON subpath exports. Referenced SVG and license files are copied into both package roots; SVG bytes are also embedded behind the Rust and JavaScript accessors so runtime consumers never resolve catalog paths through the host. +Cargo `stack-theme` exposes typed Rust records, `catalog()`, `catalog_json()`, `catalog_schema_json()`, `provider_pack_schema_json()`, `icon_svg()`, `CATALOG_VERSION`, and `CATALOG_REVISION`. npm `@stack-sh/theme` exposes the equivalent frozen `catalog`, `providerPackSchema`, `iconAssets`, `iconSvg()`, `catalogVersion`, and `catalogRevision`, plus TypeScript declarations and both schema JSON subpath exports. Referenced core SVG and license files are copied into both package roots; core SVG bytes are also embedded behind the Rust and JavaScript accessors so runtime consumers never resolve catalog paths through the host. Provider-pack asset bytes are supplied by the user and are not embedded. `npm run generate` validates the source catalog, checks package version equality, computes one revision, and updates both package artifacts. `npm run generate:check` fails when a generated artifact is missing or stale. Generated package data is checked into Git so Cargo and npm builds do not need network, filesystem discovery, Git, a clock, locale, or host font measurement at runtime. diff --git a/Cargo.lock b/Cargo.lock index 2722201..83d4517 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,7 +77,7 @@ dependencies = [ [[package]] name = "stack-theme" -version = "0.2.0" +version = "0.3.0" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 63733da..0c00418 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.2.0" +version = "0.3.0" [workspace.dependencies] serde = { version = "1.0", features = ["derive"] } diff --git a/PROVIDER_PACKS.md b/PROVIDER_PACKS.md new file mode 100644 index 0000000..cf7b6ac --- /dev/null +++ b/PROVIDER_PACKS.md @@ -0,0 +1,51 @@ +# User-imported provider icon packs + +## 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. + +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. + +## Manifest identity + +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; +- 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. + +## 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. + +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, 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. + +## Terms and output + +`rights.permittedOutputs` is copied from the reviewed provider terms rather than inferred from Stack's Apache-2.0 license. The manifest requires explicit acceptance of those linked terms. A renderer may embed an icon only in a listed output type and must preserve the official artwork. + +Rendered output should record provider ID, icon ID, official product name, upstream release, archive hash, terms URL, and pack revision. The CLI should emit a human-readable notice sidecar containing the manifest notice and every used asset. The product name should appear near the icon when the provider guidance recommends or requires it. + +Terms can change independently of a pack. Hosts must display the review date and source link, warn after `reviewAfter`, and allow a pack to be removed without changing the core catalog. A provider pack never overrides a core icon ID or another provider namespace. + +## Validation + +Repository validation covers JSON Schema shape, namespace equality, duplicate IDs and paths, source and processed hashes, transformation evidence, path containment, file presence, SVG safety, and Cargo/npm schema equality. No test fixture uses a real provider asset. + +Run: + +```sh +npm run validate +npm test +npm run generate:check +npm run typecheck +cargo test --workspace --locked +``` diff --git a/README.md b/README.md index a78b2b3..ab78a93 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # Stack Theme -`stack-sh/theme` is the canonical public catalog for Stack diagram themes, icons, and font metrics. +`stack-sh/theme` is the canonical public contract for Stack diagram themes, icons, font metrics, and local provider icon packs. -The draft catalog contract is defined by [`CONTRACT.md`](./CONTRACT.md) and [`schemas/catalog.schema.json`](./schemas/catalog.schema.json). The canonical source is [`catalog/catalog.json`](./catalog/catalog.json); Cargo and npm artifacts are generated from that source with one content revision. +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.2.0` catalog contains the core `default`, `light`, and `dark` themes, repository-authored fallbacks for every Stack 1.0 node kind, 12 provider-neutral explicit icons, and versioned host-independent font metrics. The explicit icon identifiers are `api`, `web`, `mobile`, `desktop`, `server`, `container`, `cluster`, `cloud`, `scheduler`, `webhook`, `identity`, and `observability`. Neither package has been released to a registry yet. +The current `0.3.0` catalog contains the core `default`, `light`, and `dark` themes, repository-authored fallbacks for every Stack 1.0 node kind, 12 provider-neutral explicit icons, and versioned host-independent font metrics. The explicit icon identifiers are `api`, `web`, `mobile`, `desktop`, `server`, `container`, `cluster`, `cloud`, `scheduler`, `webhook`, `identity`, and `observability`. Neither package has been released to a registry yet. + +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. ## Scope @@ -14,10 +16,11 @@ This repository will own: - icon metadata and SVG-safe icon assets; - deterministic font metrics used by the layout engine; - equivalent Rust and npm artifacts generated from the same catalog data. +- a provider-neutral manifest and validation contract for user-imported vendor icons. It does not own the Stack language, compilation, layout, SVG rendering, user authentication, billing, entitlement checks, or paid-theme delivery. -Only free core themes belong in this public repository. Paid or proprietary theme bundles must be stored and distributed separately under their own terms. +Only free, repository-authored core themes and assets belong in this public repository. Vendor asset bytes, paid themes, and proprietary delivery do not. ## Development @@ -42,4 +45,4 @@ The local [core theme review](./review/index.html) renders every fallback withou Repository-authored source code, catalog data, and assets are licensed under the [Apache License 2.0](./LICENSE). -Third-party fonts, icons, and other assets keep their own licenses and are not relicensed under Apache-2.0. Their provenance and redistribution terms must be recorded in [THIRD_PARTY_LICENSES.md](./THIRD_PARTY_LICENSES.md) before they are committed. +Third-party fonts, icons, and other assets keep their own licenses and are not relicensed under Apache-2.0. Their provenance and redistribution terms must be recorded in [THIRD_PARTY_LICENSES.md](./THIRD_PARTY_LICENSES.md) before they are committed. Current provider icons are user-imported and are never committed or copied into the Cargo or npm packages. diff --git a/catalog/catalog.json b/catalog/catalog.json index ba3ee98..2f41e09 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.2.0", + "catalogVersion": "0.3.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 new file mode 100644 index 0000000..5e19271 --- /dev/null +++ b/crates/stack-theme/schema/provider-pack.schema.json @@ -0,0 +1,289 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/provider-pack.schema.json", + "title": "Stack user-imported provider icon pack", + "description": "A local provider icon pack produced from an archive explicitly selected by the user.", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "schemaVersion", + "packVersion", + "provider", + "distributionMode", + "source", + "rights", + "notice", + "icons" + ], + "properties": { + "$schema": { "type": "string" }, + "schemaVersion": { "const": "1.0" }, + "packVersion": { "$ref": "#/$defs/semanticVersion" }, + "provider": { "$ref": "#/$defs/provider" }, + "distributionMode": { "const": "user-imported" }, + "source": { "$ref": "#/$defs/source" }, + "rights": { "$ref": "#/$defs/rights" }, + "notice": { "$ref": "#/$defs/notice" }, + "icons": { + "type": "array", + "minItems": 1, + "maxItems": 10000, + "items": { "$ref": "#/$defs/icon" } + } + }, + "$defs": { + "semanticVersion": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$" + }, + "providerId": { + "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}$" + }, + "relativePath": { + "type": "string", + "minLength": 1, + "maxLength": 320, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[^\\u0000]+$" + }, + "httpsUrl": { + "type": "string", + "pattern": "^https://[^\\s]+$" + }, + "sha256": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "date": { + "type": "string", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])$" + }, + "nonEmptyText": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "provider": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name"], + "properties": { + "id": { "$ref": "#/$defs/providerId" }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + } + } + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": [ + "pageUrl", + "archiveUrl", + "archiveSha256", + "release", + "retrievedAt", + "termsUrl", + "termsReviewedAt", + "reviewAfter", + "copyright", + "licenseId", + "archiveLicenseIncluded" + ], + "properties": { + "pageUrl": { "$ref": "#/$defs/httpsUrl" }, + "archiveUrl": { "$ref": "#/$defs/httpsUrl" }, + "archiveSha256": { "$ref": "#/$defs/sha256" }, + "release": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "retrievedAt": { "$ref": "#/$defs/date" }, + "termsUrl": { "$ref": "#/$defs/httpsUrl" }, + "termsReviewedAt": { "$ref": "#/$defs/date" }, + "reviewAfter": { "$ref": "#/$defs/date" }, + "copyright": { "$ref": "#/$defs/nonEmptyText" }, + "licenseId": { + "type": "string", + "pattern": "^LicenseRef-[A-Za-z0-9.-]+$" + }, + "archiveLicenseIncluded": { "type": "boolean" } + } + }, + "redistribution": { + "type": "object", + "additionalProperties": false, + "required": [ + "cargo", + "npm", + "wasm", + "webAsset", + "nativeBinary", + "generatedOutput" + ], + "properties": { + "cargo": { "const": false }, + "npm": { "const": false }, + "wasm": { "const": false }, + "webAsset": { "const": false }, + "nativeBinary": { "const": false }, + "generatedOutput": { "const": true } + } + }, + "processing": { + "type": "object", + "additionalProperties": false, + "required": [ + "localOnly", + "automaticDownload", + "serverUpload", + "preserveColors", + "preserveGeometry", + "productNameNearby" + ], + "properties": { + "localOnly": { "const": true }, + "automaticDownload": { "const": false }, + "serverUpload": { "const": false }, + "preserveColors": { "const": true }, + "preserveGeometry": { "const": true }, + "productNameNearby": { "type": "boolean" } + } + }, + "rights": { + "type": "object", + "additionalProperties": false, + "required": [ + "termsAcceptanceRequired", + "permittedOutputs", + "redistribution", + "processing", + "modificationPolicy" + ], + "properties": { + "termsAcceptanceRequired": { "const": true }, + "permittedOutputs": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "architecture-diagram", + "training-material", + "documentation", + "whitepaper", + "presentation", + "data-sheet", + "poster" + ] + } + }, + "redistribution": { "$ref": "#/$defs/redistribution" }, + "processing": { "$ref": "#/$defs/processing" }, + "modificationPolicy": { "const": "visual-preservation-only" } + } + }, + "notice": { + "type": "object", + "additionalProperties": false, + "required": ["attribution", "termsSummary", "nonEndorsement"], + "properties": { + "attribution": { "$ref": "#/$defs/nonEmptyText" }, + "termsSummary": { "$ref": "#/$defs/nonEmptyText" }, + "nonEndorsement": { "$ref": "#/$defs/nonEmptyText" } + } + }, + "nodeKind": { + "enum": [ + "actor", + "client", + "service", + "function", + "worker", + "database", + "cache", + "queue", + "storage", + "external" + ] + }, + "viewBox": { + "type": "array", + "prefixItems": [ + { "type": "integer" }, + { "type": "integer" }, + { "type": "integer", "minimum": 1 }, + { "type": "integer", "minimum": 1 } + ], + "items": false, + "minItems": 4, + "maxItems": 4 + }, + "transformation": { + "enum": [ + "remove-metadata", + "inline-styles", + "remove-unused-identifiers", + "normalize-xml" + ] + }, + "iconAsset": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "originalPath", + "viewBox", + "originalSha256", + "processedSha256", + "transformations" + ], + "properties": { + "path": { "$ref": "#/$defs/relativePath" }, + "originalPath": { "$ref": "#/$defs/relativePath" }, + "viewBox": { "$ref": "#/$defs/viewBox" }, + "originalSha256": { "$ref": "#/$defs/sha256" }, + "processedSha256": { "$ref": "#/$defs/sha256" }, + "transformations": { + "type": "array", + "uniqueItems": true, + "items": { "$ref": "#/$defs/transformation" } + } + } + }, + "icon": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "subject", + "productName", + "recommendedNodeKind", + "asset" + ], + "properties": { + "id": { "$ref": "#/$defs/namespacedIconId" }, + "subject": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "productName": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "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 055c96d..ed7ee96 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.2.0", + "catalogVersion": "0.3.0", "reservedThemeIds": [], "fallbacks": { "missingThemeId": "default", diff --git a/crates/stack-theme/src/generated/metadata.rs b/crates/stack-theme/src/generated/metadata.rs index dc9ea83..16dd579 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.2.0"; -pub const CATALOG_REVISION: &str = "sha256:d3a8a5a9d2100e496af3fd7adf389788f4a77508bf749a108183a2abf8f681e1"; +pub const CATALOG_VERSION: &str = "0.3.0"; +pub const CATALOG_REVISION: &str = "sha256:e4eaad0813fcfef4a203e861909ff38833270646f9097155974c7c92108c5b1e"; 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 98aa726..95b2fd4 100644 --- a/crates/stack-theme/src/lib.rs +++ b/crates/stack-theme/src/lib.rs @@ -16,6 +16,7 @@ pub use generated::{CATALOG_REVISION, CATALOG_VERSION}; const CATALOG_JSON: &str = include_str!("generated/catalog.json"); const CATALOG_SCHEMA_JSON: &str = include_str!("../schema/catalog.schema.json"); +const PROVIDER_PACK_SCHEMA_JSON: &str = include_str!("../schema/provider-pack.schema.json"); static CATALOG: OnceLock = OnceLock::new(); /// Returns the embedded catalog parsed into the public Rust contract. @@ -38,6 +39,12 @@ pub const fn catalog_schema_json() -> &'static str { CATALOG_SCHEMA_JSON } +/// Returns the JSON Schema for local user-imported provider icon packs. +#[must_use] +pub const fn provider_pack_schema_json() -> &'static str { + PROVIDER_PACK_SCHEMA_JSON +} + /// Returns one validated SVG asset by its catalog path. /// /// The bytes are embedded at compile time; this function never reads the host @@ -280,6 +287,167 @@ pub struct Redistribution { pub commercial_applications: bool, } +/// A local provider icon pack produced from an archive selected by the user. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderPack { + #[serde(rename = "$schema")] + pub schema: String, + pub schema_version: String, + pub pack_version: String, + pub provider: ProviderPackIdentity, + pub distribution_mode: ProviderPackDistributionMode, + pub source: ProviderPackSource, + pub rights: ProviderPackRights, + pub notice: ProviderPackNotice, + pub icons: Vec, +} + +/// Stable provider namespace and display name. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderPackIdentity { + pub id: String, + pub name: String, +} + +/// Provider packs are always supplied through an explicit local import. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProviderPackDistributionMode { + UserImported, +} + +/// Immutable provenance for the official source archive and reviewed terms. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderPackSource { + pub page_url: String, + pub archive_url: String, + pub archive_sha256: String, + pub release: String, + pub retrieved_at: String, + pub terms_url: String, + pub terms_reviewed_at: String, + pub review_after: String, + pub copyright: String, + pub license_id: String, + pub archive_license_included: bool, +} + +/// Provider-specific usage boundary retained with every imported pack. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderPackRights { + pub terms_acceptance_required: bool, + pub permitted_outputs: Vec, + pub redistribution: ProviderPackRedistribution, + pub processing: ProviderPackProcessing, + pub modification_policy: ProviderPackModificationPolicy, +} + +/// Output categories copied from the provider's reviewed terms. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProviderPackPermittedOutput { + ArchitectureDiagram, + TrainingMaterial, + Documentation, + Whitepaper, + Presentation, + DataSheet, + Poster, +} + +/// Asset redistribution switches fixed by the user-imported contract. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderPackRedistribution { + pub cargo: bool, + pub npm: bool, + pub wasm: bool, + pub web_asset: bool, + pub native_binary: bool, + pub generated_output: bool, +} + +/// Local processing and artwork-preservation requirements. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderPackProcessing { + pub local_only: bool, + pub automatic_download: bool, + pub server_upload: bool, + pub preserve_colors: bool, + pub preserve_geometry: bool, + pub product_name_nearby: bool, +} + +/// The only modification policy supported by the provider-pack schema. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProviderPackModificationPolicy { + VisualPreservationOnly, +} + +/// User-visible source, terms, and non-endorsement text. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderPackNotice { + pub attribution: String, + pub terms_summary: String, + pub non_endorsement: String, +} + +/// One namespaced product icon and its locally processed safe SVG. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderIcon { + pub id: String, + pub subject: String, + pub product_name: String, + pub recommended_node_kind: ProviderNodeKind, + pub asset: ProviderIconAsset, +} + +/// Stack node-kind recommendation attached without changing node semantics. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProviderNodeKind { + Actor, + Client, + Service, + Function, + Worker, + Database, + Cache, + Queue, + Storage, + External, +} + +/// Original and processed identities for one local SVG file. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderIconAsset { + pub path: String, + pub original_path: String, + pub view_box: [i32; 4], + pub original_sha256: String, + pub processed_sha256: String, + pub transformations: Vec, +} + +/// Auditable, visual-preservation-only transformations applied during import. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProviderPackTransformation { + RemoveMetadata, + InlineStyles, + RemoveUnusedIdentifiers, + NormalizeXml, +} + #[cfg(test)] mod tests { use super::*; @@ -322,5 +490,11 @@ mod tests { schema["$schema"], "https://json-schema.org/draft/2020-12/schema" ); + let provider_schema: serde_json::Value = + serde_json::from_str(provider_pack_schema_json()).unwrap(); + assert_eq!( + provider_schema["$id"], + "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/provider-pack.schema.json" + ); } } diff --git a/package-lock.json b/package-lock.json index f30f64c..6e9df9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stack-theme-workspace", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stack-theme-workspace", - "version": "0.2.0", + "version": "0.3.0", "workspaces": [ "packages/theme" ], @@ -475,7 +475,7 @@ }, "packages/theme": { "name": "@stack-sh/theme", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0" } } diff --git a/package.json b/package.json index df3cc53..997895f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stack-theme-workspace", "private": true, - "version": "0.2.0", + "version": "0.3.0", "workspaces": [ "packages/theme" ], diff --git a/packages/theme/README.md b/packages/theme/README.md index d7f0dad..9378bfe 100644 --- a/packages/theme/README.md +++ b/packages/theme/README.md @@ -2,6 +2,8 @@ `@stack-sh/theme` exposes the typed Stack theme catalog and embeds the same generated catalog revision as the `stack-theme` Cargo crate. -The package is browser-safe and performs no filesystem, network, clock, locale, or host-font access. See the repository's [`CONTRACT.md`](https://github.com/stack-sh/theme/blob/main/CONTRACT.md) for the catalog and compatibility contract. +The package is browser-safe and performs no filesystem, network, clock, locale, or host-font access. See the repository's [`CONTRACT.md`](https://github.com/stack-sh/theme/blob/main/CONTRACT.md) for the core catalog and [`PROVIDER_PACKS.md`](https://github.com/stack-sh/theme/blob/main/PROVIDER_PACKS.md) for the local provider-pack contract. -Catalog `0.2.0` includes 12 provider-neutral explicit icons shared by the `default`, `light`, and `dark` themes. Resolve an icon's catalog asset path through `iconSvg()`; do not treat a logical icon identifier as a filesystem path or URL. +Catalog `0.3.0` includes 12 provider-neutral explicit icons shared by the `default`, `light`, and `dark` themes. Resolve a core icon's catalog asset path through `iconSvg()`; do not treat a logical icon identifier as a filesystem path or URL. + +`providerPackSchema` describes manifests produced from a provider archive that the user explicitly imports. It requires local-only processing, disabled package redistribution, provider-prefixed icon IDs, source and processed hashes, artwork-preservation policy, and user-visible terms notices. The package contains no vendor asset bytes and never downloads or uploads an archive. diff --git a/packages/theme/catalog-metadata.json b/packages/theme/catalog-metadata.json index bf1d8e1..a465f72 100644 --- a/packages/theme/catalog-metadata.json +++ b/packages/theme/catalog-metadata.json @@ -1,4 +1,4 @@ { - "catalogVersion": "0.2.0", - "catalogRevision": "sha256:d3a8a5a9d2100e496af3fd7adf389788f4a77508bf749a108183a2abf8f681e1" + "catalogVersion": "0.3.0", + "catalogRevision": "sha256:e4eaad0813fcfef4a203e861909ff38833270646f9097155974c7c92108c5b1e" } diff --git a/packages/theme/catalog.generated.js b/packages/theme/catalog.generated.js index 8d120cf..2b680d6 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.2.0", + "catalogVersion": "0.3.0", "reservedThemeIds": [], "fallbacks": { "missingThemeId": "default", @@ -2273,6 +2273,406 @@ const catalogData = { } ] }; +const providerPackSchemaData = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/provider-pack.schema.json", + "title": "Stack user-imported provider icon pack", + "description": "A local provider icon pack produced from an archive explicitly selected by the user.", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "schemaVersion", + "packVersion", + "provider", + "distributionMode", + "source", + "rights", + "notice", + "icons" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schemaVersion": { + "const": "1.0" + }, + "packVersion": { + "$ref": "#/$defs/semanticVersion" + }, + "provider": { + "$ref": "#/$defs/provider" + }, + "distributionMode": { + "const": "user-imported" + }, + "source": { + "$ref": "#/$defs/source" + }, + "rights": { + "$ref": "#/$defs/rights" + }, + "notice": { + "$ref": "#/$defs/notice" + }, + "icons": { + "type": "array", + "minItems": 1, + "maxItems": 10000, + "items": { + "$ref": "#/$defs/icon" + } + } + }, + "$defs": { + "semanticVersion": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$" + }, + "providerId": { + "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}$" + }, + "relativePath": { + "type": "string", + "minLength": 1, + "maxLength": 320, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[^\\u0000]+$" + }, + "httpsUrl": { + "type": "string", + "pattern": "^https://[^\\s]+$" + }, + "sha256": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "date": { + "type": "string", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])$" + }, + "nonEmptyText": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "provider": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "$ref": "#/$defs/providerId" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + } + } + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": [ + "pageUrl", + "archiveUrl", + "archiveSha256", + "release", + "retrievedAt", + "termsUrl", + "termsReviewedAt", + "reviewAfter", + "copyright", + "licenseId", + "archiveLicenseIncluded" + ], + "properties": { + "pageUrl": { + "$ref": "#/$defs/httpsUrl" + }, + "archiveUrl": { + "$ref": "#/$defs/httpsUrl" + }, + "archiveSha256": { + "$ref": "#/$defs/sha256" + }, + "release": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "retrievedAt": { + "$ref": "#/$defs/date" + }, + "termsUrl": { + "$ref": "#/$defs/httpsUrl" + }, + "termsReviewedAt": { + "$ref": "#/$defs/date" + }, + "reviewAfter": { + "$ref": "#/$defs/date" + }, + "copyright": { + "$ref": "#/$defs/nonEmptyText" + }, + "licenseId": { + "type": "string", + "pattern": "^LicenseRef-[A-Za-z0-9.-]+$" + }, + "archiveLicenseIncluded": { + "type": "boolean" + } + } + }, + "redistribution": { + "type": "object", + "additionalProperties": false, + "required": [ + "cargo", + "npm", + "wasm", + "webAsset", + "nativeBinary", + "generatedOutput" + ], + "properties": { + "cargo": { + "const": false + }, + "npm": { + "const": false + }, + "wasm": { + "const": false + }, + "webAsset": { + "const": false + }, + "nativeBinary": { + "const": false + }, + "generatedOutput": { + "const": true + } + } + }, + "processing": { + "type": "object", + "additionalProperties": false, + "required": [ + "localOnly", + "automaticDownload", + "serverUpload", + "preserveColors", + "preserveGeometry", + "productNameNearby" + ], + "properties": { + "localOnly": { + "const": true + }, + "automaticDownload": { + "const": false + }, + "serverUpload": { + "const": false + }, + "preserveColors": { + "const": true + }, + "preserveGeometry": { + "const": true + }, + "productNameNearby": { + "type": "boolean" + } + } + }, + "rights": { + "type": "object", + "additionalProperties": false, + "required": [ + "termsAcceptanceRequired", + "permittedOutputs", + "redistribution", + "processing", + "modificationPolicy" + ], + "properties": { + "termsAcceptanceRequired": { + "const": true + }, + "permittedOutputs": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "architecture-diagram", + "training-material", + "documentation", + "whitepaper", + "presentation", + "data-sheet", + "poster" + ] + } + }, + "redistribution": { + "$ref": "#/$defs/redistribution" + }, + "processing": { + "$ref": "#/$defs/processing" + }, + "modificationPolicy": { + "const": "visual-preservation-only" + } + } + }, + "notice": { + "type": "object", + "additionalProperties": false, + "required": [ + "attribution", + "termsSummary", + "nonEndorsement" + ], + "properties": { + "attribution": { + "$ref": "#/$defs/nonEmptyText" + }, + "termsSummary": { + "$ref": "#/$defs/nonEmptyText" + }, + "nonEndorsement": { + "$ref": "#/$defs/nonEmptyText" + } + } + }, + "nodeKind": { + "enum": [ + "actor", + "client", + "service", + "function", + "worker", + "database", + "cache", + "queue", + "storage", + "external" + ] + }, + "viewBox": { + "type": "array", + "prefixItems": [ + { + "type": "integer" + }, + { + "type": "integer" + }, + { + "type": "integer", + "minimum": 1 + }, + { + "type": "integer", + "minimum": 1 + } + ], + "items": false, + "minItems": 4, + "maxItems": 4 + }, + "transformation": { + "enum": [ + "remove-metadata", + "inline-styles", + "remove-unused-identifiers", + "normalize-xml" + ] + }, + "iconAsset": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "originalPath", + "viewBox", + "originalSha256", + "processedSha256", + "transformations" + ], + "properties": { + "path": { + "$ref": "#/$defs/relativePath" + }, + "originalPath": { + "$ref": "#/$defs/relativePath" + }, + "viewBox": { + "$ref": "#/$defs/viewBox" + }, + "originalSha256": { + "$ref": "#/$defs/sha256" + }, + "processedSha256": { + "$ref": "#/$defs/sha256" + }, + "transformations": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/transformation" + } + } + } + }, + "icon": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "subject", + "productName", + "recommendedNodeKind", + "asset" + ], + "properties": { + "id": { + "$ref": "#/$defs/namespacedIconId" + }, + "subject": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "productName": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "recommendedNodeKind": { + "$ref": "#/$defs/nodeKind" + }, + "asset": { + "$ref": "#/$defs/iconAsset" + } + } + } + } +}; const iconAssetsData = { "assets/core/api.svg": "\n \n\n", "assets/core/cloud.svg": "\n \n\n", @@ -2329,8 +2729,9 @@ function deepFreeze(value) { } export const catalog = deepFreeze(catalogData); -export const catalogVersion = "0.2.0"; -export const catalogRevision = "sha256:d3a8a5a9d2100e496af3fd7adf389788f4a77508bf749a108183a2abf8f681e1"; +export const providerPackSchema = deepFreeze(providerPackSchemaData); +export const catalogVersion = "0.3.0"; +export const catalogRevision = "sha256:e4eaad0813fcfef4a203e861909ff38833270646f9097155974c7c92108c5b1e"; 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 055c96d..ed7ee96 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.2.0", + "catalogVersion": "0.3.0", "reservedThemeIds": [], "fallbacks": { "missingThemeId": "default", diff --git a/packages/theme/index.d.ts b/packages/theme/index.d.ts index 6d466f1..e82eb62 100644 --- a/packages/theme/index.d.ts +++ b/packages/theme/index.d.ts @@ -147,7 +147,105 @@ export interface Redistribution { readonly commercialApplications: boolean; } +export type ProviderPackDistributionMode = "user-imported"; + +export type ProviderPackPermittedOutput = + | "architecture-diagram" + | "training-material" + | "documentation" + | "whitepaper" + | "presentation" + | "data-sheet" + | "poster"; + +export type ProviderPackTransformation = + | "remove-metadata" + | "inline-styles" + | "remove-unused-identifiers" + | "normalize-xml"; + +export interface ProviderPack { + readonly $schema: string; + readonly schemaVersion: "1.0"; + readonly packVersion: string; + readonly provider: ProviderPackIdentity; + readonly distributionMode: ProviderPackDistributionMode; + readonly source: ProviderPackSource; + readonly rights: ProviderPackRights; + readonly notice: ProviderPackNotice; + readonly icons: readonly ProviderIcon[]; +} + +export interface ProviderPackIdentity { + readonly id: string; + readonly name: string; +} + +export interface ProviderPackSource { + readonly pageUrl: `https://${string}`; + readonly archiveUrl: `https://${string}`; + readonly archiveSha256: `sha256:${string}`; + readonly release: string; + readonly retrievedAt: string; + readonly termsUrl: `https://${string}`; + readonly termsReviewedAt: string; + readonly reviewAfter: string; + readonly copyright: string; + readonly licenseId: `LicenseRef-${string}`; + readonly archiveLicenseIncluded: boolean; +} + +export interface ProviderPackRights { + readonly termsAcceptanceRequired: true; + readonly permittedOutputs: readonly ProviderPackPermittedOutput[]; + readonly redistribution: ProviderPackRedistribution; + readonly processing: ProviderPackProcessing; + readonly modificationPolicy: "visual-preservation-only"; +} + +export interface ProviderPackRedistribution { + readonly cargo: false; + readonly npm: false; + readonly wasm: false; + readonly webAsset: false; + readonly nativeBinary: false; + readonly generatedOutput: true; +} + +export interface ProviderPackProcessing { + readonly localOnly: true; + readonly automaticDownload: false; + readonly serverUpload: false; + readonly preserveColors: true; + readonly preserveGeometry: true; + readonly productNameNearby: boolean; +} + +export interface ProviderPackNotice { + readonly attribution: string; + readonly termsSummary: string; + readonly nonEndorsement: string; +} + +export interface ProviderIcon { + readonly id: `${string}:${string}`; + readonly subject: string; + readonly productName: string; + readonly recommendedNodeKind: keyof NodeKindFallbacks; + readonly asset: ProviderIconAsset; +} + +export interface ProviderIconAsset { + readonly path: string; + readonly originalPath: string; + readonly viewBox: readonly [number, number, number, number]; + readonly originalSha256: `sha256:${string}`; + readonly processedSha256: `sha256:${string}`; + readonly transformations: readonly ProviderPackTransformation[]; +} + export declare const catalog: Readonly; +export declare const providerPackSchema: Readonly>; export declare const catalogVersion: string; export declare const catalogRevision: `sha256:${string}`; export declare const iconAssets: Readonly>; diff --git a/packages/theme/index.js b/packages/theme/index.js index 1c3792b..fd9ecab 100644 --- a/packages/theme/index.js +++ b/packages/theme/index.js @@ -4,4 +4,5 @@ export { catalogVersion, iconAssets, iconSvg, + providerPackSchema, } from "./catalog.generated.js"; diff --git a/packages/theme/package.json b/packages/theme/package.json index 5eb4e50..4ec8838 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,6 +1,6 @@ { "name": "@stack-sh/theme", - "version": "0.2.0", + "version": "0.3.0", "description": "Canonical Stack theme catalog contract and embedded catalog data", "type": "module", "license": "Apache-2.0", @@ -17,7 +17,8 @@ }, "./catalog.json": "./catalog.json", "./catalog-metadata.json": "./catalog-metadata.json", - "./schema/catalog.schema.json": "./schema/catalog.schema.json" + "./schema/catalog.schema.json": "./schema/catalog.schema.json", + "./schema/provider-pack.schema.json": "./schema/provider-pack.schema.json" }, "files": [ "assets", diff --git a/packages/theme/schema/provider-pack.schema.json b/packages/theme/schema/provider-pack.schema.json new file mode 100644 index 0000000..5e19271 --- /dev/null +++ b/packages/theme/schema/provider-pack.schema.json @@ -0,0 +1,289 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/provider-pack.schema.json", + "title": "Stack user-imported provider icon pack", + "description": "A local provider icon pack produced from an archive explicitly selected by the user.", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "schemaVersion", + "packVersion", + "provider", + "distributionMode", + "source", + "rights", + "notice", + "icons" + ], + "properties": { + "$schema": { "type": "string" }, + "schemaVersion": { "const": "1.0" }, + "packVersion": { "$ref": "#/$defs/semanticVersion" }, + "provider": { "$ref": "#/$defs/provider" }, + "distributionMode": { "const": "user-imported" }, + "source": { "$ref": "#/$defs/source" }, + "rights": { "$ref": "#/$defs/rights" }, + "notice": { "$ref": "#/$defs/notice" }, + "icons": { + "type": "array", + "minItems": 1, + "maxItems": 10000, + "items": { "$ref": "#/$defs/icon" } + } + }, + "$defs": { + "semanticVersion": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$" + }, + "providerId": { + "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}$" + }, + "relativePath": { + "type": "string", + "minLength": 1, + "maxLength": 320, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[^\\u0000]+$" + }, + "httpsUrl": { + "type": "string", + "pattern": "^https://[^\\s]+$" + }, + "sha256": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "date": { + "type": "string", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])$" + }, + "nonEmptyText": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "provider": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name"], + "properties": { + "id": { "$ref": "#/$defs/providerId" }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + } + } + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": [ + "pageUrl", + "archiveUrl", + "archiveSha256", + "release", + "retrievedAt", + "termsUrl", + "termsReviewedAt", + "reviewAfter", + "copyright", + "licenseId", + "archiveLicenseIncluded" + ], + "properties": { + "pageUrl": { "$ref": "#/$defs/httpsUrl" }, + "archiveUrl": { "$ref": "#/$defs/httpsUrl" }, + "archiveSha256": { "$ref": "#/$defs/sha256" }, + "release": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "retrievedAt": { "$ref": "#/$defs/date" }, + "termsUrl": { "$ref": "#/$defs/httpsUrl" }, + "termsReviewedAt": { "$ref": "#/$defs/date" }, + "reviewAfter": { "$ref": "#/$defs/date" }, + "copyright": { "$ref": "#/$defs/nonEmptyText" }, + "licenseId": { + "type": "string", + "pattern": "^LicenseRef-[A-Za-z0-9.-]+$" + }, + "archiveLicenseIncluded": { "type": "boolean" } + } + }, + "redistribution": { + "type": "object", + "additionalProperties": false, + "required": [ + "cargo", + "npm", + "wasm", + "webAsset", + "nativeBinary", + "generatedOutput" + ], + "properties": { + "cargo": { "const": false }, + "npm": { "const": false }, + "wasm": { "const": false }, + "webAsset": { "const": false }, + "nativeBinary": { "const": false }, + "generatedOutput": { "const": true } + } + }, + "processing": { + "type": "object", + "additionalProperties": false, + "required": [ + "localOnly", + "automaticDownload", + "serverUpload", + "preserveColors", + "preserveGeometry", + "productNameNearby" + ], + "properties": { + "localOnly": { "const": true }, + "automaticDownload": { "const": false }, + "serverUpload": { "const": false }, + "preserveColors": { "const": true }, + "preserveGeometry": { "const": true }, + "productNameNearby": { "type": "boolean" } + } + }, + "rights": { + "type": "object", + "additionalProperties": false, + "required": [ + "termsAcceptanceRequired", + "permittedOutputs", + "redistribution", + "processing", + "modificationPolicy" + ], + "properties": { + "termsAcceptanceRequired": { "const": true }, + "permittedOutputs": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "architecture-diagram", + "training-material", + "documentation", + "whitepaper", + "presentation", + "data-sheet", + "poster" + ] + } + }, + "redistribution": { "$ref": "#/$defs/redistribution" }, + "processing": { "$ref": "#/$defs/processing" }, + "modificationPolicy": { "const": "visual-preservation-only" } + } + }, + "notice": { + "type": "object", + "additionalProperties": false, + "required": ["attribution", "termsSummary", "nonEndorsement"], + "properties": { + "attribution": { "$ref": "#/$defs/nonEmptyText" }, + "termsSummary": { "$ref": "#/$defs/nonEmptyText" }, + "nonEndorsement": { "$ref": "#/$defs/nonEmptyText" } + } + }, + "nodeKind": { + "enum": [ + "actor", + "client", + "service", + "function", + "worker", + "database", + "cache", + "queue", + "storage", + "external" + ] + }, + "viewBox": { + "type": "array", + "prefixItems": [ + { "type": "integer" }, + { "type": "integer" }, + { "type": "integer", "minimum": 1 }, + { "type": "integer", "minimum": 1 } + ], + "items": false, + "minItems": 4, + "maxItems": 4 + }, + "transformation": { + "enum": [ + "remove-metadata", + "inline-styles", + "remove-unused-identifiers", + "normalize-xml" + ] + }, + "iconAsset": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "originalPath", + "viewBox", + "originalSha256", + "processedSha256", + "transformations" + ], + "properties": { + "path": { "$ref": "#/$defs/relativePath" }, + "originalPath": { "$ref": "#/$defs/relativePath" }, + "viewBox": { "$ref": "#/$defs/viewBox" }, + "originalSha256": { "$ref": "#/$defs/sha256" }, + "processedSha256": { "$ref": "#/$defs/sha256" }, + "transformations": { + "type": "array", + "uniqueItems": true, + "items": { "$ref": "#/$defs/transformation" } + } + } + }, + "icon": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "subject", + "productName", + "recommendedNodeKind", + "asset" + ], + "properties": { + "id": { "$ref": "#/$defs/namespacedIconId" }, + "subject": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "productName": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "recommendedNodeKind": { "$ref": "#/$defs/nodeKind" }, + "asset": { "$ref": "#/$defs/iconAsset" } + } + } + } +} diff --git a/schemas/provider-pack.schema.json b/schemas/provider-pack.schema.json new file mode 100644 index 0000000..5e19271 --- /dev/null +++ b/schemas/provider-pack.schema.json @@ -0,0 +1,289 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/provider-pack.schema.json", + "title": "Stack user-imported provider icon pack", + "description": "A local provider icon pack produced from an archive explicitly selected by the user.", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "schemaVersion", + "packVersion", + "provider", + "distributionMode", + "source", + "rights", + "notice", + "icons" + ], + "properties": { + "$schema": { "type": "string" }, + "schemaVersion": { "const": "1.0" }, + "packVersion": { "$ref": "#/$defs/semanticVersion" }, + "provider": { "$ref": "#/$defs/provider" }, + "distributionMode": { "const": "user-imported" }, + "source": { "$ref": "#/$defs/source" }, + "rights": { "$ref": "#/$defs/rights" }, + "notice": { "$ref": "#/$defs/notice" }, + "icons": { + "type": "array", + "minItems": 1, + "maxItems": 10000, + "items": { "$ref": "#/$defs/icon" } + } + }, + "$defs": { + "semanticVersion": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$" + }, + "providerId": { + "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}$" + }, + "relativePath": { + "type": "string", + "minLength": 1, + "maxLength": 320, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[^\\u0000]+$" + }, + "httpsUrl": { + "type": "string", + "pattern": "^https://[^\\s]+$" + }, + "sha256": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "date": { + "type": "string", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])$" + }, + "nonEmptyText": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "provider": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name"], + "properties": { + "id": { "$ref": "#/$defs/providerId" }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + } + } + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": [ + "pageUrl", + "archiveUrl", + "archiveSha256", + "release", + "retrievedAt", + "termsUrl", + "termsReviewedAt", + "reviewAfter", + "copyright", + "licenseId", + "archiveLicenseIncluded" + ], + "properties": { + "pageUrl": { "$ref": "#/$defs/httpsUrl" }, + "archiveUrl": { "$ref": "#/$defs/httpsUrl" }, + "archiveSha256": { "$ref": "#/$defs/sha256" }, + "release": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "retrievedAt": { "$ref": "#/$defs/date" }, + "termsUrl": { "$ref": "#/$defs/httpsUrl" }, + "termsReviewedAt": { "$ref": "#/$defs/date" }, + "reviewAfter": { "$ref": "#/$defs/date" }, + "copyright": { "$ref": "#/$defs/nonEmptyText" }, + "licenseId": { + "type": "string", + "pattern": "^LicenseRef-[A-Za-z0-9.-]+$" + }, + "archiveLicenseIncluded": { "type": "boolean" } + } + }, + "redistribution": { + "type": "object", + "additionalProperties": false, + "required": [ + "cargo", + "npm", + "wasm", + "webAsset", + "nativeBinary", + "generatedOutput" + ], + "properties": { + "cargo": { "const": false }, + "npm": { "const": false }, + "wasm": { "const": false }, + "webAsset": { "const": false }, + "nativeBinary": { "const": false }, + "generatedOutput": { "const": true } + } + }, + "processing": { + "type": "object", + "additionalProperties": false, + "required": [ + "localOnly", + "automaticDownload", + "serverUpload", + "preserveColors", + "preserveGeometry", + "productNameNearby" + ], + "properties": { + "localOnly": { "const": true }, + "automaticDownload": { "const": false }, + "serverUpload": { "const": false }, + "preserveColors": { "const": true }, + "preserveGeometry": { "const": true }, + "productNameNearby": { "type": "boolean" } + } + }, + "rights": { + "type": "object", + "additionalProperties": false, + "required": [ + "termsAcceptanceRequired", + "permittedOutputs", + "redistribution", + "processing", + "modificationPolicy" + ], + "properties": { + "termsAcceptanceRequired": { "const": true }, + "permittedOutputs": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "architecture-diagram", + "training-material", + "documentation", + "whitepaper", + "presentation", + "data-sheet", + "poster" + ] + } + }, + "redistribution": { "$ref": "#/$defs/redistribution" }, + "processing": { "$ref": "#/$defs/processing" }, + "modificationPolicy": { "const": "visual-preservation-only" } + } + }, + "notice": { + "type": "object", + "additionalProperties": false, + "required": ["attribution", "termsSummary", "nonEndorsement"], + "properties": { + "attribution": { "$ref": "#/$defs/nonEmptyText" }, + "termsSummary": { "$ref": "#/$defs/nonEmptyText" }, + "nonEndorsement": { "$ref": "#/$defs/nonEmptyText" } + } + }, + "nodeKind": { + "enum": [ + "actor", + "client", + "service", + "function", + "worker", + "database", + "cache", + "queue", + "storage", + "external" + ] + }, + "viewBox": { + "type": "array", + "prefixItems": [ + { "type": "integer" }, + { "type": "integer" }, + { "type": "integer", "minimum": 1 }, + { "type": "integer", "minimum": 1 } + ], + "items": false, + "minItems": 4, + "maxItems": 4 + }, + "transformation": { + "enum": [ + "remove-metadata", + "inline-styles", + "remove-unused-identifiers", + "normalize-xml" + ] + }, + "iconAsset": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "originalPath", + "viewBox", + "originalSha256", + "processedSha256", + "transformations" + ], + "properties": { + "path": { "$ref": "#/$defs/relativePath" }, + "originalPath": { "$ref": "#/$defs/relativePath" }, + "viewBox": { "$ref": "#/$defs/viewBox" }, + "originalSha256": { "$ref": "#/$defs/sha256" }, + "processedSha256": { "$ref": "#/$defs/sha256" }, + "transformations": { + "type": "array", + "uniqueItems": true, + "items": { "$ref": "#/$defs/transformation" } + } + } + }, + "icon": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "subject", + "productName", + "recommendedNodeKind", + "asset" + ], + "properties": { + "id": { "$ref": "#/$defs/namespacedIconId" }, + "subject": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "productName": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "recommendedNodeKind": { "$ref": "#/$defs/nodeKind" }, + "asset": { "$ref": "#/$defs/iconAsset" } + } + } + } +} diff --git a/scripts/catalog-lib.mjs b/scripts/catalog-lib.mjs index f740d18..080b3ce 100644 --- a/scripts/catalog-lib.mjs +++ b/scripts/catalog-lib.mjs @@ -358,6 +358,76 @@ export async function validateCatalog( return catalog; } +export async function validateProviderPack( + providerPack, + { root = repositoryRoot, validateAssets = true } = {}, +) { + const schema = await readJson( + path.join(repositoryRoot, "schemas/provider-pack.schema.json"), + ); + const ajv = new Ajv2020({ allErrors: true, strict: true }); + const validate = ajv.compile(schema); + if (!validate(providerPack)) { + const details = validate.errors + .map((error) => `${error.instancePath || "/"} ${error.message}`) + .join("; "); + fail(`provider pack schema validation failed: ${details}`); + } + + const expectedPrefix = `${providerPack.provider.id}:`; + const iconIds = new Set(); + const assetPaths = new Set(); + for (const icon of providerPack.icons) { + if (!icon.id.startsWith(expectedPrefix)) { + fail( + `provider icon ${icon.id} must use the ${providerPack.provider.id} namespace`, + ); + } + if (iconIds.has(icon.id)) { + fail(`duplicate provider icon id: ${icon.id}`); + } + iconIds.add(icon.id); + + if (!icon.asset.path.startsWith("assets/")) { + fail(`provider icon asset must be under assets/: ${icon.asset.path}`); + } + if (assetPaths.has(icon.asset.path)) { + fail(`duplicate provider icon asset path: ${icon.asset.path}`); + } + assetPaths.add(icon.asset.path); + + const hashesMatch = + icon.asset.originalSha256 === icon.asset.processedSha256; + if (icon.asset.transformations.length === 0 && !hashesMatch) { + fail(`provider icon ${icon.id} changed without a transformation record`); + } + + if (validateAssets) { + const assetFile = resolveRepositoryPath( + root, + icon.asset.path, + "provider icon asset path", + ); + await requireFile(assetFile, "provider icon asset"); + if (path.extname(assetFile).toLowerCase() !== ".svg") { + fail(`provider icon asset must be an SVG file: ${icon.asset.path}`); + } + const assetBytes = await readFile(assetFile); + const digest = `sha256:${createHash("sha256").update(assetBytes).digest("hex")}`; + if (digest !== icon.asset.processedSha256) { + fail(`provider icon ${icon.id} processed hash does not match its asset`); + } + validateSvgText( + assetBytes.toString("utf8"), + icon.asset.path, + icon.asset.viewBox, + ); + } + } + + return providerPack; +} + export async function catalogRevision(catalog, root = repositoryRoot) { const hash = createHash("sha256"); hash.update("stack-theme-catalog-v1\0"); diff --git a/scripts/generate.mjs b/scripts/generate.mjs index a31063c..0f4a5e4 100644 --- a/scripts/generate.mjs +++ b/scripts/generate.mjs @@ -46,6 +46,11 @@ const schemaText = await readFile( path.join(repositoryRoot, "schemas/catalog.schema.json"), "utf8", ); +const providerPackSchemaText = await readFile( + path.join(repositoryRoot, "schemas/provider-pack.schema.json"), + "utf8", +); +const providerPackSchema = JSON.parse(providerPackSchemaText); const assetEntriesByPath = new Map(); const licensePaths = new Set(["LICENSE"]); for (const metrics of catalog.fontMetrics) { @@ -72,7 +77,7 @@ const assetEntries = [...assetEntriesByPath].sort(([left], [right]) => ); const assetObject = Object.fromEntries(assetEntries); -const generatedJavaScript = `// Generated by scripts/generate.mjs. Do not edit.\nconst catalogData = ${JSON.stringify(catalog, null, 2)};\nconst iconAssetsData = ${JSON.stringify(assetObject, null, 2)};\n\nfunction deepFreeze(value) {\n Object.freeze(value);\n for (const child of Object.values(value)) {\n if (child !== null && typeof child === "object" && !Object.isFrozen(child)) {\n deepFreeze(child);\n }\n }\n return value;\n}\n\nexport const catalog = deepFreeze(catalogData);\nexport const catalogVersion = ${JSON.stringify(catalog.catalogVersion)};\nexport const catalogRevision = ${JSON.stringify(revision)};\nexport const iconAssets = deepFreeze(iconAssetsData);\nexport function iconSvg(assetPath) {\n return iconAssets[assetPath];\n}\n`; +const generatedJavaScript = `// Generated by scripts/generate.mjs. Do not edit.\nconst catalogData = ${JSON.stringify(catalog, null, 2)};\nconst providerPackSchemaData = ${JSON.stringify(providerPackSchema, null, 2)};\nconst iconAssetsData = ${JSON.stringify(assetObject, null, 2)};\n\nfunction deepFreeze(value) {\n Object.freeze(value);\n for (const child of Object.values(value)) {\n if (child !== null && typeof child === "object" && !Object.isFrozen(child)) {\n deepFreeze(child);\n }\n }\n return value;\n}\n\nexport const catalog = deepFreeze(catalogData);\nexport const providerPackSchema = deepFreeze(providerPackSchemaData);\nexport const catalogVersion = ${JSON.stringify(catalog.catalogVersion)};\nexport const catalogRevision = ${JSON.stringify(revision)};\nexport const iconAssets = deepFreeze(iconAssetsData);\nexport function iconSvg(assetPath) {\n return iconAssets[assetPath];\n}\n`; const rustAssetArms = assetEntries .map( ([assetPath]) => @@ -88,10 +93,12 @@ const generatedRust = `// Generated by scripts/generate.mjs. Do not edit.\npub c const artifacts = new Map([ ["crates/stack-theme/LICENSE", licenseText], ["crates/stack-theme/schema/catalog.schema.json", schemaText], + ["crates/stack-theme/schema/provider-pack.schema.json", providerPackSchemaText], ["crates/stack-theme/src/generated/catalog.json", catalogJson], ["crates/stack-theme/src/generated/metadata.rs", generatedRust], ["packages/theme/LICENSE", licenseText], ["packages/theme/schema/catalog.schema.json", schemaText], + ["packages/theme/schema/provider-pack.schema.json", providerPackSchemaText], ["packages/theme/catalog.json", catalogJson], ["packages/theme/catalog-metadata.json", metadataJson], ["packages/theme/catalog.generated.js", generatedJavaScript], diff --git a/scripts/validate.mjs b/scripts/validate.mjs index ed19044..27165e4 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -1,6 +1,11 @@ import path from "node:path"; -import { readJson, repositoryRoot, validateCatalog } from "./catalog-lib.mjs"; +import { + readJson, + repositoryRoot, + validateCatalog, + validateProviderPack, +} from "./catalog-lib.mjs"; const catalogs = [ { @@ -15,3 +20,13 @@ for (const entry of catalogs) { await validateCatalog(catalog, { requiredThemeIds: entry.requiredThemeIds }); 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"); diff --git a/tests/catalog.test.mjs b/tests/catalog.test.mjs index 8bc9b98..76b11d6 100644 --- a/tests/catalog.test.mjs +++ b/tests/catalog.test.mjs @@ -7,6 +7,7 @@ import { readJson, repositoryRoot, validateCatalog, + validateProviderPack, validateSvgText, } from "../scripts/catalog-lib.mjs"; import { @@ -15,6 +16,7 @@ import { catalogVersion, iconAssets, iconSvg, + providerPackSchema, } from "../packages/theme/index.js"; test("the complete contract fixture is valid", async () => { @@ -24,6 +26,69 @@ test("the complete contract fixture is valid", async () => { await validateCatalog(fixture); }); +const providerPackRoot = path.join( + repositoryRoot, + "tests/fixtures/provider-pack", +); + +async function providerPackFixture() { + return readJson(path.join(providerPackRoot, "valid.json")); +} + +test("the user-imported provider pack fixture is valid", async () => { + await validateProviderPack(await providerPackFixture(), { + root: providerPackRoot, + }); +}); + +test("provider icon IDs must match the declared namespace", async () => { + const fixture = await providerPackFixture(); + fixture.icons[0].id = "other:object-storage"; + await assert.rejects( + validateProviderPack(fixture, { + root: providerPackRoot, + validateAssets: false, + }), + /must use the acme namespace/, + ); +}); + +test("provider packs cannot enable package redistribution", async () => { + const fixture = await providerPackFixture(); + fixture.rights.redistribution.npm = true; + await assert.rejects( + validateProviderPack(fixture, { + root: providerPackRoot, + validateAssets: false, + }), + /provider pack schema validation failed/, + ); +}); + +test("provider assets require matching processed hashes", async () => { + const fixture = await providerPackFixture(); + fixture.icons[0].asset.processedSha256 = + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + fixture.icons[0].asset.transformations = ["normalize-xml"]; + await assert.rejects( + validateProviderPack(fixture, { root: providerPackRoot }), + /processed hash does not match its asset/, + ); +}); + +test("provider asset changes require a transformation record", async () => { + const fixture = await providerPackFixture(); + fixture.icons[0].asset.originalSha256 = + "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + await assert.rejects( + validateProviderPack(fixture, { + root: providerPackRoot, + validateAssets: false, + }), + /changed without a transformation record/, + ); +}); + for (const [name, pattern] of [ ["unsafe-script.svg", /element script is not allowed/], ["unsafe-event.svg", /event handler attribute onload is forbidden/], @@ -257,6 +322,21 @@ test("Cargo and npm artifacts expose one semantic catalog revision", async () => const npmSchema = await readJson( path.join(repositoryRoot, "packages/theme/schema/catalog.schema.json"), ); + const sourceProviderPackSchema = await readJson( + path.join(repositoryRoot, "schemas/provider-pack.schema.json"), + ); + const cargoProviderPackSchema = await readJson( + path.join( + repositoryRoot, + "crates/stack-theme/schema/provider-pack.schema.json", + ), + ); + const npmProviderPackSchema = await readJson( + path.join( + repositoryRoot, + "packages/theme/schema/provider-pack.schema.json", + ), + ); const metadata = await readJson( path.join(repositoryRoot, "packages/theme/catalog-metadata.json"), ); @@ -272,6 +352,9 @@ test("Cargo and npm artifacts expose one semantic catalog revision", async () => assert.deepEqual(cargoCatalog, npmCatalog); assert.deepEqual(cargoSchema, sourceSchema); assert.deepEqual(cargoSchema, npmSchema); + assert.deepEqual(cargoProviderPackSchema, sourceProviderPackSchema); + assert.deepEqual(cargoProviderPackSchema, npmProviderPackSchema); + assert.deepEqual(providerPackSchema, sourceProviderPackSchema); assert.deepEqual(catalog, npmCatalog); assert.equal(catalogVersion, npmCatalog.catalogVersion); assert.equal(metadata.catalogVersion, catalogVersion); diff --git a/tests/fixtures/provider-pack/assets/example.svg b/tests/fixtures/provider-pack/assets/example.svg new file mode 100644 index 0000000..e557d04 --- /dev/null +++ b/tests/fixtures/provider-pack/assets/example.svg @@ -0,0 +1,3 @@ + + + diff --git a/tests/fixtures/provider-pack/valid.json b/tests/fixtures/provider-pack/valid.json new file mode 100644 index 0000000..097c0e3 --- /dev/null +++ b/tests/fixtures/provider-pack/valid.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/provider-pack.schema.json", + "schemaVersion": "1.0", + "packVersion": "0.1.0", + "provider": { + "id": "acme", + "name": "Acme Cloud" + }, + "distributionMode": "user-imported", + "source": { + "pageUrl": "https://example.com/acme/icons", + "archiveUrl": "https://example.com/acme/icons.zip", + "archiveSha256": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "release": "fixture-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": { + "path": "assets/example.svg", + "originalPath": "Official Icons/Acme Object Storage.svg", + "viewBox": [0, 0, 24, 24], + "originalSha256": "sha256:bb77c20b241b47db4ca268ebee418f0f74e19bdb1a2c2e2fa271b7f65c2d447d", + "processedSha256": "sha256:bb77c20b241b47db4ca268ebee418f0f74e19bdb1a2c2e2fa271b7f65c2d447d", + "transformations": [] + } + } + ] +} diff --git a/tests/types.test.ts b/tests/types.test.ts index 326630c..9df79ae 100644 --- a/tests/types.test.ts +++ b/tests/types.test.ts @@ -4,7 +4,9 @@ import { catalogVersion, iconAssets, iconSvg, + providerPackSchema, type Catalog, + type ProviderPack, type Theme, } from "@stack-sh/theme"; @@ -14,9 +16,14 @@ const version: string = catalogVersion; const revision: `sha256:${string}` = catalogRevision; const assets: Readonly> = iconAssets; const missingAsset: string | undefined = iconSvg("assets/missing.svg"); +const schema: Readonly> = providerPackSchema; +declare const providerPack: ProviderPack; +const providerId: string = providerPack.provider.id; void themes; void version; void revision; void assets; void missingAsset; +void schema; +void providerId;