diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e1e881d..aefed75 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -15,11 +15,74 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + - name: Check whitespace run: git diff --check "$(git hash-object -t tree /dev/null)" HEAD - - name: Verify foundation files + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install JavaScript development dependencies + run: npm ci + + - name: Validate catalog schemas, data, and SVG assets + run: npm run validate + + - name: Verify generated Cargo and npm artifacts + run: npm run generate:check + + - name: Test catalog security and cross-package equality + run: npm test + + - name: Check npm package types + run: npm run typecheck + + - name: Verify npm package contents + run: npm pack --dry-run --workspace @stack-sh/theme + + - name: Install latest stable Rust toolchain + run: rustup toolchain install stable --profile minimal --component clippy,rustfmt + + - name: Check Rust formatting + run: cargo +stable fmt --check + + - name: Run Rust tests + run: cargo +stable test --workspace --locked + + - name: Run Clippy + run: cargo +stable clippy --workspace --all-targets --locked -- -D warnings + + - name: Build Rust documentation + env: + RUSTDOCFLAGS: -D warnings + run: cargo +stable doc --workspace --no-deps --locked + + - name: Verify Cargo package contents + run: cargo +stable package -p stack-theme --locked + + - name: Verify repository files run: | test -s README.md test -s AGENTS.md + test -s CONTRACT.md test -s LICENSE test -s THIRD_PARTY_LICENSES.md + + msrv: + name: Minimum supported Rust + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Rust 1.85 + run: rustup toolchain install 1.85.0 --profile minimal --component clippy + + - name: Run tests + run: cargo +1.85.0 test --workspace --locked + + - name: Run Clippy + run: cargo +1.85.0 clippy --workspace --all-targets --locked -- -D warnings diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb71222 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/node_modules/ +/target/ +*.tgz diff --git a/CONTRACT.md b/CONTRACT.md new file mode 100644 index 0000000..33cca4b --- /dev/null +++ b/CONTRACT.md @@ -0,0 +1,90 @@ +# Stack Theme Catalog Contract + +## Status + +This document defines the draft `1.0` catalog document shape. Catalog and package version `0.1.0` remain pre-1.0 and may change incompatibly before a stable release. Registry publication is outside this repository change. + +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. + +## Catalog identity + +Every catalog has these required top-level fields: + +| Field | Meaning | +| --- | --- | +| `$schema` | Schema location for editors and validators | +| `schemaVersion` | Catalog document shape; currently `1.0` | +| `catalogVersion` | Semantic version shared by the source catalog, Cargo crate, and npm package | +| `reservedThemeIds` | Previously registered theme identifiers that cannot be reused | +| `fontMetrics` | Deterministic font measurement records | +| `themes` | Themes in canonical catalog order | + +Theme identifiers use the Stack language identifier form. Active identifiers are unique and cannot also be reserved. When a registered theme is removed, its identifier moves to `reservedThemeIds`; it is never reassigned. + +`catalogRevision` is generated metadata rather than a source field. It is a `sha256:` digest over the normalized source catalog followed by every referenced SVG path and byte sequence in path order. The revision therefore changes when semantic catalog data, provenance, or icon bytes change, without requiring a Git commit to contain its own commit identifier. + +## Theme records + +A theme requires `id`, `name`, `palette`, `typography`, `nodeKindFallbacks`, `connector`, and `icons`. `description` is optional. + +`palette` requires `canvas`, `surface`, `surfaceMuted`, `text`, `textMuted`, `border`, `accent`, `danger`, and `connector` colors. Colors are six- or eight-digit hexadecimal sRGB values. Other records refer to these semantic slots instead of duplicating colors. + +`typography` requires: + +- `fontMetricsId`, which must resolve to one catalog font metric record; +- node label, node detail, group label, and edge label sizes in integer thousandths of a CSS pixel; +- line height in integer thousandths of the selected font size; +- label and detail weights from 100 through 900. + +Integer units keep Rust and JavaScript consumers from introducing representation-dependent rounding before layout begins. + +`nodeKindFallbacks` requires one visual for every Stack 1.0 node kind: `actor`, `client`, `service`, `function`, `worker`, `database`, `cache`, `queue`, `storage`, and `external`. Each visual requires a renderer-supported shape, palette references, corner radius, and a theme-local `fallbackIconId`. Every fallback icon must exist in that theme. + +`connector` requires line, text, and label-background palette references plus integer line width and arrow size. `dashMilliPx` is the only optional connector field; absence means a solid line. + +## Font metrics + +A font metric record requires `id`, `family`, `version`, `unitsPerEm`, `ascent`, `descent`, `lineGap`, `defaultAdvance`, `glyphAdvances`, and the same source, license, and distribution `provenance` required for icon assets. + +Advances use integer font design units. `glyphAdvances` keys are uppercase Unicode scalar labels such as `U+0041`. A consumer uses `defaultAdvance` for a scalar without an explicit entry. This makes measurement deterministic and removes any need for a host font API. The catalog version and revision identify the exact measurement table used by a render. + +## Icon metadata and provenance + +Icon identifiers are unique within one theme. Each icon requires `id`, a stable logical `subject`, and `asset`; `description` is optional. When the same icon identifier appears in multiple themes, its `subject` must be identical so theme switching cannot change its meaning. + +An icon asset requires a repository-relative SVG `path`, four-integer `viewBox`, and `provenance`. Provenance requires: + +- HTTPS source URL and pinned source revision; +- copyright statement; +- SPDX license expression and either the root `LICENSE` or a file under `licenses/`; +- modification status; +- explicit permission for Cargo, npm, WASM, and commercial-application redistribution. + +The catalog rejects assets that are not permitted in every supported distribution channel. `THIRD_PARTY_LICENSES.md` must also record third-party assets before they are committed. + +SVG validation uses an element and attribute allowlist. It rejects scripts, event handlers, nested SVG documents, CDATA, processing instructions, doctypes, entities, style attributes, `href`, `url(...)`, executable schemes, data URLs, network URLs, arbitrary namespaces, and any element or attribute outside the allowlist. The root `viewBox` must exactly match catalog metadata. Renderers may inline only validated, catalog-owned SVG bytes and must never resolve a source identifier as a path or URL. + +## 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. + +`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. + +The package versions and `catalogVersion` move together. Any data or asset change also changes `catalogRevision`. Consumers should record both values in render metadata; the semantic equality test verifies that the Cargo and npm artifacts contain the same catalog and metadata. + +## Validation + +Run the complete local gate with: + +```sh +npm ci +npm run validate +npm run generate:check +npm test +npm run typecheck +cargo test --workspace --locked +cargo clippy --workspace --all-targets --locked -- -D warnings +cargo doc --workspace --no-deps --locked +``` + +The complete fixture exercises every required field. Security fixtures prove rejection of script, event-handler, and external-reference SVGs. The source catalog intentionally contains no themes until the core `default`, `light`, and `dark` data lands as the next provider change. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c165369 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,107 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "stack-theme" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..73ecf08 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[workspace] +members = ["crates/stack-theme"] +resolver = "3" + +[workspace.package] +edition = "2024" +license = "Apache-2.0" +repository = "https://github.com/stack-sh/theme" +rust-version = "1.85" +version = "0.1.0" + +[workspace.dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/README.md b/README.md index e929b39..39ddbf3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ `stack-sh/theme` is the canonical public catalog for Stack diagram themes, icons, and font metrics. -This repository currently contains only its repository foundation. Theme schemas, package APIs, and compatibility guarantees are not stable until their public contract is added in a later change. +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 current `0.1.0` catalog is intentionally empty. The core `default`, `light`, and `dark` themes will be added as the next reviewed catalog change before either package is released to a registry. ## Scope @@ -19,7 +21,20 @@ Only free core themes belong in this public repository. Paid or proprietary them ## Development -Repository checks currently validate the foundation files on every push and pull request. Source-specific formatting, tests, builds, and package validation will be added together with the first catalog implementation. +Install Node.js 20 or newer and Rust 1.85, then run: + +```sh +npm ci +npm run validate +npm run generate:check +npm test +npm run typecheck +cargo test --workspace --locked +cargo clippy --workspace --all-targets --locked -- -D warnings +cargo doc --workspace --no-deps --locked +``` + +`npm run generate` is the only supported way to update checked-in package copies and revision metadata. CI validates the schema and asset boundary, rejects unsafe SVG fixtures, compares Cargo and npm catalog semantics, builds both packages, and verifies the minimum Rust version. ## Licensing diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 52464a2..c8c57eb 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -1,6 +1,18 @@ # Third-party licenses -No third-party fonts, icons, code, or other assets are bundled in this repository yet. +No third-party fonts, icons, or other runtime assets are bundled in this repository yet. + +The repository uses the following third-party libraries: + +| Project | Version | Use | Source | Copyright | License | Distribution | +| --- | --- | --- | --- | --- | --- | --- | +| Ajv | 8.20.0 | Development-only JSON Schema validation | https://github.com/ajv-validator/ajv/tree/v8.20.0 | Evgeny Poberezkin | MIT | Installed by `npm ci`; not included in `@stack-sh/theme` | +| saxes | 6.0.0 | Development-only streaming XML parser for SVG validation | https://github.com/lddubeau/saxes/tree/v6.0.0 | Louis-Dominique Dubeau | ISC | Installed by `npm ci`; not included in `@stack-sh/theme` | +| TypeScript | 7.0.2 | Development-only npm declaration checking | https://github.com/microsoft/TypeScript/tree/v7.0.2 | Microsoft Corporation | Apache-2.0 | Installed by `npm ci`; not included in `@stack-sh/theme` | +| serde | 1.0.229 | Rust catalog deserialization and public data types | https://github.com/serde-rs/serde/tree/v1.0.229 | David Tolnay | MIT OR Apache-2.0 | Cargo dependency; no separately bundled asset | +| serde_json | 1.0.151 | Embedded JSON catalog deserialization | https://github.com/serde-rs/json/tree/v1.0.151 | David Tolnay | MIT OR Apache-2.0 | Cargo dependency; no separately bundled asset | + +Ajv, saxes, and TypeScript retain their upstream licenses in installed package metadata and the npm lockfile. serde and serde_json retain their upstream Cargo package metadata and license terms. No dependency source or license text is copied into either generated distribution package by this repository. Before adding a third-party asset, record all of the following in this file: diff --git a/catalog/catalog.json b/catalog/catalog.json new file mode 100644 index 0000000..3f883f3 --- /dev/null +++ b/catalog/catalog.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/catalog.schema.json", + "schemaVersion": "1.0", + "catalogVersion": "0.1.0", + "reservedThemeIds": [], + "fontMetrics": [], + "themes": [] +} diff --git a/crates/stack-theme/Cargo.toml b/crates/stack-theme/Cargo.toml new file mode 100644 index 0000000..ae50284 --- /dev/null +++ b/crates/stack-theme/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "stack-theme" +description = "Canonical Stack theme catalog contract and embedded catalog data" +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true +include = ["src/**", "assets/**", "licenses/**", "schema/**", "README.md", "LICENSE"] + +[dependencies] +serde.workspace = true +serde_json.workspace = true diff --git a/crates/stack-theme/LICENSE b/crates/stack-theme/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/crates/stack-theme/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/stack-theme/README.md b/crates/stack-theme/README.md new file mode 100644 index 0000000..c6a2611 --- /dev/null +++ b/crates/stack-theme/README.md @@ -0,0 +1,5 @@ +# stack-theme + +`stack-theme` exposes the typed Stack theme catalog and embeds the same generated catalog revision as the `@stack-sh/theme` npm package. + +The public contract and compatibility policy are documented in the repository's [`CONTRACT.md`](https://github.com/stack-sh/theme/blob/main/CONTRACT.md). diff --git a/crates/stack-theme/schema/catalog.schema.json b/crates/stack-theme/schema/catalog.schema.json new file mode 100644 index 0000000..f9f09a4 --- /dev/null +++ b/crates/stack-theme/schema/catalog.schema.json @@ -0,0 +1,428 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/catalog.schema.json", + "title": "Stack Theme Catalog", + "description": "The portable source contract for Stack theme, icon, and deterministic font metric data.", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "schemaVersion", + "catalogVersion", + "reservedThemeIds", + "fontMetrics", + "themes" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schemaVersion": { + "const": "1.0" + }, + "catalogVersion": { + "$ref": "#/$defs/semanticVersion" + }, + "reservedThemeIds": { + "type": "array", + "items": { + "$ref": "#/$defs/identifier" + }, + "uniqueItems": true + }, + "fontMetrics": { + "type": "array", + "items": { + "$ref": "#/$defs/fontMetrics" + } + }, + "themes": { + "type": "array", + "items": { + "$ref": "#/$defs/theme" + } + } + }, + "$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.-]+)?$" + }, + "identifier": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,63}$" + }, + "iconIdentifier": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{0,63}$" + }, + "relativePath": { + "type": "string", + "minLength": 1, + "maxLength": 240, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/-]+$" + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$" + }, + "paletteToken": { + "enum": [ + "canvas", + "surface", + "surfaceMuted", + "text", + "textMuted", + "border", + "accent", + "danger", + "connector" + ] + }, + "palette": { + "type": "object", + "additionalProperties": false, + "required": [ + "canvas", + "surface", + "surfaceMuted", + "text", + "textMuted", + "border", + "accent", + "danger", + "connector" + ], + "properties": { + "canvas": { "$ref": "#/$defs/color" }, + "surface": { "$ref": "#/$defs/color" }, + "surfaceMuted": { "$ref": "#/$defs/color" }, + "text": { "$ref": "#/$defs/color" }, + "textMuted": { "$ref": "#/$defs/color" }, + "border": { "$ref": "#/$defs/color" }, + "accent": { "$ref": "#/$defs/color" }, + "danger": { "$ref": "#/$defs/color" }, + "connector": { "$ref": "#/$defs/color" } + } + }, + "typography": { + "type": "object", + "additionalProperties": false, + "required": [ + "fontMetricsId", + "nodeLabelSizeMilliPx", + "nodeDetailSizeMilliPx", + "groupLabelSizeMilliPx", + "edgeLabelSizeMilliPx", + "lineHeightPermille", + "labelWeight", + "detailWeight" + ], + "properties": { + "fontMetricsId": { "$ref": "#/$defs/identifier" }, + "nodeLabelSizeMilliPx": { "$ref": "#/$defs/positiveDimension" }, + "nodeDetailSizeMilliPx": { "$ref": "#/$defs/positiveDimension" }, + "groupLabelSizeMilliPx": { "$ref": "#/$defs/positiveDimension" }, + "edgeLabelSizeMilliPx": { "$ref": "#/$defs/positiveDimension" }, + "lineHeightPermille": { + "type": "integer", + "minimum": 1000, + "maximum": 3000 + }, + "labelWeight": { "$ref": "#/$defs/fontWeight" }, + "detailWeight": { "$ref": "#/$defs/fontWeight" } + } + }, + "positiveDimension": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "fontWeight": { + "type": "integer", + "minimum": 100, + "maximum": 900, + "multipleOf": 100 + }, + "nodeVisual": { + "type": "object", + "additionalProperties": false, + "required": [ + "shape", + "fill", + "stroke", + "text", + "accent", + "cornerRadiusMilliPx", + "fallbackIconId" + ], + "properties": { + "shape": { + "enum": [ + "rounded-rectangle", + "capsule", + "circle", + "cylinder", + "hexagon" + ] + }, + "fill": { "$ref": "#/$defs/paletteToken" }, + "stroke": { "$ref": "#/$defs/paletteToken" }, + "text": { "$ref": "#/$defs/paletteToken" }, + "accent": { "$ref": "#/$defs/paletteToken" }, + "cornerRadiusMilliPx": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 + }, + "fallbackIconId": { "$ref": "#/$defs/iconIdentifier" } + } + }, + "nodeKindFallbacks": { + "type": "object", + "additionalProperties": false, + "required": [ + "actor", + "client", + "service", + "function", + "worker", + "database", + "cache", + "queue", + "storage", + "external" + ], + "properties": { + "actor": { "$ref": "#/$defs/nodeVisual" }, + "client": { "$ref": "#/$defs/nodeVisual" }, + "service": { "$ref": "#/$defs/nodeVisual" }, + "function": { "$ref": "#/$defs/nodeVisual" }, + "worker": { "$ref": "#/$defs/nodeVisual" }, + "database": { "$ref": "#/$defs/nodeVisual" }, + "cache": { "$ref": "#/$defs/nodeVisual" }, + "queue": { "$ref": "#/$defs/nodeVisual" }, + "storage": { "$ref": "#/$defs/nodeVisual" }, + "external": { "$ref": "#/$defs/nodeVisual" } + } + }, + "connectorStyle": { + "type": "object", + "additionalProperties": false, + "required": [ + "stroke", + "text", + "labelBackground", + "widthMilliPx", + "arrowSizeMilliPx" + ], + "properties": { + "stroke": { "$ref": "#/$defs/paletteToken" }, + "text": { "$ref": "#/$defs/paletteToken" }, + "labelBackground": { "$ref": "#/$defs/paletteToken" }, + "widthMilliPx": { "$ref": "#/$defs/positiveDimension" }, + "arrowSizeMilliPx": { "$ref": "#/$defs/positiveDimension" }, + "dashMilliPx": { + "type": "array", + "items": { "$ref": "#/$defs/positiveDimension" }, + "minItems": 2, + "maxItems": 8 + } + } + }, + "redistribution": { + "type": "object", + "additionalProperties": false, + "required": [ + "cargo", + "npm", + "wasm", + "commercialApplications" + ], + "properties": { + "cargo": { "type": "boolean" }, + "npm": { "type": "boolean" }, + "wasm": { "type": "boolean" }, + "commercialApplications": { "type": "boolean" } + } + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": [ + "sourceUrl", + "sourceRevision", + "copyright", + "licenseSpdx", + "licenseFile", + "modified", + "redistribution" + ], + "properties": { + "sourceUrl": { + "type": "string", + "pattern": "^https://[^\\s]+$" + }, + "sourceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "copyright": { + "type": "string", + "minLength": 1, + "maxLength": 240 + }, + "licenseSpdx": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "licenseFile": { "$ref": "#/$defs/relativePath" }, + "modified": { "type": "boolean" }, + "redistribution": { "$ref": "#/$defs/redistribution" } + } + }, + "iconAsset": { + "type": "object", + "additionalProperties": false, + "required": ["path", "viewBox", "provenance"], + "properties": { + "path": { "$ref": "#/$defs/relativePath" }, + "viewBox": { + "type": "array", + "prefixItems": [ + { "type": "integer" }, + { "type": "integer" }, + { "type": "integer", "minimum": 1 }, + { "type": "integer", "minimum": 1 } + ], + "items": false, + "minItems": 4, + "maxItems": 4 + }, + "provenance": { "$ref": "#/$defs/provenance" } + } + }, + "icon": { + "type": "object", + "additionalProperties": false, + "required": ["id", "subject", "asset"], + "properties": { + "id": { "$ref": "#/$defs/iconIdentifier" }, + "subject": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 240 + }, + "asset": { "$ref": "#/$defs/iconAsset" } + } + }, + "theme": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "palette", + "typography", + "nodeKindFallbacks", + "connector", + "icons" + ], + "properties": { + "id": { "$ref": "#/$defs/identifier" }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 240 + }, + "palette": { "$ref": "#/$defs/palette" }, + "typography": { "$ref": "#/$defs/typography" }, + "nodeKindFallbacks": { "$ref": "#/$defs/nodeKindFallbacks" }, + "connector": { "$ref": "#/$defs/connectorStyle" }, + "icons": { + "type": "array", + "items": { "$ref": "#/$defs/icon" } + } + } + }, + "glyphAdvances": { + "type": "object", + "propertyNames": { + "pattern": "^U\\+[0-9A-F]{4,6}$" + }, + "additionalProperties": { + "type": "integer", + "minimum": 1, + "maximum": 32767 + } + }, + "fontMetrics": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "family", + "version", + "unitsPerEm", + "ascent", + "descent", + "lineGap", + "defaultAdvance", + "glyphAdvances", + "provenance" + ], + "properties": { + "id": { "$ref": "#/$defs/identifier" }, + "family": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "unitsPerEm": { + "type": "integer", + "minimum": 16, + "maximum": 16384 + }, + "ascent": { + "type": "integer", + "minimum": 1, + "maximum": 32767 + }, + "descent": { + "type": "integer", + "minimum": -32768, + "maximum": 0 + }, + "lineGap": { + "type": "integer", + "minimum": 0, + "maximum": 32767 + }, + "defaultAdvance": { + "type": "integer", + "minimum": 1, + "maximum": 32767 + }, + "glyphAdvances": { "$ref": "#/$defs/glyphAdvances" }, + "provenance": { "$ref": "#/$defs/provenance" } + } + } + } +} diff --git a/crates/stack-theme/src/generated/catalog.json b/crates/stack-theme/src/generated/catalog.json new file mode 100644 index 0000000..3f883f3 --- /dev/null +++ b/crates/stack-theme/src/generated/catalog.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/catalog.schema.json", + "schemaVersion": "1.0", + "catalogVersion": "0.1.0", + "reservedThemeIds": [], + "fontMetrics": [], + "themes": [] +} diff --git a/crates/stack-theme/src/generated/metadata.rs b/crates/stack-theme/src/generated/metadata.rs new file mode 100644 index 0000000..13cd487 --- /dev/null +++ b/crates/stack-theme/src/generated/metadata.rs @@ -0,0 +1,7 @@ +// Generated by scripts/generate.mjs. Do not edit. +pub const CATALOG_VERSION: &str = "0.1.0"; +pub const CATALOG_REVISION: &str = "sha256:4615e4947392933d443da7142f2c54e95bfb13330f3924de7432c297111978e8"; + +pub fn icon_svg(_asset_path: &str) -> Option<&'static str> { + None +} diff --git a/crates/stack-theme/src/lib.rs b/crates/stack-theme/src/lib.rs new file mode 100644 index 0000000..944fab8 --- /dev/null +++ b/crates/stack-theme/src/lib.rs @@ -0,0 +1,284 @@ +//! Typed access to the canonical Stack theme catalog. +//! +//! The embedded data is generated from `catalog/catalog.json`. It performs no +//! filesystem, network, clock, locale, or host-font access at runtime. + +use std::collections::BTreeMap; +use std::sync::OnceLock; + +use serde::{Deserialize, Serialize}; + +mod generated { + include!("generated/metadata.rs"); +} + +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"); +static CATALOG: OnceLock = OnceLock::new(); + +/// Returns the embedded catalog parsed into the public Rust contract. +#[must_use] +pub fn catalog() -> &'static Catalog { + CATALOG.get_or_init(|| { + serde_json::from_str(CATALOG_JSON).expect("generated catalog must match the Rust contract") + }) +} + +/// Returns the exact generated JSON embedded in the crate. +#[must_use] +pub const fn catalog_json() -> &'static str { + CATALOG_JSON +} + +/// Returns the JSON Schema for the embedded catalog document shape. +#[must_use] +pub const fn catalog_schema_json() -> &'static str { + CATALOG_SCHEMA_JSON +} + +/// Returns one validated SVG asset by its catalog path. +/// +/// The bytes are embedded at compile time; this function never reads the host +/// filesystem or performs network access. +#[must_use] +pub fn icon_svg(asset_path: &str) -> Option<&'static str> { + generated::icon_svg(asset_path) +} + +/// The complete versioned theme catalog. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Catalog { + /// JSON Schema location recorded by the source catalog. + #[serde(rename = "$schema")] + pub schema: String, + /// Major/minor version of the catalog document shape. + pub schema_version: String, + /// Version shared by the catalog and both distribution packages. + pub catalog_version: String, + /// Theme identifiers that cannot be registered again. + pub reserved_theme_ids: Vec, + /// Deterministic, versioned font measurement tables. + pub font_metrics: Vec, + /// Theme records in canonical catalog order. + pub themes: Vec, +} + +/// One deterministic font measurement table. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FontMetrics { + /// Catalog-local metrics identifier. + pub id: String, + /// Display family name. + pub family: String, + /// Upstream or repository-authored metrics version. + pub version: String, + /// Font design units per em. + pub units_per_em: u32, + /// Ascender in font design units. + pub ascent: i32, + /// Descender in font design units. + pub descent: i32, + /// Additional line gap in font design units. + pub line_gap: u32, + /// Advance used for a scalar absent from `glyph_advances`. + pub default_advance: u32, + /// Unicode scalar advances keyed as uppercase `U+XXXX` values. + pub glyph_advances: BTreeMap, + /// Source, license, and distribution evidence for the metrics. + pub provenance: Provenance, +} + +/// One theme and its theme-local icon collection. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Theme { + /// Global Stack theme identifier. + pub id: String, + /// Human-readable theme name. + pub name: String, + /// Optional contributor-facing description. + pub description: Option, + /// Named colors available to other theme records. + pub palette: Palette, + /// Typography sizes, weights, and deterministic metrics reference. + pub typography: Typography, + /// Visual fallback for every Stack node kind. + pub node_kind_fallbacks: NodeKindFallbacks, + /// Connector and connector-label treatment. + pub connector: ConnectorStyle, + /// Theme-local named and fallback icon assets. + pub icons: Vec, +} + +/// Required semantic color slots. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Palette { + pub canvas: String, + pub surface: String, + pub surface_muted: String, + pub text: String, + pub text_muted: String, + pub border: String, + pub accent: String, + pub danger: String, + pub connector: String, +} + +/// Typography values expressed without platform font measurement. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Typography { + pub font_metrics_id: String, + pub node_label_size_milli_px: u32, + pub node_detail_size_milli_px: u32, + pub group_label_size_milli_px: u32, + pub edge_label_size_milli_px: u32, + pub line_height_permille: u32, + pub label_weight: u16, + pub detail_weight: u16, +} + +/// Complete fallback mapping for Stack 1.0 node kinds. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NodeKindFallbacks { + pub actor: NodeVisual, + pub client: NodeVisual, + pub service: NodeVisual, + #[serde(rename = "function")] + pub function_: NodeVisual, + pub worker: NodeVisual, + pub database: NodeVisual, + pub cache: NodeVisual, + pub queue: NodeVisual, + pub storage: NodeVisual, + pub external: NodeVisual, +} + +/// Node shape, palette references, and fallback icon. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NodeVisual { + pub shape: NodeShape, + pub fill: PaletteToken, + pub stroke: PaletteToken, + pub text: PaletteToken, + pub accent: PaletteToken, + pub corner_radius_milli_px: u32, + pub fallback_icon_id: String, +} + +/// Renderer-supported node outlines. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum NodeShape { + RoundedRectangle, + Capsule, + Circle, + Cylinder, + Hexagon, +} + +/// A reference to a required palette slot. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum PaletteToken { + Canvas, + Surface, + SurfaceMuted, + Text, + TextMuted, + Border, + Accent, + Danger, + Connector, +} + +/// Connector line and label treatment. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectorStyle { + pub stroke: PaletteToken, + pub text: PaletteToken, + pub label_background: PaletteToken, + pub width_milli_px: u32, + pub arrow_size_milli_px: u32, + pub dash_milli_px: Option>, +} + +/// Theme-local icon metadata and its safe SVG asset. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Icon { + pub id: String, + pub subject: String, + pub description: Option, + pub asset: IconAsset, +} + +/// A repository-relative icon asset and declared viewport. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IconAsset { + pub path: String, + pub view_box: [i32; 4], + pub provenance: Provenance, +} + +/// Source, license, and distribution evidence for an asset. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Provenance { + pub source_url: String, + pub source_revision: String, + pub copyright: String, + pub license_spdx: String, + pub license_file: String, + pub modified: bool, + pub redistribution: Redistribution, +} + +/// Supported artifact and application distribution channels. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Redistribution { + pub cargo: bool, + pub npm: bool, + pub wasm: bool, + pub commercial_applications: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_catalog_matches_public_metadata() { + let catalog = catalog(); + + assert_eq!(catalog.schema_version, "1.0"); + assert_eq!(catalog.catalog_version, CATALOG_VERSION); + assert!(CATALOG_REVISION.starts_with("sha256:")); + assert_eq!(CATALOG_REVISION.len(), 71); + assert_eq!(icon_svg("assets/missing.svg"), None); + } + + #[test] + fn embedded_catalog_round_trips_semantically() { + let reparsed: Catalog = serde_json::from_str(catalog_json()).unwrap(); + let serialized = serde_json::to_value(&reparsed).unwrap(); + let source: serde_json::Value = serde_json::from_str(catalog_json()).unwrap(); + let schema: serde_json::Value = serde_json::from_str(catalog_schema_json()).unwrap(); + + assert_eq!(serialized, source); + assert_eq!( + schema["$schema"], + "https://json-schema.org/draft/2020-12/schema" + ); + } +} diff --git a/docs/decisions/0001-use-one-generated-catalog-for-cargo-and-npm.md b/docs/decisions/0001-use-one-generated-catalog-for-cargo-and-npm.md new file mode 100644 index 0000000..c19d024 --- /dev/null +++ b/docs/decisions/0001-use-one-generated-catalog-for-cargo-and-npm.md @@ -0,0 +1,52 @@ +# ADR-0001: Use One Generated Catalog for Cargo and npm + +## Status + +Accepted + +## Date + +2026-09-03 + +## Context + +The native engine and browser WASM adapter must resolve the same theme, icon, and font metric data without host I/O. Independent Rust and JavaScript catalogs would make visual drift possible even when both packages use the same semantic version. Using a Git commit as embedded revision metadata would require a build-time Git checkout and would create a self-referential value for checked-in artifacts. + +SVG assets also cross a security and licensing boundary. A renderer cannot safely inline arbitrary markup or fetch resources named by Stack source. Asset provenance must travel with the canonical catalog and be validated before either package exposes it. + +## Decision + +`catalog/catalog.json` and its referenced repository assets are the only source catalog. JSON Schema defines the portable document shape. A repository validator enforces referential integrity, identifier registration, cross-theme icon subject consistency, distribution permission, license-file presence, and an SVG element and attribute allowlist. + +One generator normalizes the catalog, hashes the normalized JSON plus path-ordered SVG bytes, and writes equivalent Cargo and npm artifacts with the same catalog version and `sha256:` revision. Generated artifacts are committed and verified in CI. Runtime APIs use only embedded data. + +All layout-affecting dimensions and font measurements use integers. Font metrics provide a deterministic default advance for Unicode scalars not listed explicitly. + +## Consequences + +- Rust, native, WASM, and JavaScript consumers can record one reproducible catalog identity. +- Package generation is deterministic and does not depend on Git metadata. +- A source or asset change requires regenerated artifacts and changes the revision. +- The repository owns validation tooling, while renderers consume only already validated data. +- Catalog package versions move together, so Cargo and npm releases must be coordinated. +- The initial contract catalog may be empty; required core themes are added and visually reviewed in the next change before any registry release. + +## Alternatives Considered + +### Maintain handwritten package-specific catalogs + +- Pros: No generation step. +- Cons: Semantic equality depends on manual synchronization. +- Rejected: Cross-runtime visual parity is a core requirement. + +### Embed the Git commit SHA + +- Pros: Direct link to repository history. +- Cons: Checked-in generated files cannot include the commit that contains them, and package builds would require Git metadata. +- Rejected: A content revision is self-contained and reproducible. + +### Allow runtime theme or icon loading + +- Pros: Third parties can add assets without rebuilding packages. +- Cons: Introduces filesystem or network behavior, weakens provenance enforcement, and makes native/WASM output depend on the host. +- Rejected: The first engine requires a fully installed, versioned catalog. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2d853b7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,482 @@ +{ + "name": "stack-theme-workspace", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "stack-theme-workspace", + "version": "0.1.0", + "workspaces": [ + "packages/theme" + ], + "devDependencies": { + "ajv": "8.20.0", + "saxes": "6.0.0", + "typescript": "7.0.2" + } + }, + "node_modules/@stack-sh/theme": { + "resolved": "packages/theme", + "link": true + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "packages/theme": { + "name": "@stack-sh/theme", + "version": "0.1.0", + "license": "Apache-2.0" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ec5a9e1 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "stack-theme-workspace", + "private": true, + "version": "0.1.0", + "workspaces": [ + "packages/theme" + ], + "scripts": { + "generate": "node scripts/generate.mjs", + "generate:check": "node scripts/generate.mjs --check", + "test": "node --test tests/*.test.mjs", + "typecheck": "tsc --project tsconfig.json", + "validate": "node scripts/validate.mjs" + }, + "devDependencies": { + "ajv": "8.20.0", + "saxes": "6.0.0", + "typescript": "7.0.2" + } +} diff --git a/packages/theme/LICENSE b/packages/theme/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/packages/theme/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/theme/README.md b/packages/theme/README.md new file mode 100644 index 0000000..09e23a5 --- /dev/null +++ b/packages/theme/README.md @@ -0,0 +1,5 @@ +# @stack-sh/theme + +`@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. diff --git a/packages/theme/catalog-metadata.json b/packages/theme/catalog-metadata.json new file mode 100644 index 0000000..e61f0f3 --- /dev/null +++ b/packages/theme/catalog-metadata.json @@ -0,0 +1,4 @@ +{ + "catalogVersion": "0.1.0", + "catalogRevision": "sha256:4615e4947392933d443da7142f2c54e95bfb13330f3924de7432c297111978e8" +} diff --git a/packages/theme/catalog.generated.js b/packages/theme/catalog.generated.js new file mode 100644 index 0000000..8aabf54 --- /dev/null +++ b/packages/theme/catalog.generated.js @@ -0,0 +1,28 @@ +// Generated by scripts/generate.mjs. Do not edit. +const catalogData = { + "$schema": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/catalog.schema.json", + "schemaVersion": "1.0", + "catalogVersion": "0.1.0", + "reservedThemeIds": [], + "fontMetrics": [], + "themes": [] +}; +const iconAssetsData = {}; + +function deepFreeze(value) { + Object.freeze(value); + for (const child of Object.values(value)) { + if (child !== null && typeof child === "object" && !Object.isFrozen(child)) { + deepFreeze(child); + } + } + return value; +} + +export const catalog = deepFreeze(catalogData); +export const catalogVersion = "0.1.0"; +export const catalogRevision = "sha256:4615e4947392933d443da7142f2c54e95bfb13330f3924de7432c297111978e8"; +export const iconAssets = deepFreeze(iconAssetsData); +export function iconSvg(assetPath) { + return iconAssets[assetPath]; +} diff --git a/packages/theme/catalog.json b/packages/theme/catalog.json new file mode 100644 index 0000000..3f883f3 --- /dev/null +++ b/packages/theme/catalog.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/catalog.schema.json", + "schemaVersion": "1.0", + "catalogVersion": "0.1.0", + "reservedThemeIds": [], + "fontMetrics": [], + "themes": [] +} diff --git a/packages/theme/index.d.ts b/packages/theme/index.d.ts new file mode 100644 index 0000000..fbfa632 --- /dev/null +++ b/packages/theme/index.d.ts @@ -0,0 +1,141 @@ +export type PaletteToken = + | "canvas" + | "surface" + | "surfaceMuted" + | "text" + | "textMuted" + | "border" + | "accent" + | "danger" + | "connector"; + +export type NodeShape = + | "rounded-rectangle" + | "capsule" + | "circle" + | "cylinder" + | "hexagon"; + +export interface Catalog { + readonly $schema: string; + readonly schemaVersion: "1.0"; + readonly catalogVersion: string; + readonly reservedThemeIds: readonly string[]; + readonly fontMetrics: readonly FontMetrics[]; + readonly themes: readonly Theme[]; +} + +export interface FontMetrics { + readonly id: string; + readonly family: string; + readonly version: string; + readonly unitsPerEm: number; + readonly ascent: number; + readonly descent: number; + readonly lineGap: number; + readonly defaultAdvance: number; + readonly glyphAdvances: Readonly>; + readonly provenance: Provenance; +} + +export interface Theme { + readonly id: string; + readonly name: string; + readonly description?: string; + readonly palette: Palette; + readonly typography: Typography; + readonly nodeKindFallbacks: NodeKindFallbacks; + readonly connector: ConnectorStyle; + readonly icons: readonly Icon[]; +} + +export interface Palette { + readonly canvas: string; + readonly surface: string; + readonly surfaceMuted: string; + readonly text: string; + readonly textMuted: string; + readonly border: string; + readonly accent: string; + readonly danger: string; + readonly connector: string; +} + +export interface Typography { + readonly fontMetricsId: string; + readonly nodeLabelSizeMilliPx: number; + readonly nodeDetailSizeMilliPx: number; + readonly groupLabelSizeMilliPx: number; + readonly edgeLabelSizeMilliPx: number; + readonly lineHeightPermille: number; + readonly labelWeight: number; + readonly detailWeight: number; +} + +export interface NodeKindFallbacks { + readonly actor: NodeVisual; + readonly client: NodeVisual; + readonly service: NodeVisual; + readonly function: NodeVisual; + readonly worker: NodeVisual; + readonly database: NodeVisual; + readonly cache: NodeVisual; + readonly queue: NodeVisual; + readonly storage: NodeVisual; + readonly external: NodeVisual; +} + +export interface NodeVisual { + readonly shape: NodeShape; + readonly fill: PaletteToken; + readonly stroke: PaletteToken; + readonly text: PaletteToken; + readonly accent: PaletteToken; + readonly cornerRadiusMilliPx: number; + readonly fallbackIconId: string; +} + +export interface ConnectorStyle { + readonly stroke: PaletteToken; + readonly text: PaletteToken; + readonly labelBackground: PaletteToken; + readonly widthMilliPx: number; + readonly arrowSizeMilliPx: number; + readonly dashMilliPx?: readonly number[]; +} + +export interface Icon { + readonly id: string; + readonly subject: string; + readonly description?: string; + readonly asset: IconAsset; +} + +export interface IconAsset { + readonly path: string; + readonly viewBox: readonly [number, number, number, number]; + readonly provenance: Provenance; +} + +export interface Provenance { + readonly sourceUrl: string; + readonly sourceRevision: string; + readonly copyright: string; + readonly licenseSpdx: string; + readonly licenseFile: string; + readonly modified: boolean; + readonly redistribution: Redistribution; +} + +export interface Redistribution { + readonly cargo: boolean; + readonly npm: boolean; + readonly wasm: boolean; + readonly commercialApplications: boolean; +} + +export declare const catalog: Readonly; +export declare const catalogVersion: string; +export declare const catalogRevision: `sha256:${string}`; +export declare const iconAssets: Readonly>; +export declare function iconSvg(assetPath: string): string | undefined; diff --git a/packages/theme/index.js b/packages/theme/index.js new file mode 100644 index 0000000..1c3792b --- /dev/null +++ b/packages/theme/index.js @@ -0,0 +1,7 @@ +export { + catalog, + catalogRevision, + catalogVersion, + iconAssets, + iconSvg, +} from "./catalog.generated.js"; diff --git a/packages/theme/package.json b/packages/theme/package.json new file mode 100644 index 0000000..f7223f8 --- /dev/null +++ b/packages/theme/package.json @@ -0,0 +1,34 @@ +{ + "name": "@stack-sh/theme", + "version": "0.1.0", + "description": "Canonical Stack theme catalog contract and embedded catalog data", + "type": "module", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/stack-sh/theme.git" + }, + "sideEffects": false, + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./index.js", + "default": "./index.js" + }, + "./catalog.json": "./catalog.json", + "./catalog-metadata.json": "./catalog-metadata.json", + "./schema/catalog.schema.json": "./schema/catalog.schema.json" + }, + "files": [ + "assets", + "catalog.generated.js", + "catalog.json", + "catalog-metadata.json", + "index.d.ts", + "index.js", + "LICENSE", + "licenses", + "README.md", + "schema" + ] +} diff --git a/packages/theme/schema/catalog.schema.json b/packages/theme/schema/catalog.schema.json new file mode 100644 index 0000000..f9f09a4 --- /dev/null +++ b/packages/theme/schema/catalog.schema.json @@ -0,0 +1,428 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/catalog.schema.json", + "title": "Stack Theme Catalog", + "description": "The portable source contract for Stack theme, icon, and deterministic font metric data.", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "schemaVersion", + "catalogVersion", + "reservedThemeIds", + "fontMetrics", + "themes" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schemaVersion": { + "const": "1.0" + }, + "catalogVersion": { + "$ref": "#/$defs/semanticVersion" + }, + "reservedThemeIds": { + "type": "array", + "items": { + "$ref": "#/$defs/identifier" + }, + "uniqueItems": true + }, + "fontMetrics": { + "type": "array", + "items": { + "$ref": "#/$defs/fontMetrics" + } + }, + "themes": { + "type": "array", + "items": { + "$ref": "#/$defs/theme" + } + } + }, + "$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.-]+)?$" + }, + "identifier": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,63}$" + }, + "iconIdentifier": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{0,63}$" + }, + "relativePath": { + "type": "string", + "minLength": 1, + "maxLength": 240, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/-]+$" + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$" + }, + "paletteToken": { + "enum": [ + "canvas", + "surface", + "surfaceMuted", + "text", + "textMuted", + "border", + "accent", + "danger", + "connector" + ] + }, + "palette": { + "type": "object", + "additionalProperties": false, + "required": [ + "canvas", + "surface", + "surfaceMuted", + "text", + "textMuted", + "border", + "accent", + "danger", + "connector" + ], + "properties": { + "canvas": { "$ref": "#/$defs/color" }, + "surface": { "$ref": "#/$defs/color" }, + "surfaceMuted": { "$ref": "#/$defs/color" }, + "text": { "$ref": "#/$defs/color" }, + "textMuted": { "$ref": "#/$defs/color" }, + "border": { "$ref": "#/$defs/color" }, + "accent": { "$ref": "#/$defs/color" }, + "danger": { "$ref": "#/$defs/color" }, + "connector": { "$ref": "#/$defs/color" } + } + }, + "typography": { + "type": "object", + "additionalProperties": false, + "required": [ + "fontMetricsId", + "nodeLabelSizeMilliPx", + "nodeDetailSizeMilliPx", + "groupLabelSizeMilliPx", + "edgeLabelSizeMilliPx", + "lineHeightPermille", + "labelWeight", + "detailWeight" + ], + "properties": { + "fontMetricsId": { "$ref": "#/$defs/identifier" }, + "nodeLabelSizeMilliPx": { "$ref": "#/$defs/positiveDimension" }, + "nodeDetailSizeMilliPx": { "$ref": "#/$defs/positiveDimension" }, + "groupLabelSizeMilliPx": { "$ref": "#/$defs/positiveDimension" }, + "edgeLabelSizeMilliPx": { "$ref": "#/$defs/positiveDimension" }, + "lineHeightPermille": { + "type": "integer", + "minimum": 1000, + "maximum": 3000 + }, + "labelWeight": { "$ref": "#/$defs/fontWeight" }, + "detailWeight": { "$ref": "#/$defs/fontWeight" } + } + }, + "positiveDimension": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "fontWeight": { + "type": "integer", + "minimum": 100, + "maximum": 900, + "multipleOf": 100 + }, + "nodeVisual": { + "type": "object", + "additionalProperties": false, + "required": [ + "shape", + "fill", + "stroke", + "text", + "accent", + "cornerRadiusMilliPx", + "fallbackIconId" + ], + "properties": { + "shape": { + "enum": [ + "rounded-rectangle", + "capsule", + "circle", + "cylinder", + "hexagon" + ] + }, + "fill": { "$ref": "#/$defs/paletteToken" }, + "stroke": { "$ref": "#/$defs/paletteToken" }, + "text": { "$ref": "#/$defs/paletteToken" }, + "accent": { "$ref": "#/$defs/paletteToken" }, + "cornerRadiusMilliPx": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 + }, + "fallbackIconId": { "$ref": "#/$defs/iconIdentifier" } + } + }, + "nodeKindFallbacks": { + "type": "object", + "additionalProperties": false, + "required": [ + "actor", + "client", + "service", + "function", + "worker", + "database", + "cache", + "queue", + "storage", + "external" + ], + "properties": { + "actor": { "$ref": "#/$defs/nodeVisual" }, + "client": { "$ref": "#/$defs/nodeVisual" }, + "service": { "$ref": "#/$defs/nodeVisual" }, + "function": { "$ref": "#/$defs/nodeVisual" }, + "worker": { "$ref": "#/$defs/nodeVisual" }, + "database": { "$ref": "#/$defs/nodeVisual" }, + "cache": { "$ref": "#/$defs/nodeVisual" }, + "queue": { "$ref": "#/$defs/nodeVisual" }, + "storage": { "$ref": "#/$defs/nodeVisual" }, + "external": { "$ref": "#/$defs/nodeVisual" } + } + }, + "connectorStyle": { + "type": "object", + "additionalProperties": false, + "required": [ + "stroke", + "text", + "labelBackground", + "widthMilliPx", + "arrowSizeMilliPx" + ], + "properties": { + "stroke": { "$ref": "#/$defs/paletteToken" }, + "text": { "$ref": "#/$defs/paletteToken" }, + "labelBackground": { "$ref": "#/$defs/paletteToken" }, + "widthMilliPx": { "$ref": "#/$defs/positiveDimension" }, + "arrowSizeMilliPx": { "$ref": "#/$defs/positiveDimension" }, + "dashMilliPx": { + "type": "array", + "items": { "$ref": "#/$defs/positiveDimension" }, + "minItems": 2, + "maxItems": 8 + } + } + }, + "redistribution": { + "type": "object", + "additionalProperties": false, + "required": [ + "cargo", + "npm", + "wasm", + "commercialApplications" + ], + "properties": { + "cargo": { "type": "boolean" }, + "npm": { "type": "boolean" }, + "wasm": { "type": "boolean" }, + "commercialApplications": { "type": "boolean" } + } + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": [ + "sourceUrl", + "sourceRevision", + "copyright", + "licenseSpdx", + "licenseFile", + "modified", + "redistribution" + ], + "properties": { + "sourceUrl": { + "type": "string", + "pattern": "^https://[^\\s]+$" + }, + "sourceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "copyright": { + "type": "string", + "minLength": 1, + "maxLength": 240 + }, + "licenseSpdx": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "licenseFile": { "$ref": "#/$defs/relativePath" }, + "modified": { "type": "boolean" }, + "redistribution": { "$ref": "#/$defs/redistribution" } + } + }, + "iconAsset": { + "type": "object", + "additionalProperties": false, + "required": ["path", "viewBox", "provenance"], + "properties": { + "path": { "$ref": "#/$defs/relativePath" }, + "viewBox": { + "type": "array", + "prefixItems": [ + { "type": "integer" }, + { "type": "integer" }, + { "type": "integer", "minimum": 1 }, + { "type": "integer", "minimum": 1 } + ], + "items": false, + "minItems": 4, + "maxItems": 4 + }, + "provenance": { "$ref": "#/$defs/provenance" } + } + }, + "icon": { + "type": "object", + "additionalProperties": false, + "required": ["id", "subject", "asset"], + "properties": { + "id": { "$ref": "#/$defs/iconIdentifier" }, + "subject": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 240 + }, + "asset": { "$ref": "#/$defs/iconAsset" } + } + }, + "theme": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "palette", + "typography", + "nodeKindFallbacks", + "connector", + "icons" + ], + "properties": { + "id": { "$ref": "#/$defs/identifier" }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 240 + }, + "palette": { "$ref": "#/$defs/palette" }, + "typography": { "$ref": "#/$defs/typography" }, + "nodeKindFallbacks": { "$ref": "#/$defs/nodeKindFallbacks" }, + "connector": { "$ref": "#/$defs/connectorStyle" }, + "icons": { + "type": "array", + "items": { "$ref": "#/$defs/icon" } + } + } + }, + "glyphAdvances": { + "type": "object", + "propertyNames": { + "pattern": "^U\\+[0-9A-F]{4,6}$" + }, + "additionalProperties": { + "type": "integer", + "minimum": 1, + "maximum": 32767 + } + }, + "fontMetrics": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "family", + "version", + "unitsPerEm", + "ascent", + "descent", + "lineGap", + "defaultAdvance", + "glyphAdvances", + "provenance" + ], + "properties": { + "id": { "$ref": "#/$defs/identifier" }, + "family": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "unitsPerEm": { + "type": "integer", + "minimum": 16, + "maximum": 16384 + }, + "ascent": { + "type": "integer", + "minimum": 1, + "maximum": 32767 + }, + "descent": { + "type": "integer", + "minimum": -32768, + "maximum": 0 + }, + "lineGap": { + "type": "integer", + "minimum": 0, + "maximum": 32767 + }, + "defaultAdvance": { + "type": "integer", + "minimum": 1, + "maximum": 32767 + }, + "glyphAdvances": { "$ref": "#/$defs/glyphAdvances" }, + "provenance": { "$ref": "#/$defs/provenance" } + } + } + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..7e18c85 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.85.0" +components = ["clippy", "rustfmt"] +profile = "minimal" diff --git a/schemas/catalog.schema.json b/schemas/catalog.schema.json new file mode 100644 index 0000000..f9f09a4 --- /dev/null +++ b/schemas/catalog.schema.json @@ -0,0 +1,428 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/catalog.schema.json", + "title": "Stack Theme Catalog", + "description": "The portable source contract for Stack theme, icon, and deterministic font metric data.", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "schemaVersion", + "catalogVersion", + "reservedThemeIds", + "fontMetrics", + "themes" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schemaVersion": { + "const": "1.0" + }, + "catalogVersion": { + "$ref": "#/$defs/semanticVersion" + }, + "reservedThemeIds": { + "type": "array", + "items": { + "$ref": "#/$defs/identifier" + }, + "uniqueItems": true + }, + "fontMetrics": { + "type": "array", + "items": { + "$ref": "#/$defs/fontMetrics" + } + }, + "themes": { + "type": "array", + "items": { + "$ref": "#/$defs/theme" + } + } + }, + "$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.-]+)?$" + }, + "identifier": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,63}$" + }, + "iconIdentifier": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{0,63}$" + }, + "relativePath": { + "type": "string", + "minLength": 1, + "maxLength": 240, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/-]+$" + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$" + }, + "paletteToken": { + "enum": [ + "canvas", + "surface", + "surfaceMuted", + "text", + "textMuted", + "border", + "accent", + "danger", + "connector" + ] + }, + "palette": { + "type": "object", + "additionalProperties": false, + "required": [ + "canvas", + "surface", + "surfaceMuted", + "text", + "textMuted", + "border", + "accent", + "danger", + "connector" + ], + "properties": { + "canvas": { "$ref": "#/$defs/color" }, + "surface": { "$ref": "#/$defs/color" }, + "surfaceMuted": { "$ref": "#/$defs/color" }, + "text": { "$ref": "#/$defs/color" }, + "textMuted": { "$ref": "#/$defs/color" }, + "border": { "$ref": "#/$defs/color" }, + "accent": { "$ref": "#/$defs/color" }, + "danger": { "$ref": "#/$defs/color" }, + "connector": { "$ref": "#/$defs/color" } + } + }, + "typography": { + "type": "object", + "additionalProperties": false, + "required": [ + "fontMetricsId", + "nodeLabelSizeMilliPx", + "nodeDetailSizeMilliPx", + "groupLabelSizeMilliPx", + "edgeLabelSizeMilliPx", + "lineHeightPermille", + "labelWeight", + "detailWeight" + ], + "properties": { + "fontMetricsId": { "$ref": "#/$defs/identifier" }, + "nodeLabelSizeMilliPx": { "$ref": "#/$defs/positiveDimension" }, + "nodeDetailSizeMilliPx": { "$ref": "#/$defs/positiveDimension" }, + "groupLabelSizeMilliPx": { "$ref": "#/$defs/positiveDimension" }, + "edgeLabelSizeMilliPx": { "$ref": "#/$defs/positiveDimension" }, + "lineHeightPermille": { + "type": "integer", + "minimum": 1000, + "maximum": 3000 + }, + "labelWeight": { "$ref": "#/$defs/fontWeight" }, + "detailWeight": { "$ref": "#/$defs/fontWeight" } + } + }, + "positiveDimension": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "fontWeight": { + "type": "integer", + "minimum": 100, + "maximum": 900, + "multipleOf": 100 + }, + "nodeVisual": { + "type": "object", + "additionalProperties": false, + "required": [ + "shape", + "fill", + "stroke", + "text", + "accent", + "cornerRadiusMilliPx", + "fallbackIconId" + ], + "properties": { + "shape": { + "enum": [ + "rounded-rectangle", + "capsule", + "circle", + "cylinder", + "hexagon" + ] + }, + "fill": { "$ref": "#/$defs/paletteToken" }, + "stroke": { "$ref": "#/$defs/paletteToken" }, + "text": { "$ref": "#/$defs/paletteToken" }, + "accent": { "$ref": "#/$defs/paletteToken" }, + "cornerRadiusMilliPx": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 + }, + "fallbackIconId": { "$ref": "#/$defs/iconIdentifier" } + } + }, + "nodeKindFallbacks": { + "type": "object", + "additionalProperties": false, + "required": [ + "actor", + "client", + "service", + "function", + "worker", + "database", + "cache", + "queue", + "storage", + "external" + ], + "properties": { + "actor": { "$ref": "#/$defs/nodeVisual" }, + "client": { "$ref": "#/$defs/nodeVisual" }, + "service": { "$ref": "#/$defs/nodeVisual" }, + "function": { "$ref": "#/$defs/nodeVisual" }, + "worker": { "$ref": "#/$defs/nodeVisual" }, + "database": { "$ref": "#/$defs/nodeVisual" }, + "cache": { "$ref": "#/$defs/nodeVisual" }, + "queue": { "$ref": "#/$defs/nodeVisual" }, + "storage": { "$ref": "#/$defs/nodeVisual" }, + "external": { "$ref": "#/$defs/nodeVisual" } + } + }, + "connectorStyle": { + "type": "object", + "additionalProperties": false, + "required": [ + "stroke", + "text", + "labelBackground", + "widthMilliPx", + "arrowSizeMilliPx" + ], + "properties": { + "stroke": { "$ref": "#/$defs/paletteToken" }, + "text": { "$ref": "#/$defs/paletteToken" }, + "labelBackground": { "$ref": "#/$defs/paletteToken" }, + "widthMilliPx": { "$ref": "#/$defs/positiveDimension" }, + "arrowSizeMilliPx": { "$ref": "#/$defs/positiveDimension" }, + "dashMilliPx": { + "type": "array", + "items": { "$ref": "#/$defs/positiveDimension" }, + "minItems": 2, + "maxItems": 8 + } + } + }, + "redistribution": { + "type": "object", + "additionalProperties": false, + "required": [ + "cargo", + "npm", + "wasm", + "commercialApplications" + ], + "properties": { + "cargo": { "type": "boolean" }, + "npm": { "type": "boolean" }, + "wasm": { "type": "boolean" }, + "commercialApplications": { "type": "boolean" } + } + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": [ + "sourceUrl", + "sourceRevision", + "copyright", + "licenseSpdx", + "licenseFile", + "modified", + "redistribution" + ], + "properties": { + "sourceUrl": { + "type": "string", + "pattern": "^https://[^\\s]+$" + }, + "sourceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "copyright": { + "type": "string", + "minLength": 1, + "maxLength": 240 + }, + "licenseSpdx": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "licenseFile": { "$ref": "#/$defs/relativePath" }, + "modified": { "type": "boolean" }, + "redistribution": { "$ref": "#/$defs/redistribution" } + } + }, + "iconAsset": { + "type": "object", + "additionalProperties": false, + "required": ["path", "viewBox", "provenance"], + "properties": { + "path": { "$ref": "#/$defs/relativePath" }, + "viewBox": { + "type": "array", + "prefixItems": [ + { "type": "integer" }, + { "type": "integer" }, + { "type": "integer", "minimum": 1 }, + { "type": "integer", "minimum": 1 } + ], + "items": false, + "minItems": 4, + "maxItems": 4 + }, + "provenance": { "$ref": "#/$defs/provenance" } + } + }, + "icon": { + "type": "object", + "additionalProperties": false, + "required": ["id", "subject", "asset"], + "properties": { + "id": { "$ref": "#/$defs/iconIdentifier" }, + "subject": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 240 + }, + "asset": { "$ref": "#/$defs/iconAsset" } + } + }, + "theme": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "palette", + "typography", + "nodeKindFallbacks", + "connector", + "icons" + ], + "properties": { + "id": { "$ref": "#/$defs/identifier" }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 240 + }, + "palette": { "$ref": "#/$defs/palette" }, + "typography": { "$ref": "#/$defs/typography" }, + "nodeKindFallbacks": { "$ref": "#/$defs/nodeKindFallbacks" }, + "connector": { "$ref": "#/$defs/connectorStyle" }, + "icons": { + "type": "array", + "items": { "$ref": "#/$defs/icon" } + } + } + }, + "glyphAdvances": { + "type": "object", + "propertyNames": { + "pattern": "^U\\+[0-9A-F]{4,6}$" + }, + "additionalProperties": { + "type": "integer", + "minimum": 1, + "maximum": 32767 + } + }, + "fontMetrics": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "family", + "version", + "unitsPerEm", + "ascent", + "descent", + "lineGap", + "defaultAdvance", + "glyphAdvances", + "provenance" + ], + "properties": { + "id": { "$ref": "#/$defs/identifier" }, + "family": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "unitsPerEm": { + "type": "integer", + "minimum": 16, + "maximum": 16384 + }, + "ascent": { + "type": "integer", + "minimum": 1, + "maximum": 32767 + }, + "descent": { + "type": "integer", + "minimum": -32768, + "maximum": 0 + }, + "lineGap": { + "type": "integer", + "minimum": 0, + "maximum": 32767 + }, + "defaultAdvance": { + "type": "integer", + "minimum": 1, + "maximum": 32767 + }, + "glyphAdvances": { "$ref": "#/$defs/glyphAdvances" }, + "provenance": { "$ref": "#/$defs/provenance" } + } + } + } +} diff --git a/scripts/catalog-lib.mjs b/scripts/catalog-lib.mjs new file mode 100644 index 0000000..4346a78 --- /dev/null +++ b/scripts/catalog-lib.mjs @@ -0,0 +1,337 @@ +import { createHash } from "node:crypto"; +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import Ajv2020 from "ajv/dist/2020.js"; +import { SaxesParser } from "saxes"; + +export const repositoryRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +const allowedElements = new Set([ + "circle", + "desc", + "ellipse", + "g", + "line", + "path", + "polygon", + "polyline", + "rect", + "svg", + "title", +]); + +const allowedAttributes = new Set([ + "aria-hidden", + "clip-rule", + "cx", + "cy", + "d", + "fill", + "fill-rule", + "height", + "opacity", + "points", + "r", + "role", + "rx", + "ry", + "stroke", + "stroke-linecap", + "stroke-linejoin", + "stroke-width", + "transform", + "viewBox", + "width", + "x", + "x1", + "x2", + "xmlns", + "y", + "y1", + "y2", +]); + +const nodeKinds = [ + "actor", + "client", + "service", + "function", + "worker", + "database", + "cache", + "queue", + "storage", + "external", +]; + +export async function readJson(filePath) { + return JSON.parse(await readFile(filePath, "utf8")); +} + +export function formatJson(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function fail(message) { + throw new Error(message); +} + +function resolveRepositoryPath(root, relativePath, label) { + const resolved = path.resolve(root, relativePath); + const relative = path.relative(root, resolved); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + fail(`${label} leaves the repository: ${relativePath}`); + } + return resolved; +} + +async function requireFile(filePath, label) { + let fileStat; + try { + fileStat = await stat(filePath); + } catch { + fail(`${label} does not exist: ${filePath}`); + } + if (!fileStat.isFile()) { + fail(`${label} is not a file: ${filePath}`); + } +} + +function validateAttribute(element, name, value, assetPath) { + if (/^on/i.test(name)) { + fail(`${assetPath}: event handler attribute ${name} is forbidden`); + } + if (name.includes(":")) { + fail(`${assetPath}: namespaced attribute ${name} is forbidden`); + } + if (!allowedAttributes.has(name)) { + fail(`${assetPath}: attribute ${name} on ${element} is not allowed`); + } + if (name === "xmlns") { + if (element !== "svg" || value !== "http://www.w3.org/2000/svg") { + fail(`${assetPath}: only the canonical SVG namespace is allowed`); + } + return; + } + if (/url\s*\(|javascript:|data:|https?:\/\/|\/\//i.test(value)) { + fail(`${assetPath}: external or executable reference in ${name} is forbidden`); + } +} + +export function validateSvgText(svg, assetPath, expectedViewBox) { + if (/ { + if (!allowedElements.has(tag.name)) { + fail(`${assetPath}: element ${tag.name} is not allowed`); + } + if (elementStack.length === 0) { + rootCount += 1; + if (tag.name !== "svg") { + fail(`${assetPath}: root element must be svg`); + } + rootNamespace = tag.attributes.xmlns; + rootViewBox = tag.attributes.viewBox; + } else if (tag.name === "svg") { + fail(`${assetPath}: nested svg elements are forbidden`); + } + for (const [name, value] of Object.entries(tag.attributes)) { + validateAttribute(tag.name, name, value, assetPath); + } + elementStack.push(tag.name); + }); + parser.on("closetag", () => { + elementStack.pop(); + }); + parser.on("text", (text) => { + const parent = elementStack.at(-1); + if (text.trim() !== "" && parent !== "title" && parent !== "desc") { + fail(`${assetPath}: visible text outside title or desc is forbidden`); + } + }); + parser.on("cdata", () => { + fail(`${assetPath}: CDATA is forbidden`); + }); + parser.on("error", (error) => { + parseError = error; + }); + + try { + parser.write(svg).close(); + } catch (error) { + fail(`${assetPath}: invalid or unsafe SVG: ${error.message}`); + } + if (parseError) { + fail(`${assetPath}: invalid SVG: ${parseError.message}`); + } + if (rootCount !== 1 || elementStack.length !== 0) { + fail(`${assetPath}: SVG must contain exactly one complete root element`); + } + if (rootNamespace !== "http://www.w3.org/2000/svg") { + fail(`${assetPath}: root must declare the canonical SVG namespace`); + } + + const actualViewBox = rootViewBox + ?.trim() + .split(/[ ,]+/) + .map((value) => Number(value)); + if ( + !actualViewBox || + actualViewBox.length !== 4 || + actualViewBox.some((value) => !Number.isFinite(value)) || + actualViewBox.some((value, index) => value !== expectedViewBox[index]) + ) { + fail(`${assetPath}: root viewBox does not match catalog metadata`); + } +} + +export async function validateCatalog( + catalog, + { root = repositoryRoot, validateAssets = true } = {}, +) { + const schema = await readJson(path.join(repositoryRoot, "schemas/catalog.schema.json")); + const ajv = new Ajv2020({ allErrors: true, strict: true }); + const validate = ajv.compile(schema); + if (!validate(catalog)) { + const details = validate.errors + .map((error) => `${error.instancePath || "/"} ${error.message}`) + .join("; "); + fail(`catalog schema validation failed: ${details}`); + } + + const metricIds = new Set(); + for (const metrics of catalog.fontMetrics) { + if (metricIds.has(metrics.id)) { + fail(`duplicate font metrics id: ${metrics.id}`); + } + metricIds.add(metrics.id); + if (Object.values(metrics.provenance.redistribution).some((permitted) => !permitted)) { + fail(`font metrics ${metrics.id} is not redistributable in every supported artifact`); + } + if ( + metrics.provenance.licenseFile !== "LICENSE" && + !metrics.provenance.licenseFile.startsWith("licenses/") + ) { + fail(`font metrics ${metrics.id} license file must be LICENSE or under licenses/`); + } + if (validateAssets) { + const licenseFile = resolveRepositoryPath( + root, + metrics.provenance.licenseFile, + "license file path", + ); + await requireFile(licenseFile, "license file"); + } + } + + const themeIds = new Set(); + const reservedIds = new Set(catalog.reservedThemeIds); + const iconSubjects = new Map(); + const assetPaths = new Set(); + + for (const theme of catalog.themes) { + if (themeIds.has(theme.id)) { + fail(`duplicate theme id: ${theme.id}`); + } + if (reservedIds.has(theme.id)) { + fail(`active theme id is reserved: ${theme.id}`); + } + if (!metricIds.has(theme.typography.fontMetricsId)) { + fail( + `theme ${theme.id} references unknown font metrics ${theme.typography.fontMetricsId}`, + ); + } + themeIds.add(theme.id); + + const icons = new Map(); + for (const icon of theme.icons) { + if (icons.has(icon.id)) { + fail(`theme ${theme.id} has duplicate icon id: ${icon.id}`); + } + const existingSubject = iconSubjects.get(icon.id); + if (existingSubject !== undefined && existingSubject !== icon.subject) { + fail(`icon ${icon.id} has inconsistent subjects across themes`); + } + iconSubjects.set(icon.id, icon.subject); + icons.set(icon.id, icon); + + if (assetPaths.has(icon.asset.path)) { + fail(`icon asset path is used more than once: ${icon.asset.path}`); + } + assetPaths.add(icon.asset.path); + + const distribution = icon.asset.provenance.redistribution; + if (Object.values(distribution).some((permitted) => !permitted)) { + fail( + `icon ${theme.id}/${icon.id} is not redistributable in every supported artifact`, + ); + } + if ( + icon.asset.provenance.licenseFile !== "LICENSE" && + !icon.asset.provenance.licenseFile.startsWith("licenses/") + ) { + fail(`icon ${theme.id}/${icon.id} license file must be LICENSE or under licenses/`); + } + + if (validateAssets) { + const assetFile = resolveRepositoryPath(root, icon.asset.path, "icon asset path"); + const licenseFile = resolveRepositoryPath( + root, + icon.asset.provenance.licenseFile, + "license file path", + ); + await requireFile(assetFile, "icon asset"); + await requireFile(licenseFile, "license file"); + if (path.extname(assetFile).toLowerCase() !== ".svg") { + fail(`icon asset must be an SVG file: ${icon.asset.path}`); + } + validateSvgText( + await readFile(assetFile, "utf8"), + icon.asset.path, + icon.asset.viewBox, + ); + } + } + + for (const kind of nodeKinds) { + const fallbackId = theme.nodeKindFallbacks[kind].fallbackIconId; + if (!icons.has(fallbackId)) { + fail(`theme ${theme.id} ${kind} fallback references unknown icon ${fallbackId}`); + } + } + } + + return catalog; +} + +export async function catalogRevision(catalog, root = repositoryRoot) { + const hash = createHash("sha256"); + hash.update("stack-theme-catalog-v1\0"); + hash.update(formatJson(catalog)); + + const assetPaths = catalog.themes + .flatMap((theme) => theme.icons.map((icon) => icon.asset.path)) + .sort(); + for (const assetPath of assetPaths) { + hash.update("asset\0"); + hash.update(assetPath); + hash.update("\0"); + hash.update(await readFile(resolveRepositoryPath(root, assetPath, "icon asset path"))); + } + + return `sha256:${hash.digest("hex")}`; +} diff --git a/scripts/generate.mjs b/scripts/generate.mjs new file mode 100644 index 0000000..6597eec --- /dev/null +++ b/scripts/generate.mjs @@ -0,0 +1,126 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { + catalogRevision, + formatJson, + readJson, + repositoryRoot, + validateCatalog, +} from "./catalog-lib.mjs"; + +const checkOnly = process.argv.includes("--check"); +const catalogPath = path.join(repositoryRoot, "catalog/catalog.json"); +const catalog = await validateCatalog(await readJson(catalogPath)); +const revision = await catalogRevision(catalog); + +const rootPackage = await readJson(path.join(repositoryRoot, "package.json")); +const npmPackage = await readJson( + path.join(repositoryRoot, "packages/theme/package.json"), +); +const cargoManifest = await readFile(path.join(repositoryRoot, "Cargo.toml"), "utf8"); +const cargoVersion = cargoManifest.match( + /\[workspace\.package\][\s\S]*?\nversion = "([^"]+)"/, +)?.[1]; + +for (const [name, version] of [ + ["workspace npm package", rootPackage.version], + ["@stack-sh/theme", npmPackage.version], + ["stack-theme", cargoVersion], +]) { + if (version !== catalog.catalogVersion) { + throw new Error( + `${name} version ${version ?? ""} does not match catalog ${catalog.catalogVersion}`, + ); + } +} + +const catalogJson = formatJson(catalog); +const metadata = { + catalogVersion: catalog.catalogVersion, + catalogRevision: revision, +}; +const metadataJson = formatJson(metadata); +const licenseText = await readFile(path.join(repositoryRoot, "LICENSE"), "utf8"); +const schemaText = await readFile( + path.join(repositoryRoot, "schemas/catalog.schema.json"), + "utf8", +); +const assetEntries = []; +const licensePaths = new Set(["LICENSE"]); +for (const metrics of catalog.fontMetrics) { + licensePaths.add(metrics.provenance.licenseFile); +} +for (const theme of catalog.themes) { + for (const icon of theme.icons) { + if (!icon.asset.path.startsWith("assets/")) { + throw new Error( + `source catalog asset must be under assets/: ${icon.asset.path}`, + ); + } + assetEntries.push([ + icon.asset.path, + await readFile(path.join(repositoryRoot, icon.asset.path), "utf8"), + ]); + licensePaths.add(icon.asset.provenance.licenseFile); + } +} +assetEntries.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)); + +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 rustAssetArms = assetEntries + .map( + ([assetPath]) => + ` ${JSON.stringify(assetPath)} => Some(include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/${assetPath}"))),`, + ) + .join("\n"); +const rustAssetFunction = + rustAssetArms === "" + ? "pub fn icon_svg(_asset_path: &str) -> Option<&'static str> {\n None\n}" + : `pub fn icon_svg(asset_path: &str) -> Option<&'static str> {\n match asset_path {\n${rustAssetArms}\n _ => None,\n }\n}`; +const generatedRust = `// Generated by scripts/generate.mjs. Do not edit.\npub const CATALOG_VERSION: &str = ${JSON.stringify(catalog.catalogVersion)};\npub const CATALOG_REVISION: &str = ${JSON.stringify(revision)};\n\n${rustAssetFunction}\n`; + +const artifacts = new Map([ + ["crates/stack-theme/LICENSE", licenseText], + ["crates/stack-theme/schema/catalog.schema.json", schemaText], + ["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/catalog.json", catalogJson], + ["packages/theme/catalog-metadata.json", metadataJson], + ["packages/theme/catalog.generated.js", generatedJavaScript], +]); + +for (const [assetPath, svg] of assetEntries) { + artifacts.set(`crates/stack-theme/${assetPath}`, svg); + artifacts.set(`packages/theme/${assetPath}`, svg); +} +for (const licensePath of [...licensePaths].sort()) { + const contents = await readFile(path.join(repositoryRoot, licensePath), "utf8"); + artifacts.set(`crates/stack-theme/${licensePath}`, contents); + artifacts.set(`packages/theme/${licensePath}`, contents); +} + +for (const [relativePath, contents] of artifacts) { + const outputPath = path.join(repositoryRoot, relativePath); + if (checkOnly) { + let actual; + try { + actual = await readFile(outputPath, "utf8"); + } catch { + throw new Error(`${relativePath} is missing; run npm run generate`); + } + if (actual !== contents) { + throw new Error(`${relativePath} is stale; run npm run generate`); + } + } else { + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, contents); + } +} + +console.log( + `${checkOnly ? "verified" : "generated"} catalog ${catalog.catalogVersion} (${revision})`, +); diff --git a/scripts/validate.mjs b/scripts/validate.mjs new file mode 100644 index 0000000..fbd4d8d --- /dev/null +++ b/scripts/validate.mjs @@ -0,0 +1,14 @@ +import path from "node:path"; + +import { readJson, repositoryRoot, validateCatalog } from "./catalog-lib.mjs"; + +const catalogPaths = [ + "catalog/catalog.json", + "tests/fixtures/catalog/valid.json", +]; + +for (const relativePath of catalogPaths) { + const catalog = await readJson(path.join(repositoryRoot, relativePath)); + await validateCatalog(catalog); + console.log(`validated ${relativePath}`); +} diff --git a/tests/catalog.test.mjs b/tests/catalog.test.mjs new file mode 100644 index 0000000..f4231c6 --- /dev/null +++ b/tests/catalog.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; + +import { + readJson, + repositoryRoot, + validateCatalog, + validateSvgText, +} from "../scripts/catalog-lib.mjs"; +import { + catalog, + catalogRevision, + catalogVersion, + iconAssets, + iconSvg, +} from "../packages/theme/index.js"; + +test("the complete contract fixture is valid", async () => { + const fixture = await readJson( + path.join(repositoryRoot, "tests/fixtures/catalog/valid.json"), + ); + await validateCatalog(fixture); +}); + +for (const [name, pattern] of [ + ["unsafe-script.svg", /element script is not allowed/], + ["unsafe-event.svg", /event handler attribute onload is forbidden/], + ["unsafe-external-reference.svg", /element image is not allowed/], + ["unsafe-url.svg", /external or executable reference in fill is forbidden/], +]) { + test(`unsafe SVG fixture ${name} is rejected`, async () => { + const svg = await readFile( + path.join(repositoryRoot, "tests/fixtures/assets", name), + "utf8", + ); + assert.throws(() => validateSvgText(svg, name, [0, 0, 24, 24]), pattern); + }); +} + +test("unknown fallback icons are rejected", async () => { + const fixture = await readJson( + path.join(repositoryRoot, "tests/fixtures/catalog/valid.json"), + ); + fixture.themes[0].nodeKindFallbacks.actor.fallbackIconId = "missing"; + await assert.rejects( + validateCatalog(fixture), + /actor fallback references unknown icon missing/, + ); +}); + +test("Cargo and npm artifacts expose one semantic catalog revision", async () => { + const sourceCatalog = await readJson( + path.join(repositoryRoot, "catalog/catalog.json"), + ); + const cargoCatalog = await readJson( + path.join( + repositoryRoot, + "crates/stack-theme/src/generated/catalog.json", + ), + ); + const npmCatalog = await readJson( + path.join(repositoryRoot, "packages/theme/catalog.json"), + ); + const sourceSchema = await readJson( + path.join(repositoryRoot, "schemas/catalog.schema.json"), + ); + const cargoSchema = await readJson( + path.join(repositoryRoot, "crates/stack-theme/schema/catalog.schema.json"), + ); + const npmSchema = await readJson( + path.join(repositoryRoot, "packages/theme/schema/catalog.schema.json"), + ); + const metadata = await readJson( + path.join(repositoryRoot, "packages/theme/catalog-metadata.json"), + ); + const rustMetadata = await readFile( + path.join( + repositoryRoot, + "crates/stack-theme/src/generated/metadata.rs", + ), + "utf8", + ); + + assert.deepEqual(cargoCatalog, sourceCatalog); + assert.deepEqual(cargoCatalog, npmCatalog); + assert.deepEqual(cargoSchema, sourceSchema); + assert.deepEqual(cargoSchema, npmSchema); + assert.deepEqual(catalog, npmCatalog); + assert.equal(catalogVersion, npmCatalog.catalogVersion); + assert.equal(metadata.catalogVersion, catalogVersion); + assert.equal(metadata.catalogRevision, catalogRevision); + assert.match( + rustMetadata, + new RegExp(`CATALOG_VERSION: &str = "${catalogVersion}"`), + ); + assert.match( + rustMetadata, + new RegExp(`CATALOG_REVISION: &str = "${catalogRevision}"`), + ); + assert.match(catalogRevision, /^sha256:[0-9a-f]{64}$/); + assert.deepEqual(iconAssets, {}); + assert.equal(iconSvg("assets/missing.svg"), undefined); +}); diff --git a/tests/fixtures/assets/safe.svg b/tests/fixtures/assets/safe.svg new file mode 100644 index 0000000..e557d04 --- /dev/null +++ b/tests/fixtures/assets/safe.svg @@ -0,0 +1,3 @@ + + + diff --git a/tests/fixtures/assets/unsafe-event.svg b/tests/fixtures/assets/unsafe-event.svg new file mode 100644 index 0000000..0e7f63d --- /dev/null +++ b/tests/fixtures/assets/unsafe-event.svg @@ -0,0 +1,3 @@ + + + diff --git a/tests/fixtures/assets/unsafe-external-reference.svg b/tests/fixtures/assets/unsafe-external-reference.svg new file mode 100644 index 0000000..9363e99 --- /dev/null +++ b/tests/fixtures/assets/unsafe-external-reference.svg @@ -0,0 +1,3 @@ + + + diff --git a/tests/fixtures/assets/unsafe-script.svg b/tests/fixtures/assets/unsafe-script.svg new file mode 100644 index 0000000..0e366cf --- /dev/null +++ b/tests/fixtures/assets/unsafe-script.svg @@ -0,0 +1,3 @@ + + + diff --git a/tests/fixtures/assets/unsafe-url.svg b/tests/fixtures/assets/unsafe-url.svg new file mode 100644 index 0000000..e052f9a --- /dev/null +++ b/tests/fixtures/assets/unsafe-url.svg @@ -0,0 +1,3 @@ + + + diff --git a/tests/fixtures/catalog/valid.json b/tests/fixtures/catalog/valid.json new file mode 100644 index 0000000..7bdc439 --- /dev/null +++ b/tests/fixtures/catalog/valid.json @@ -0,0 +1,199 @@ +{ + "$schema": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/catalog.schema.json", + "schemaVersion": "1.0", + "catalogVersion": "0.0.0", + "reservedThemeIds": [ + "retired" + ], + "fontMetrics": [ + { + "id": "fixture_font", + "family": "Fixture Sans", + "version": "1", + "unitsPerEm": 1000, + "ascent": 800, + "descent": -200, + "lineGap": 100, + "defaultAdvance": 600, + "glyphAdvances": { + "U+0020": 250, + "U+0041": 650 + }, + "provenance": { + "sourceUrl": "https://github.com/stack-sh/theme", + "sourceRevision": "repository-authored fixture", + "copyright": "Stack contributors", + "licenseSpdx": "Apache-2.0", + "licenseFile": "LICENSE", + "modified": false, + "redistribution": { + "cargo": true, + "npm": true, + "wasm": true, + "commercialApplications": true + } + } + } + ], + "themes": [ + { + "id": "fixture", + "name": "Fixture", + "description": "A complete catalog contract fixture.", + "palette": { + "canvas": "#FFFFFF", + "surface": "#F8FAFC", + "surfaceMuted": "#E2E8F0", + "text": "#0F172A", + "textMuted": "#475569", + "border": "#94A3B8", + "accent": "#2563EB", + "danger": "#DC2626", + "connector": "#334155" + }, + "typography": { + "fontMetricsId": "fixture_font", + "nodeLabelSizeMilliPx": 14000, + "nodeDetailSizeMilliPx": 12000, + "groupLabelSizeMilliPx": 13000, + "edgeLabelSizeMilliPx": 11000, + "lineHeightPermille": 1400, + "labelWeight": 600, + "detailWeight": 400 + }, + "nodeKindFallbacks": { + "actor": { + "shape": "circle", + "fill": "surface", + "stroke": "border", + "text": "text", + "accent": "accent", + "cornerRadiusMilliPx": 0, + "fallbackIconId": "generic" + }, + "client": { + "shape": "rounded-rectangle", + "fill": "surface", + "stroke": "border", + "text": "text", + "accent": "accent", + "cornerRadiusMilliPx": 8000, + "fallbackIconId": "generic" + }, + "service": { + "shape": "rounded-rectangle", + "fill": "surface", + "stroke": "border", + "text": "text", + "accent": "accent", + "cornerRadiusMilliPx": 8000, + "fallbackIconId": "generic" + }, + "function": { + "shape": "hexagon", + "fill": "surface", + "stroke": "border", + "text": "text", + "accent": "accent", + "cornerRadiusMilliPx": 0, + "fallbackIconId": "generic" + }, + "worker": { + "shape": "capsule", + "fill": "surface", + "stroke": "border", + "text": "text", + "accent": "accent", + "cornerRadiusMilliPx": 16000, + "fallbackIconId": "generic" + }, + "database": { + "shape": "cylinder", + "fill": "surface", + "stroke": "border", + "text": "text", + "accent": "accent", + "cornerRadiusMilliPx": 0, + "fallbackIconId": "generic" + }, + "cache": { + "shape": "cylinder", + "fill": "surfaceMuted", + "stroke": "border", + "text": "text", + "accent": "accent", + "cornerRadiusMilliPx": 0, + "fallbackIconId": "generic" + }, + "queue": { + "shape": "capsule", + "fill": "surfaceMuted", + "stroke": "border", + "text": "text", + "accent": "accent", + "cornerRadiusMilliPx": 12000, + "fallbackIconId": "generic" + }, + "storage": { + "shape": "cylinder", + "fill": "surface", + "stroke": "border", + "text": "text", + "accent": "accent", + "cornerRadiusMilliPx": 0, + "fallbackIconId": "generic" + }, + "external": { + "shape": "rounded-rectangle", + "fill": "surfaceMuted", + "stroke": "border", + "text": "text", + "accent": "accent", + "cornerRadiusMilliPx": 4000, + "fallbackIconId": "generic" + } + }, + "connector": { + "stroke": "connector", + "text": "textMuted", + "labelBackground": "canvas", + "widthMilliPx": 1500, + "arrowSizeMilliPx": 7000, + "dashMilliPx": [ + 4000, + 3000 + ] + }, + "icons": [ + { + "id": "generic", + "subject": "Generic architectural component", + "description": "A repository-authored conformance fixture.", + "asset": { + "path": "tests/fixtures/assets/safe.svg", + "viewBox": [ + 0, + 0, + 24, + 24 + ], + "provenance": { + "sourceUrl": "https://github.com/stack-sh/theme", + "sourceRevision": "repository-authored fixture", + "copyright": "Stack contributors", + "licenseSpdx": "Apache-2.0", + "licenseFile": "LICENSE", + "modified": false, + "redistribution": { + "cargo": true, + "npm": true, + "wasm": true, + "commercialApplications": true + } + } + } + } + ] + } + ] +} diff --git a/tests/types.test.ts b/tests/types.test.ts new file mode 100644 index 0000000..326630c --- /dev/null +++ b/tests/types.test.ts @@ -0,0 +1,22 @@ +import { + catalog, + catalogRevision, + catalogVersion, + iconAssets, + iconSvg, + type Catalog, + type Theme, +} from "@stack-sh/theme"; + +const typedCatalog: Readonly = catalog; +const themes: readonly Theme[] = typedCatalog.themes; +const version: string = catalogVersion; +const revision: `sha256:${string}` = catalogRevision; +const assets: Readonly> = iconAssets; +const missingAsset: string | undefined = iconSvg("assets/missing.svg"); + +void themes; +void version; +void revision; +void assets; +void missingAsset; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e29c94c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "skipLibCheck": false, + "strict": true, + "target": "ES2022" + }, + "include": [ + "tests/types.test.ts" + ] +}