diff --git a/.github/workflows/distribution-catalog.yml b/.github/workflows/distribution-catalog.yml
index 8321e35..c7a3fc1 100644
--- a/.github/workflows/distribution-catalog.yml
+++ b/.github/workflows/distribution-catalog.yml
@@ -5,6 +5,8 @@ on:
paths:
- ".github/workflows/distribution-catalog.yml"
- "distribution/**"
+ - "auth_samples_*/.github/social-preview.*"
+ - "ai_saas_starter/.github/social-preview.png"
- "LICENSE"
- "README.md"
push:
@@ -13,6 +15,8 @@ on:
paths:
- ".github/workflows/distribution-catalog.yml"
- "distribution/**"
+ - "auth_samples_*/.github/social-preview.*"
+ - "ai_saas_starter/.github/social-preview.png"
- "LICENSE"
- "README.md"
@@ -47,5 +51,8 @@ jobs:
- name: Validate catalog
run: npm run validate
+ - name: Check social previews are reproducible
+ run: npm run previews && git diff --exit-code -- ../auth_samples_*/.github/social-preview.svg
+
- name: Audit validator dependencies
run: npm audit --audit-level=moderate
diff --git a/.github/workflows/sync-satellites.yml b/.github/workflows/sync-satellites.yml
index d47d386..3c21bb7 100644
--- a/.github/workflows/sync-satellites.yml
+++ b/.github/workflows/sync-satellites.yml
@@ -38,6 +38,9 @@ jobs:
- name: Install distribution tooling
working-directory: distribution
run: npm ci
+ - name: Validate catalog and managed assets before synchronization
+ working-directory: distribution
+ run: npm run validate
- name: Select affected templates
id: selection
working-directory: distribution
diff --git a/README.md b/README.md
index c2d2e9c..bdcbfc1 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,10 @@ The samples in this repository show practical Tuurio ID client integrations for
| Vue 3 | Vite + Composition API | [Vue guide](https://id.tuurio.com/public/developers/examples/vue) | [auth_samples_vue3](./auth_samples_vue3) |
| Angular | Standalone Components | [Angular guide](https://id.tuurio.com/public/developers/examples/angular) | [auth_samples_angular](./auth_samples_angular) |
| Next.js | App Router + Auth.js | [Next.js guide](https://id.tuurio.com/public/developers/examples/nextjs) | [auth_samples_nextjs](./auth_samples_nextjs) |
+| SvelteKit | SvelteKit + server hooks | [SvelteKit guide](https://id.tuurio.com/public/developers/examples/sveltekit) | [auth_samples_sveltekit](./auth_samples_sveltekit) |
+| Nuxt | Nuxt + server routes | [Nuxt guide](https://id.tuurio.com/public/developers/examples/nuxt) | [auth_samples_nuxt](./auth_samples_nuxt) |
+| Astro | Astro + server sessions | [Astro guide](https://id.tuurio.com/public/developers/examples/astro) | [auth_samples_astro](./auth_samples_astro) |
+| React Router | React Router framework mode | [React Router guide](https://id.tuurio.com/public/developers/examples/react-router) | [auth_samples_react_router](./auth_samples_react_router) |
### Mobile and Native
@@ -52,6 +56,8 @@ The samples in this repository show practical Tuurio ID client integrations for
| Java | Spring Boot 3 | [Spring Boot guide](https://id.tuurio.com/public/developers/examples/spring-boot) | [auth_samples_java](./auth_samples_java) |
| PHP | Vanilla PHP | [PHP guide](https://id.tuurio.com/public/developers/examples/php) | [auth_samples_php](./auth_samples_php) |
| Laravel | Laravel 12 | [Laravel guide](https://id.tuurio.com/public/developers/examples/laravel) | [auth_samples_laravel](./auth_samples_laravel) |
+| Python | Django | [Django guide](https://id.tuurio.com/public/developers/examples/django) | [auth_samples_django](./auth_samples_django) |
+| Python | FastAPI | [FastAPI guide](https://id.tuurio.com/public/developers/examples/fastapi) | [auth_samples_fastapi](./auth_samples_fastapi) |
## Getting Started
@@ -75,7 +81,7 @@ npm run dev
## Template distribution
-The reviewed catalog and target repository metadata live in [`distribution/templates.yml`](./distribution/templates.yml). It currently inventories 14 implemented samples and six planned additions. The distribution pipeline validates, packages, and synchronizes each source directory into a separately discoverable GitHub template repository without making the satellite an independent source of truth.
+The reviewed catalog and target repository metadata live in [`distribution/templates.yml`](./distribution/templates.yml). It inventories 20 implemented framework templates. The distribution pipeline validates, packages, and synchronizes each source directory into a separately discoverable GitHub template repository without making the satellite an independent source of truth.
Validate the catalog with:
diff --git a/auth_samples_android/.github/social-preview.png b/auth_samples_android/.github/social-preview.png
new file mode 100644
index 0000000..351d91b
Binary files /dev/null and b/auth_samples_android/.github/social-preview.png differ
diff --git a/auth_samples_android/.github/social-preview.svg b/auth_samples_android/.github/social-preview.svg
new file mode 100644
index 0000000..baf559f
--- /dev/null
+++ b/auth_samples_android/.github/social-preview.svg
@@ -0,0 +1,45 @@
+
diff --git a/auth_samples_angular/.github/social-preview.png b/auth_samples_angular/.github/social-preview.png
new file mode 100644
index 0000000..0be2738
Binary files /dev/null and b/auth_samples_angular/.github/social-preview.png differ
diff --git a/auth_samples_angular/.github/social-preview.svg b/auth_samples_angular/.github/social-preview.svg
new file mode 100644
index 0000000..645a790
--- /dev/null
+++ b/auth_samples_angular/.github/social-preview.svg
@@ -0,0 +1,45 @@
+
diff --git a/auth_samples_astro/.github/social-preview.png b/auth_samples_astro/.github/social-preview.png
new file mode 100644
index 0000000..9376dd2
Binary files /dev/null and b/auth_samples_astro/.github/social-preview.png differ
diff --git a/auth_samples_astro/.github/social-preview.svg b/auth_samples_astro/.github/social-preview.svg
new file mode 100644
index 0000000..ffab8ce
--- /dev/null
+++ b/auth_samples_astro/.github/social-preview.svg
@@ -0,0 +1,45 @@
+
diff --git a/auth_samples_django/.github/social-preview.png b/auth_samples_django/.github/social-preview.png
new file mode 100644
index 0000000..a5d6e41
Binary files /dev/null and b/auth_samples_django/.github/social-preview.png differ
diff --git a/auth_samples_django/.github/social-preview.svg b/auth_samples_django/.github/social-preview.svg
new file mode 100644
index 0000000..ad2b343
--- /dev/null
+++ b/auth_samples_django/.github/social-preview.svg
@@ -0,0 +1,45 @@
+
diff --git a/auth_samples_fastapi/.github/social-preview.png b/auth_samples_fastapi/.github/social-preview.png
new file mode 100644
index 0000000..9bfab4e
Binary files /dev/null and b/auth_samples_fastapi/.github/social-preview.png differ
diff --git a/auth_samples_fastapi/.github/social-preview.svg b/auth_samples_fastapi/.github/social-preview.svg
new file mode 100644
index 0000000..4fa24bf
--- /dev/null
+++ b/auth_samples_fastapi/.github/social-preview.svg
@@ -0,0 +1,45 @@
+
diff --git a/auth_samples_flutter/.github/social-preview.png b/auth_samples_flutter/.github/social-preview.png
new file mode 100644
index 0000000..5dc017f
Binary files /dev/null and b/auth_samples_flutter/.github/social-preview.png differ
diff --git a/auth_samples_flutter/.github/social-preview.svg b/auth_samples_flutter/.github/social-preview.svg
new file mode 100644
index 0000000..bdf5672
--- /dev/null
+++ b/auth_samples_flutter/.github/social-preview.svg
@@ -0,0 +1,45 @@
+
diff --git a/auth_samples_go/.github/social-preview.png b/auth_samples_go/.github/social-preview.png
new file mode 100644
index 0000000..80bccec
Binary files /dev/null and b/auth_samples_go/.github/social-preview.png differ
diff --git a/auth_samples_go/.github/social-preview.svg b/auth_samples_go/.github/social-preview.svg
new file mode 100644
index 0000000..3406508
--- /dev/null
+++ b/auth_samples_go/.github/social-preview.svg
@@ -0,0 +1,45 @@
+
diff --git a/auth_samples_ios/.github/social-preview.png b/auth_samples_ios/.github/social-preview.png
new file mode 100644
index 0000000..45a01d9
Binary files /dev/null and b/auth_samples_ios/.github/social-preview.png differ
diff --git a/auth_samples_ios/.github/social-preview.svg b/auth_samples_ios/.github/social-preview.svg
new file mode 100644
index 0000000..f411f42
--- /dev/null
+++ b/auth_samples_ios/.github/social-preview.svg
@@ -0,0 +1,45 @@
+
diff --git a/auth_samples_java/.github/social-preview.png b/auth_samples_java/.github/social-preview.png
new file mode 100644
index 0000000..cae3320
Binary files /dev/null and b/auth_samples_java/.github/social-preview.png differ
diff --git a/auth_samples_java/.github/social-preview.svg b/auth_samples_java/.github/social-preview.svg
new file mode 100644
index 0000000..f6e8b2e
--- /dev/null
+++ b/auth_samples_java/.github/social-preview.svg
@@ -0,0 +1,45 @@
+
diff --git a/auth_samples_laravel/.github/social-preview.png b/auth_samples_laravel/.github/social-preview.png
new file mode 100644
index 0000000..9ba4578
Binary files /dev/null and b/auth_samples_laravel/.github/social-preview.png differ
diff --git a/auth_samples_laravel/.github/social-preview.svg b/auth_samples_laravel/.github/social-preview.svg
new file mode 100644
index 0000000..1b6a92e
--- /dev/null
+++ b/auth_samples_laravel/.github/social-preview.svg
@@ -0,0 +1,45 @@
+
diff --git a/auth_samples_lovable/.github/social-preview.png b/auth_samples_lovable/.github/social-preview.png
new file mode 100644
index 0000000..a6ea345
Binary files /dev/null and b/auth_samples_lovable/.github/social-preview.png differ
diff --git a/auth_samples_lovable/.github/social-preview.svg b/auth_samples_lovable/.github/social-preview.svg
new file mode 100644
index 0000000..f29283d
--- /dev/null
+++ b/auth_samples_lovable/.github/social-preview.svg
@@ -0,0 +1,45 @@
+
diff --git a/auth_samples_nextjs/.github/social-preview.png b/auth_samples_nextjs/.github/social-preview.png
new file mode 100644
index 0000000..f8b35dc
Binary files /dev/null and b/auth_samples_nextjs/.github/social-preview.png differ
diff --git a/auth_samples_nextjs/.github/social-preview.svg b/auth_samples_nextjs/.github/social-preview.svg
new file mode 100644
index 0000000..d30c251
--- /dev/null
+++ b/auth_samples_nextjs/.github/social-preview.svg
@@ -0,0 +1,45 @@
+
diff --git a/auth_samples_node/.github/social-preview.png b/auth_samples_node/.github/social-preview.png
new file mode 100644
index 0000000..7a3f45e
Binary files /dev/null and b/auth_samples_node/.github/social-preview.png differ
diff --git a/auth_samples_node/.github/social-preview.svg b/auth_samples_node/.github/social-preview.svg
new file mode 100644
index 0000000..7513b69
--- /dev/null
+++ b/auth_samples_node/.github/social-preview.svg
@@ -0,0 +1,45 @@
+
diff --git a/auth_samples_nuxt/.github/social-preview.png b/auth_samples_nuxt/.github/social-preview.png
new file mode 100644
index 0000000..3333d4e
Binary files /dev/null and b/auth_samples_nuxt/.github/social-preview.png differ
diff --git a/auth_samples_nuxt/.github/social-preview.svg b/auth_samples_nuxt/.github/social-preview.svg
new file mode 100644
index 0000000..e685a9b
--- /dev/null
+++ b/auth_samples_nuxt/.github/social-preview.svg
@@ -0,0 +1,45 @@
+
diff --git a/auth_samples_php/.github/social-preview.png b/auth_samples_php/.github/social-preview.png
new file mode 100644
index 0000000..a0af550
Binary files /dev/null and b/auth_samples_php/.github/social-preview.png differ
diff --git a/auth_samples_php/.github/social-preview.svg b/auth_samples_php/.github/social-preview.svg
new file mode 100644
index 0000000..bd96531
--- /dev/null
+++ b/auth_samples_php/.github/social-preview.svg
@@ -0,0 +1,45 @@
+
diff --git a/auth_samples_python/.github/social-preview.png b/auth_samples_python/.github/social-preview.png
new file mode 100644
index 0000000..4a7486e
Binary files /dev/null and b/auth_samples_python/.github/social-preview.png differ
diff --git a/auth_samples_python/.github/social-preview.svg b/auth_samples_python/.github/social-preview.svg
new file mode 100644
index 0000000..98cc783
--- /dev/null
+++ b/auth_samples_python/.github/social-preview.svg
@@ -0,0 +1,45 @@
+
diff --git a/auth_samples_react/.github/social-preview.png b/auth_samples_react/.github/social-preview.png
new file mode 100644
index 0000000..f4d73c3
Binary files /dev/null and b/auth_samples_react/.github/social-preview.png differ
diff --git a/auth_samples_react/.github/social-preview.svg b/auth_samples_react/.github/social-preview.svg
new file mode 100644
index 0000000..d76c2b3
--- /dev/null
+++ b/auth_samples_react/.github/social-preview.svg
@@ -0,0 +1,45 @@
+
diff --git a/auth_samples_react_router/.github/social-preview.png b/auth_samples_react_router/.github/social-preview.png
new file mode 100644
index 0000000..baee049
Binary files /dev/null and b/auth_samples_react_router/.github/social-preview.png differ
diff --git a/auth_samples_react_router/.github/social-preview.svg b/auth_samples_react_router/.github/social-preview.svg
new file mode 100644
index 0000000..babd5d5
--- /dev/null
+++ b/auth_samples_react_router/.github/social-preview.svg
@@ -0,0 +1,45 @@
+
diff --git a/auth_samples_sveltekit/.github/social-preview.png b/auth_samples_sveltekit/.github/social-preview.png
new file mode 100644
index 0000000..6ee8ad6
Binary files /dev/null and b/auth_samples_sveltekit/.github/social-preview.png differ
diff --git a/auth_samples_sveltekit/.github/social-preview.svg b/auth_samples_sveltekit/.github/social-preview.svg
new file mode 100644
index 0000000..2f0b39b
--- /dev/null
+++ b/auth_samples_sveltekit/.github/social-preview.svg
@@ -0,0 +1,45 @@
+
diff --git a/auth_samples_vue3/.github/social-preview.png b/auth_samples_vue3/.github/social-preview.png
new file mode 100644
index 0000000..62b1b61
Binary files /dev/null and b/auth_samples_vue3/.github/social-preview.png differ
diff --git a/auth_samples_vue3/.github/social-preview.svg b/auth_samples_vue3/.github/social-preview.svg
new file mode 100644
index 0000000..c7e0034
--- /dev/null
+++ b/auth_samples_vue3/.github/social-preview.svg
@@ -0,0 +1,45 @@
+
diff --git a/distribution/README.md b/distribution/README.md
index db66aa1..6141927 100644
--- a/distribution/README.md
+++ b/distribution/README.md
@@ -18,6 +18,7 @@ From `distribution/`:
```bash
npm ci
+npm run previews
npm run validate
npm run affected -- --base --head
npm run sync -- --id react-vite
@@ -26,6 +27,8 @@ npm run verify:remotes -- --id react-vite
Dry-run is the default. Add `--apply` only after reviewing the package and target repository. A manual full-catalog synchronization omits `--id`; the GitHub workflow is preferred because it provides per-template isolation and summaries.
+`npm run previews` deterministically regenerates the 1280 × 640 PNG and SVG social-preview assets for all 20 framework templates with a pinned renderer and font files. Use `npm run previews -- --id react-vite` for a single catalog entry. The validator compares generated PNG pixels as well as SVG source and rejects missing, stale, oversized, transparent, or incorrectly sized assets before synchronization. The AI SaaS Starter retains separate curated artwork that is validated but never overwritten by this generator.
+
## Contribution policy
Implementation changes belong in this repository under the source path named in the satellite's `.tuurio-template.json`. Open pull requests against `Tuurio/auth_samples`, run the source sample's tests plus `npm run validate` in `distribution/`, and let the post-merge workflow propagate the reviewed result.
diff --git a/distribution/README.template.md b/distribution/README.template.md
index 0d862a5..4048e0d 100644
--- a/distribution/README.template.md
+++ b/distribution/README.template.md
@@ -4,6 +4,8 @@
[](https://github.com/{{repository}}/actions/workflows/verify.yml)
+
+
> Generated from [`Tuurio/auth_samples/{{source}}`](https://github.com/Tuurio/auth_samples/tree/main/{{source}}). Submit implementation fixes upstream so they are not replaced by the next synchronized release.
## What you get
diff --git a/distribution/package-lock.json b/distribution/package-lock.json
index d63aadf..02284fc 100644
--- a/distribution/package-lock.json
+++ b/distribution/package-lock.json
@@ -6,9 +6,866 @@
"": {
"name": "@tuurio/auth-sample-distribution",
"devDependencies": {
+ "@resvg/resvg-js": "2.6.2",
+ "dejavu-fonts-ttf": "2.37.3",
+ "sharp": "0.35.3",
"yaml": "2.9.0"
}
},
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
+ "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
+ "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
+ "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
+ "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
+ "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
+ "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
+ "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
+ "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
+ "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
+ "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
+ "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
+ "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
+ "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
+ "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
+ "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
+ "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
+ "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
+ "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
+ "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
+ "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
+ "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
+ "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.1"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
+ "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
+ "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
+ "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
+ "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@resvg/resvg-js": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
+ "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@resvg/resvg-js-android-arm-eabi": "2.6.2",
+ "@resvg/resvg-js-android-arm64": "2.6.2",
+ "@resvg/resvg-js-darwin-arm64": "2.6.2",
+ "@resvg/resvg-js-darwin-x64": "2.6.2",
+ "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2",
+ "@resvg/resvg-js-linux-arm64-gnu": "2.6.2",
+ "@resvg/resvg-js-linux-arm64-musl": "2.6.2",
+ "@resvg/resvg-js-linux-x64-gnu": "2.6.2",
+ "@resvg/resvg-js-linux-x64-musl": "2.6.2",
+ "@resvg/resvg-js-win32-arm64-msvc": "2.6.2",
+ "@resvg/resvg-js-win32-ia32-msvc": "2.6.2",
+ "@resvg/resvg-js-win32-x64-msvc": "2.6.2"
+ }
+ },
+ "node_modules/@resvg/resvg-js-android-arm-eabi": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz",
+ "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-android-arm64": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz",
+ "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-darwin-arm64": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz",
+ "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-darwin-x64": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz",
+ "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz",
+ "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-linux-arm64-gnu": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz",
+ "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-linux-arm64-musl": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz",
+ "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-linux-x64-gnu": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz",
+ "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-linux-x64-musl": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz",
+ "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-win32-arm64-msvc": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz",
+ "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-win32-ia32-msvc": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz",
+ "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-win32-x64-msvc": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz",
+ "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/dejavu-fonts-ttf": {
+ "version": "2.37.3",
+ "resolved": "https://registry.npmjs.org/dejavu-fonts-ttf/-/dejavu-fonts-ttf-2.37.3.tgz",
+ "integrity": "sha512-f1hd7jJbeQa1VWcw+K2KrTXS50zTMaHpVC4XIKJpNcDeYR5ajMtj/iLlQDYNvLOKamUB3ARVVCf79lNwNVztSQ==",
+ "dev": true,
+ "license": "SEE LICENSE IN README.md AND LICENSE"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
+ "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.35.3",
+ "@img/sharp-darwin-x64": "0.35.3",
+ "@img/sharp-freebsd-wasm32": "0.35.3",
+ "@img/sharp-libvips-darwin-arm64": "1.3.2",
+ "@img/sharp-libvips-darwin-x64": "1.3.2",
+ "@img/sharp-libvips-linux-arm": "1.3.2",
+ "@img/sharp-libvips-linux-arm64": "1.3.2",
+ "@img/sharp-libvips-linux-ppc64": "1.3.2",
+ "@img/sharp-libvips-linux-riscv64": "1.3.2",
+ "@img/sharp-libvips-linux-s390x": "1.3.2",
+ "@img/sharp-libvips-linux-x64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
+ "@img/sharp-linux-arm": "0.35.3",
+ "@img/sharp-linux-arm64": "0.35.3",
+ "@img/sharp-linux-ppc64": "0.35.3",
+ "@img/sharp-linux-riscv64": "0.35.3",
+ "@img/sharp-linux-s390x": "0.35.3",
+ "@img/sharp-linux-x64": "0.35.3",
+ "@img/sharp-linuxmusl-arm64": "0.35.3",
+ "@img/sharp-linuxmusl-x64": "0.35.3",
+ "@img/sharp-webcontainers-wasm32": "0.35.3",
+ "@img/sharp-win32-arm64": "0.35.3",
+ "@img/sharp-win32-ia32": "0.35.3",
+ "@img/sharp-win32-x64": "0.35.3"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
diff --git a/distribution/package.json b/distribution/package.json
index 221f755..7e164f8 100644
--- a/distribution/package.json
+++ b/distribution/package.json
@@ -4,14 +4,18 @@
"type": "module",
"scripts": {
"test": "node --test scripts/*.test.mjs",
- "validate": "npm test && node scripts/validate-manifest.mjs",
+ "validate": "npm test && node scripts/validate-manifest.mjs && node scripts/validate-social-previews.mjs",
"package": "node scripts/package-template.mjs",
+ "previews": "node scripts/generate-social-previews.mjs",
"affected": "node scripts/affected-templates.mjs",
"bootstrap": "node scripts/bootstrap-template-repos.mjs",
"sync": "node scripts/sync-template-repos.mjs",
"verify:remotes": "node scripts/verify-template-repos.mjs"
},
"devDependencies": {
+ "@resvg/resvg-js": "2.6.2",
+ "dejavu-fonts-ttf": "2.37.3",
+ "sharp": "0.35.3",
"yaml": "2.9.0"
}
}
diff --git a/distribution/scripts/generate-social-previews.mjs b/distribution/scripts/generate-social-previews.mjs
new file mode 100644
index 0000000..916613c
--- /dev/null
+++ b/distribution/scripts/generate-social-previews.mjs
@@ -0,0 +1,201 @@
+import { existsSync, mkdirSync, writeFileSync } from "node:fs";
+import { resolve } from "node:path";
+import process from "node:process";
+import { fileURLToPath } from "node:url";
+import { Resvg } from "@resvg/resvg-js";
+import sharp from "sharp";
+import { loadCatalog, parseCommonArgs, repositoryRoot, selectTemplates } from "./catalog.mjs";
+
+export const SOCIAL_PREVIEW_WIDTH = 1280;
+export const SOCIAL_PREVIEW_HEIGHT = 640;
+const socialPreviewRenderFontFamily = "DejaVu Sans";
+const socialPreviewSvgFontFamily = "DejaVu Sans, Arial, Helvetica, sans-serif";
+const socialPreviewFontFiles = [
+ resolve(import.meta.dirname, "../node_modules/dejavu-fonts-ttf/ttf/DejaVuSans.ttf"),
+ resolve(import.meta.dirname, "../node_modules/dejavu-fonts-ttf/ttf/DejaVuSans-Bold.ttf"),
+];
+const socialPreviewContentX = 72;
+const socialPreviewPanelX = 860;
+const socialPreviewTitleRightPadding = 48;
+const socialPreviewTitleFontSize = 58;
+const socialPreviewTitleFontWeight = 700;
+const socialPreviewTitleLetterSpacing = -1.5;
+const socialPreviewTitleMaximumWidth = socialPreviewPanelX - socialPreviewContentX - socialPreviewTitleRightPadding;
+
+for (const fontFile of socialPreviewFontFiles) {
+ if (!existsSync(fontFile)) {
+ throw new Error(`Social preview font file is missing: ${fontFile}. Run npm ci in distribution/.`);
+ }
+}
+
+const outcomeByKind = {
+ browser: "Secure browser login with OIDC + PKCE",
+ native: "Native sign-in with Authorization Code + PKCE",
+ server: "Server-side authentication with OpenID Connect",
+};
+
+const accentByKind = {
+ browser: "#4d94ff",
+ native: "#f2b84b",
+ server: "#6ee7b7",
+};
+
+function escapeXml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+function createResvg(svg) {
+ return new Resvg(svg, {
+ background: "#07111f",
+ font: {
+ fontFiles: socialPreviewFontFiles,
+ loadSystemFonts: false,
+ defaultFontFamily: socialPreviewRenderFontFamily,
+ },
+ });
+}
+
+function textWidth(value, { fontSize, fontWeight, letterSpacing }) {
+ const svg = ``;
+ return createResvg(svg).getBBox()?.width ?? Number.POSITIVE_INFINITY;
+}
+
+function titleWidth(value) {
+ return textWidth(value, {
+ fontSize: socialPreviewTitleFontSize,
+ fontWeight: socialPreviewTitleFontWeight,
+ letterSpacing: socialPreviewTitleLetterSpacing,
+ });
+}
+
+function titleLines(value, maximumWidth = socialPreviewTitleMaximumWidth) {
+ if (!/^[\x20-\x7E]+$/.test(value)) {
+ throw new Error(`${value}: displayName contains characters outside the reviewed social-preview font set`);
+ }
+ const words = value.split(/\s+/).filter(Boolean);
+ const lines = [];
+ let current = "";
+ for (const word of words) {
+ if (titleWidth(word) > maximumWidth) {
+ throw new Error(`${value}: displayName contains a word that does not fit the social preview`);
+ }
+ const candidate = current ? `${current} ${word}` : word;
+ if (current && titleWidth(candidate) > maximumWidth) {
+ lines.push(current);
+ current = word;
+ } else {
+ current = candidate;
+ }
+ }
+ if (current) lines.push(current);
+ if (lines.length > 2) {
+ throw new Error(`${value}: displayName does not fit two social-preview title lines`);
+ }
+ return lines;
+}
+
+export function renderSocialPreviewSvg(template) {
+ const accent = accentByKind[template.kind] ?? accentByKind.browser;
+ const outcome = outcomeByKind[template.kind] ?? outcomeByKind.browser;
+ if (!/^[a-z0-9.+-]{1,24}$/.test(template.framework)) {
+ throw new Error(`${template.framework}: framework must use 1-24 lowercase ASCII slug characters`);
+ }
+ const eyebrow = `TUURIO ID FOR ${template.framework.toUpperCase()}`;
+ if (
+ textWidth(eyebrow, { fontSize: 16, fontWeight: 700, letterSpacing: 3 }) > socialPreviewTitleMaximumWidth
+ ) {
+ throw new Error(`${template.framework}: framework eyebrow does not fit the social preview`);
+ }
+ const lines = titleLines(template.displayName).map(escapeXml);
+ const title = lines
+ .map(
+ (line, index) => `${line}`,
+ )
+ .join("");
+ const outcomeY = lines.length > 1 ? 390 : 330;
+
+ return ``;
+}
+
+export async function renderSocialPreviewPng(svg) {
+ const rendered = createResvg(svg).render().asPng();
+ return sharp(rendered)
+ .flatten({ background: "#07111f" })
+ .png({ compressionLevel: 9, palette: false })
+ .toBuffer();
+}
+
+export async function writeSocialPreview(template, { root = repositoryRoot } = {}) {
+ const targetDirectory = resolve(root, template.source, ".github");
+ const svgPath = resolve(targetDirectory, "social-preview.svg");
+ const pngPath = resolve(targetDirectory, "social-preview.png");
+ const svg = renderSocialPreviewSvg(template);
+ const png = await renderSocialPreviewPng(svg);
+ mkdirSync(targetDirectory, { recursive: true });
+ writeFileSync(svgPath, `${svg}\n`);
+ writeFileSync(pngPath, png);
+ return { id: template.id, svgPath, pngPath };
+}
+
+export async function generateSocialPreviews({ ids = [], root = repositoryRoot } = {}) {
+ const templates = selectTemplates(loadCatalog({ root }), ids);
+ return Promise.all(templates.map((template) => writeSocialPreview(template, { root })));
+}
+
+if (fileURLToPath(import.meta.url) === resolve(process.argv[1] ?? "")) {
+ const args = parseCommonArgs(process.argv.slice(2));
+ if (args.output || args.apply || args.initialize) {
+ throw new Error("Social previews support only optional repeated --id arguments");
+ }
+ const results = await generateSocialPreviews({ ids: args.ids });
+ console.log(JSON.stringify({ generated: results.length, templates: results.map(({ id }) => id) }));
+}
diff --git a/distribution/scripts/generate-social-previews.test.mjs b/distribution/scripts/generate-social-previews.test.mjs
new file mode 100644
index 0000000..81b54f1
--- /dev/null
+++ b/distribution/scripts/generate-social-previews.test.mjs
@@ -0,0 +1,81 @@
+import assert from "node:assert/strict";
+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { resolve } from "node:path";
+import test from "node:test";
+import sharp from "sharp";
+import {
+ generateSocialPreviews,
+ renderSocialPreviewPng,
+ renderSocialPreviewSvg,
+ SOCIAL_PREVIEW_HEIGHT,
+ SOCIAL_PREVIEW_WIDTH,
+ writeSocialPreview,
+} from "./generate-social-previews.mjs";
+
+const template = {
+ id: "react-vite",
+ displayName: "React Vite OIDC Starter",
+ framework: "react",
+ kind: "browser",
+ source: "sample",
+};
+
+test("renders a branded preview without transient or sensitive values", () => {
+ const svg = renderSocialPreviewSvg(template);
+ assert.match(svg, /Tuurio ID/);
+ assert.match(svg.replace(/<[^>]+>/g, ""), /React Vite OIDC Starter/);
+ assert.match(svg, /Secure browser login with OIDC \+ PKCE/);
+ assert.match(svg, /EU-hosted identity/);
+ assert.match(svg, /font-family="DejaVu Sans, Arial, Helvetica, sans-serif"/);
+ assert.doesNotMatch(svg, /tenantId|client_secret|redirect-uri|@1\./i);
+});
+
+test("escapes manifest text before rendering SVG", () => {
+ const svg = renderSocialPreviewSvg({ ...template, displayName: "React & " });
+ assert.match(svg, /React & <Vite>/);
+ assert.doesNotMatch(svg, /React & /);
+});
+
+test("rejects titles that cannot fit without truncation", () => {
+ assert.throws(
+ () => renderSocialPreviewSvg({ ...template, displayName: "One Two Three Four Five Six Seven Eight Nine" }),
+ /does not fit two social-preview title lines/,
+ );
+ assert.throws(
+ () => renderSocialPreviewSvg({ ...template, displayName: "ThisSingleWordCannotFitInTheAvailableSpace" }),
+ /contains a word that does not fit/,
+ );
+ assert.throws(
+ () => renderSocialPreviewSvg({ ...template, displayName: "WWWWWWWWWWWWWW" }),
+ /contains a word that does not fit/,
+ );
+ assert.throws(
+ () => renderSocialPreviewSvg({ ...template, displayName: "React 登录 Starter" }),
+ /outside the reviewed social-preview font set/,
+ );
+});
+
+test("never routes curated product artwork through the framework generator", async () => {
+ await assert.rejects(generateSocialPreviews({ ids: ["ai-saas"] }), /Unknown template id\(s\): ai-saas/);
+});
+
+test("renders the same PNG bytes on repeated invocations in one runtime", async () => {
+ const svg = renderSocialPreviewSvg(template);
+ const [first, second] = await Promise.all([renderSocialPreviewPng(svg), renderSocialPreviewPng(svg)]);
+ assert.ok(first.equals(second));
+});
+
+test("writes a 1280 by 640 RGB PNG and its SVG source", async () => {
+ const root = mkdtempSync(resolve(tmpdir(), "tuurio-social-preview-"));
+ try {
+ const result = await writeSocialPreview(template, { root });
+ const metadata = await sharp(result.pngPath).metadata();
+ assert.equal(metadata.width, SOCIAL_PREVIEW_WIDTH);
+ assert.equal(metadata.height, SOCIAL_PREVIEW_HEIGHT);
+ assert.equal(metadata.hasAlpha, false);
+ assert.match(readFileSync(result.svgPath, "utf8"), /STARTER TEMPLATE/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/distribution/scripts/validate-manifest.mjs b/distribution/scripts/validate-manifest.mjs
index c4d96ef..94c98b9 100644
--- a/distribution/scripts/validate-manifest.mjs
+++ b/distribution/scripts/validate-manifest.mjs
@@ -136,6 +136,9 @@ export const validateManifest = (manifest, { repositoryRoot = root } = {}) => {
errors.push(`${prefix}: ${field} is required when files are configured`);
}
}
+ if (typeof template.framework === "string" && !/^[a-z0-9.+-]{1,24}$/.test(template.framework)) {
+ errors.push(`${prefix}: framework must use 1-24 lowercase ASCII slug characters`);
+ }
}
if (template.status === "ready") {
try {
diff --git a/distribution/scripts/validate-manifest.test.mjs b/distribution/scripts/validate-manifest.test.mjs
index 6878fc8..fc5e914 100644
--- a/distribution/scripts/validate-manifest.test.mjs
+++ b/distribution/scripts/validate-manifest.test.mjs
@@ -91,3 +91,10 @@ test("requires packaging metadata with an allow-list", () => {
assert.ok(errors.some((error) => error.includes("files must contain")));
assert.ok(errors.some((error) => error.includes("framework is required")));
});
+
+test("rejects a framework label that cannot render safely", () => {
+ const manifest = copy();
+ manifest.templates[0].framework = "React framework with spaces 登录";
+ const { errors } = validateManifest(manifest, { repositoryRoot: root });
+ assert.ok(errors.some((error) => error.includes("framework must use 1-24 lowercase ASCII slug characters")));
+});
diff --git a/distribution/scripts/validate-social-previews.mjs b/distribution/scripts/validate-social-previews.mjs
new file mode 100644
index 0000000..7d1b46f
--- /dev/null
+++ b/distribution/scripts/validate-social-previews.mjs
@@ -0,0 +1,140 @@
+import { existsSync, readFileSync, statSync } from "node:fs";
+import { resolve } from "node:path";
+import process from "node:process";
+import { fileURLToPath } from "node:url";
+import sharp from "sharp";
+import { loadCatalog, repositoryRoot } from "./catalog.mjs";
+import {
+ renderSocialPreviewPng,
+ renderSocialPreviewSvg,
+ SOCIAL_PREVIEW_HEIGHT,
+ SOCIAL_PREVIEW_WIDTH,
+} from "./generate-social-previews.mjs";
+
+const previewPaths = [".github/social-preview.png", ".github/social-preview.svg"];
+const ignoredChannelDelta = 2;
+const maximumChannelDelta = 16;
+const maximumMaterialPixelRatio = 0.001;
+
+function pixelsMatchWithinRasterTolerance(expected, actual) {
+ if (
+ expected.info.width !== actual.info.width ||
+ expected.info.height !== actual.info.height ||
+ expected.info.channels !== actual.info.channels ||
+ expected.data.length !== actual.data.length
+ ) {
+ return false;
+ }
+
+ let largestChannelDelta = 0;
+ let materiallyDifferentPixels = 0;
+ for (let offset = 0; offset < expected.data.length; offset += expected.info.channels) {
+ let largestPixelDelta = 0;
+ for (let channel = 0; channel < expected.info.channels; channel += 1) {
+ const delta = Math.abs(expected.data[offset + channel] - actual.data[offset + channel]);
+ largestChannelDelta = Math.max(largestChannelDelta, delta);
+ largestPixelDelta = Math.max(largestPixelDelta, delta);
+ }
+ if (largestPixelDelta > ignoredChannelDelta) materiallyDifferentPixels += 1;
+ }
+
+ const totalPixels = expected.info.width * expected.info.height;
+ return (
+ largestChannelDelta <= maximumChannelDelta &&
+ materiallyDifferentPixels / totalPixels <= maximumMaterialPixelRatio
+ );
+}
+
+async function validatePng(template, pngPath, errors) {
+ const prefix = template.id;
+ if (!existsSync(pngPath)) {
+ errors.push(`${prefix}: missing ${previewPaths[0]}`);
+ return null;
+ }
+
+ try {
+ const metadata = await sharp(pngPath).metadata();
+ if (metadata.width !== SOCIAL_PREVIEW_WIDTH || metadata.height !== SOCIAL_PREVIEW_HEIGHT) {
+ errors.push(`${prefix}: PNG must be ${SOCIAL_PREVIEW_WIDTH}x${SOCIAL_PREVIEW_HEIGHT}`);
+ }
+ if (metadata.hasAlpha) errors.push(`${prefix}: PNG must not have an alpha channel`);
+ if (statSync(pngPath).size > 1_000_000) errors.push(`${prefix}: PNG exceeds 1 MB`);
+ return metadata;
+ } catch (error) {
+ errors.push(`${prefix}: PNG cannot be decoded (${error.message})`);
+ return null;
+ }
+}
+
+export async function validateGeneratedSocialPreview(template, { root = repositoryRoot } = {}) {
+ const errors = [];
+ const prefix = template.id;
+ for (const relativePath of previewPaths) {
+ if (!template.files?.includes(relativePath)) {
+ errors.push(`${prefix}: ${relativePath} is not in the managed file allow-list`);
+ }
+ }
+
+ const svgPath = resolve(root, template.source, previewPaths[1]);
+ const pngPath = resolve(root, template.source, previewPaths[0]);
+ const expectedSvgSource = renderSocialPreviewSvg(template);
+ const expectedSvg = `${expectedSvgSource}\n`;
+ if (!existsSync(svgPath)) {
+ errors.push(`${prefix}: missing ${previewPaths[1]}`);
+ } else if (readFileSync(svgPath, "utf8") !== expectedSvg) {
+ errors.push(`${prefix}: SVG is stale; run npm run previews`);
+ }
+
+ if (!(await validatePng(template, pngPath, errors))) return errors;
+
+ const expectedPng = await renderSocialPreviewPng(expectedSvgSource);
+ const [expectedPixels, actualPixels] = await Promise.all([
+ sharp(expectedPng).removeAlpha().toColourspace("srgb").raw().toBuffer({ resolveWithObject: true }),
+ sharp(pngPath).removeAlpha().toColourspace("srgb").raw().toBuffer({ resolveWithObject: true }),
+ ]);
+ if (!pixelsMatchWithinRasterTolerance(expectedPixels, actualPixels)) {
+ errors.push(`${prefix}: PNG is stale; run npm run previews`);
+ }
+ return errors;
+}
+
+export async function validateCuratedSocialPreview(entry, { root = repositoryRoot } = {}) {
+ const errors = [];
+ if (!entry.files?.includes(previewPaths[0])) {
+ errors.push(`${entry.id}: ${previewPaths[0]} is not in the managed file allow-list`);
+ }
+ await validatePng(entry, resolve(root, entry.source, previewPaths[0]), errors);
+ return errors;
+}
+
+export async function validateSocialPreviews({ root = repositoryRoot } = {}) {
+ const errors = [];
+ const manifest = loadCatalog({ root });
+ let generatedChecked = 0;
+ let curatedChecked = 0;
+
+ for (const template of manifest.templates) {
+ generatedChecked += 1;
+ errors.push(...(await validateGeneratedSocialPreview(template, { root })));
+ }
+
+ // Product artwork is deliberately bespoke. Validate its packaging constraints,
+ // but never overwrite it with the framework-template generator.
+ for (const product of manifest.products ?? []) {
+ curatedChecked += 1;
+ errors.push(...(await validateCuratedSocialPreview(product, { root })));
+ }
+
+ return { checked: generatedChecked + curatedChecked, generatedChecked, curatedChecked, errors };
+}
+
+if (fileURLToPath(import.meta.url) === resolve(process.argv[1] ?? "")) {
+ const result = await validateSocialPreviews();
+ if (result.errors.length) {
+ console.error(result.errors.map((error) => `- ${error}`).join("\n"));
+ process.exit(1);
+ }
+ console.log(
+ `Validated ${result.generatedChecked} generated social previews and ${result.curatedChecked} curated product preview.`,
+ );
+}
diff --git a/distribution/scripts/validate-social-previews.test.mjs b/distribution/scripts/validate-social-previews.test.mjs
new file mode 100644
index 0000000..ef705d3
--- /dev/null
+++ b/distribution/scripts/validate-social-previews.test.mjs
@@ -0,0 +1,99 @@
+import assert from "node:assert/strict";
+import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { resolve } from "node:path";
+import test from "node:test";
+import sharp from "sharp";
+import { distributables, loadCatalog } from "./catalog.mjs";
+import { writeSocialPreview } from "./generate-social-previews.mjs";
+import {
+ validateCuratedSocialPreview,
+ validateGeneratedSocialPreview,
+ validateSocialPreviews,
+} from "./validate-social-previews.mjs";
+
+const template = {
+ id: "test-react",
+ displayName: "Test React Starter",
+ framework: "react",
+ kind: "browser",
+ source: "sample",
+ files: [".github/social-preview.png", ".github/social-preview.svg"],
+};
+
+test("validates every checked-in framework social preview", async () => {
+ const manifest = loadCatalog();
+ const result = await validateSocialPreviews();
+ assert.equal(result.checked, distributables(manifest).length);
+ assert.equal(result.generatedChecked, manifest.templates.length);
+ assert.equal(result.curatedChecked, manifest.products.length);
+ assert.deepEqual(result.errors, []);
+});
+
+test("reports missing files and unmanaged preview paths", async () => {
+ const root = mkdtempSync(resolve(tmpdir(), "tuurio-preview-validation-"));
+ try {
+ const errors = await validateGeneratedSocialPreview({ ...template, files: [] }, { root });
+ assert.ok(errors.some((error) => error.includes("social-preview.png is not in the managed file allow-list")));
+ assert.ok(errors.some((error) => error.includes("social-preview.svg is not in the managed file allow-list")));
+ assert.ok(errors.some((error) => error.includes("missing .github/social-preview.svg")));
+ assert.ok(errors.some((error) => error.includes("missing .github/social-preview.png")));
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("detects stale SVG and PNG content", async () => {
+ const root = mkdtempSync(resolve(tmpdir(), "tuurio-preview-validation-"));
+ try {
+ const { svgPath, pngPath } = await writeSocialPreview(template, { root });
+ writeFileSync(svgPath, "