@koehler8/cms 1.0.0-beta.8 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +1491 -0
  2. package/README.md +108 -28
  3. package/bin/cms-create-extension.js +7 -0
  4. package/bin/cms-create-site.js +7 -0
  5. package/bin/cms-create-theme.js +7 -0
  6. package/bin/cms-ssg-build.js +13 -0
  7. package/bin/cms-validate-extensions.js +4 -0
  8. package/bin/cms-validate-themes.js +11 -0
  9. package/extensions/manifest.schema.json +1 -1
  10. package/package.json +36 -16
  11. package/scripts/create-extension.mjs +11 -0
  12. package/scripts/create-site.mjs +11 -0
  13. package/scripts/create-theme.mjs +11 -0
  14. package/scripts/generate-public-assets.js +70 -36
  15. package/scripts/image-variants/cache.js +40 -0
  16. package/scripts/image-variants/classifier.js +53 -0
  17. package/scripts/image-variants/config.js +54 -0
  18. package/scripts/image-variants/index.js +18 -0
  19. package/scripts/image-variants/manifest.js +76 -0
  20. package/scripts/image-variants/planner.js +95 -0
  21. package/scripts/image-variants/reconcile.js +194 -0
  22. package/scripts/image-variants/renderer.js +51 -0
  23. package/scripts/lib/extension-manifests.mjs +70 -0
  24. package/scripts/scaffold.mjs +242 -0
  25. package/scripts/ssg-build/findEmptyRenders.mjs +42 -0
  26. package/scripts/ssg-build/index.mjs +253 -0
  27. package/scripts/validate-extensions.mjs +50 -382
  28. package/scripts/validate-themes.mjs +37 -5
  29. package/src/components/About.vue +4 -2
  30. package/src/components/AboutValue.vue +5 -4
  31. package/src/components/ComingSoon.vue +12 -1
  32. package/src/components/ComingSoonModal.vue +33 -61
  33. package/src/components/Contact.vue +216 -120
  34. package/src/components/DraftGate.vue +211 -0
  35. package/src/components/Footer.vue +15 -2
  36. package/src/components/FooterMinimal.vue +13 -12
  37. package/src/components/Header.vue +184 -10
  38. package/src/components/Hero.vue +17 -8
  39. package/src/components/Home.vue +81 -48
  40. package/src/components/Intro.vue +4 -2
  41. package/src/components/IntroGate.vue +30 -73
  42. package/src/components/NotFound.vue +100 -0
  43. package/src/components/Plan.vue +10 -4
  44. package/src/components/Portfolio.vue +29 -3
  45. package/src/components/Principles.vue +3 -1
  46. package/src/components/Spacer.vue +23 -0
  47. package/src/components/Spacer15.vue +3 -4
  48. package/src/components/Spacer30.vue +3 -4
  49. package/src/components/Spacer40.vue +3 -4
  50. package/src/components/Spacer60.vue +3 -4
  51. package/src/components/StickyCTA.vue +23 -1
  52. package/src/components/Team.vue +3 -1
  53. package/src/composables/useComponentResolver.js +49 -1
  54. package/src/composables/useDraftGate.js +93 -0
  55. package/src/composables/useFocusTrap.js +85 -0
  56. package/src/composables/usePageConfig.js +126 -28
  57. package/src/composables/usePageMeta.js +178 -5
  58. package/src/composables/usePromoBackgroundStyles.js +30 -14
  59. package/src/extensions/extensionLoader.js +34 -24
  60. package/src/main.js +69 -6
  61. package/src/router/index.js +98 -37
  62. package/src/styles/base.css +11 -2
  63. package/src/themes/buildCssVarMap.js +34 -0
  64. package/src/utils/appInfo.js +8 -5
  65. package/src/utils/breadcrumbs.js +121 -0
  66. package/src/utils/canonicalUrl.js +53 -0
  67. package/src/utils/componentRegistry.js +98 -16
  68. package/src/utils/cookieConsent.js +38 -24
  69. package/src/utils/draftMode.js +65 -0
  70. package/src/utils/imageSources.js +9 -1
  71. package/src/utils/jsonLd.js +66 -0
  72. package/src/utils/loadConfig.js +106 -6
  73. package/src/utils/robotsGenerator.js +95 -0
  74. package/src/utils/sha256.js +20 -0
  75. package/src/utils/sitemapGenerator.js +112 -0
  76. package/src/utils/socialMeta.js +100 -0
  77. package/src/utils/ssgRoutes.js +40 -0
  78. package/src/utils/trackingContext.js +16 -22
  79. package/src/utils/webAppManifest.js +85 -0
  80. package/src/utils/webStorage.js +82 -0
  81. package/templates/index.html +17 -24
  82. package/templates/scaffolds/extension/README.md +53 -0
  83. package/templates/scaffolds/extension/extension.config.json +10 -0
  84. package/templates/scaffolds/extension/index.js +37 -0
  85. package/templates/scaffolds/extension/package.json +22 -0
  86. package/templates/scaffolds/site/.env.example +7 -0
  87. package/templates/scaffolds/site/.nvmrc +1 -0
  88. package/templates/scaffolds/site/CLAUDE.md +121 -0
  89. package/templates/scaffolds/site/README.md +25 -0
  90. package/templates/scaffolds/site/amplify.yml +22 -0
  91. package/templates/scaffolds/site/package.json +26 -0
  92. package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
  93. package/templates/scaffolds/site/site/components/.gitkeep +0 -0
  94. package/templates/scaffolds/site/site/content/content.config.json +3 -0
  95. package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
  96. package/templates/scaffolds/site/site/content/en/shared.json +5 -0
  97. package/templates/scaffolds/site/site/content/en/site.json +10 -0
  98. package/templates/scaffolds/site/site/style.css +4 -0
  99. package/templates/scaffolds/site/vite.config.js +15 -0
  100. package/templates/scaffolds/theme/README.md +50 -0
  101. package/templates/scaffolds/theme/index.js +8 -0
  102. package/templates/scaffolds/theme/package.json +21 -0
  103. package/templates/scaffolds/theme/theme.config.js +228 -0
  104. package/templates/scaffolds/theme/theme.css +14 -0
  105. package/themes/base/theme.config.js +100 -57
  106. package/vite-plugin.js +545 -32
  107. package/public/robots.txt +0 -5
  108. package/scripts/check-overflow.mjs +0 -33
  109. package/scripts/migrate-to-content-dirs.mjs +0 -223
  110. package/scripts/patch-lru-cache-tla.js +0 -164
package/README.md CHANGED
@@ -1,10 +1,16 @@
1
1
  # Vertex CMS
2
2
 
3
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
- [![GitHub Packages](https://img.shields.io/badge/npm-GitHub%20Packages-brightgreen)](https://github.com/koehler8/cms/packages)
4
+ [![npm](https://img.shields.io/badge/npm-registry.npmjs.org-brightgreen)](https://www.npmjs.com/package/@koehler8/cms)
5
5
 
6
6
  Vertex CMS is a lightweight, config-driven Vue 3 framework with theming, extensions, and SSG support.
7
7
 
8
+ ## Accessibility
9
+
10
+ Vertex CMS targets **[WCAG 2.2 Level AA](https://www.w3.org/WAI/WCAG22/quickref/?currentsidebar=%23col_overview&versions=2.2&levels=aaa)** as a hard requirement for the bundled components, the `base` theme, and the page wrapper. Sites that ship on this framework with the bundled pieces inherit a skip link, single `<main>` landmark, real form labels with `aria-live` errors, visible focus rings, modal focus trap+restore, AA-verified palette contrast, and `prefers-reduced-motion` support out of the box.
11
+
12
+ When extending or theming, **don't regress AA**. See the "Accessibility" section of [CLAUDE.md](CLAUDE.md#accessibility-wcag-22-level-aa--hard-requirement) for the requirements, the token naming conventions for safe colors, and the audit checklist to run when changing palette/markup/CSS.
13
+
8
14
  ## Quick Start
9
15
 
10
16
  ### 1. Create a site repo
@@ -12,30 +18,33 @@ Vertex CMS is a lightweight, config-driven Vue 3 framework with theming, extensi
12
18
  ```
13
19
  my-site/
14
20
  site/
15
- config/
16
- site.json # Site metadata and settings
17
- shared.json # Shared content blocks
18
- pages/
19
- home.json # Per-page content
20
- assets/ # Images and media
21
- style.css # Site-specific overrides (optional)
21
+ content/
22
+ content.config.json # { "baseLocale": "en" }
23
+ en/
24
+ site.json # Site metadata and settings
25
+ shared.json # Shared content blocks
26
+ pages/
27
+ home.json # Per-page content
28
+ assets/img/ # Images and media
29
+ components/ # Site-local Vue components (optional)
30
+ style.css # Site-specific overrides (optional)
22
31
  .env
23
32
  package.json
24
33
  vite.config.js
25
34
  ```
26
35
 
27
- ### 2. Install
36
+ Or scaffold all of it in one step:
28
37
 
29
- Configure npm to use GitHub Packages for the `@koehler8` scope:
30
-
31
- ```
32
- @koehler8:registry=https://npm.pkg.github.com
38
+ ```bash
39
+ npx --package @koehler8/cms@^1.0.0 cms-create-site my-site
33
40
  ```
34
41
 
35
- Then install:
42
+ ### 2. Install
43
+
44
+ Vertex CMS is published to the public npm registry (`registry.npmjs.org`) — no scope configuration needed:
36
45
 
37
46
  ```bash
38
- npm install @koehler8/cms vue vue-router vite vite-ssg @vitejs/plugin-vue
47
+ npm install @koehler8/cms@^1.0.0 vue vue-router vite vite-ssg @vitejs/plugin-vue
39
48
  ```
40
49
 
41
50
  ### 3. Configure Vite
@@ -58,11 +67,15 @@ export default {
58
67
  ### 4. Run
59
68
 
60
69
  ```bash
61
- npm run dev # Development server
62
- npm run build # Production build
63
- vite-ssg build # Static site generation
70
+ npm run dev # Development server
71
+ npm run build # Production build (SPA)
72
+ npx cms-ssg-build # Static site generation — memory-bounded shards + blank-page gate
64
73
  ```
65
74
 
75
+ (`vite-ssg build` also works directly, but `cms-ssg-build` wraps it with a
76
+ bounded heap, route sharding for large sites, and a post-render check that
77
+ fails the build if any page comes out blank.)
78
+
66
79
  ## Plugin Options
67
80
 
68
81
  | Option | Default | Description |
@@ -97,6 +110,20 @@ All files use flat dot-notation keys in alphabetical order. The base locale (spe
97
110
 
98
111
  **Supported locales:** en, fr, es, de, ja, ko, pt, ru, tr, vi, id, zh, th, hi, fil
99
112
 
113
+ ### Analytics & consent
114
+
115
+ Set `"googleId"` in `site.json` to enable Google Analytics. How the
116
+ not-yet-answered consent state is treated is configurable:
117
+
118
+ ```json
119
+ { "analytics.consentMode": "opt-in" }
120
+ ```
121
+
122
+ - `"opt-out"` (default) — analytics load while consent is pending and stop if
123
+ the visitor declines. Common US posture.
124
+ - `"opt-in"` — analytics stay off until the visitor explicitly accepts.
125
+ Use this for sites with EU/EEA audiences (GDPR / ePrivacy).
126
+
100
127
  ## Themes
101
128
 
102
129
  Themes export a manifest with design tokens (palette, typography, surfaces, CTAs, etc.) that are applied as CSS variables at runtime.
@@ -118,10 +145,10 @@ Each extension has an `extension.config.json` manifest defining:
118
145
  - **assets** -- CSS and static file references
119
146
  - **dependencies** -- Required npm packages
120
147
 
121
- Validate extension manifests with:
148
+ The manifest JSON Schema ships with the package — point your editor or JSON-schema tooling at it:
122
149
 
123
- ```bash
124
- npx cms-validate-extensions --site-dir ./site
150
+ ```json
151
+ { "$schema": "node_modules/@koehler8/cms/extensions/manifest.schema.json" }
125
152
  ```
126
153
 
127
154
  ## Built-in Components
@@ -163,6 +190,27 @@ npx cms-validate-extensions --site-dir ./site
163
190
  | `useLazyImage` | Lazy image loading with IntersectionObserver |
164
191
  | `usePromoBackgroundStyles` | Promo section background styling |
165
192
 
193
+ ## Draft Mode
194
+
195
+ Pages can be gated behind a site-wide password before launch: `"draft": true`
196
+ on a page, `"draftPaths": [...]` prefixes, or `"draft": true` site-wide in
197
+ `site.json`, with the password in `"draftPassword"`. The build replaces the
198
+ plaintext password with a SHA-256 hash (plaintext never ships), gated pages
199
+ render only the password gate in the SSG HTML, are marked `noindex`, and are
200
+ omitted from `sitemap.xml`.
201
+
202
+ **Know what the gate is — and isn't.** Draft mode is a pre-launch
203
+ convenience, not access control:
204
+
205
+ - The password hash ships in the public bundle (unsalted SHA-256 — a weak or
206
+ guessable password is trivially recoverable).
207
+ - The gated page's `content.*` keys still ship in the page's hydration JSON
208
+ so Vue can mount after unlock — a determined reader can parse them out of
209
+ the HTML without the password.
210
+
211
+ For genuinely confidential content (NDA material, unannounced listings),
212
+ don't put it in the page JSON until launch.
213
+
166
214
  ## Testing
167
215
 
168
216
  The framework uses [Vitest](https://vitest.dev/) with happy-dom for unit testing.
@@ -178,34 +226,66 @@ Tests live in `tests/` mirroring the source structure (`tests/utils/`, `tests/co
178
226
  ## CLI Commands
179
227
 
180
228
  ```bash
229
+ # Scaffold a new site / theme / extension
230
+ npx cms-create-site my-site
231
+ npx cms-create-theme my-theme
232
+ npx cms-create-extension my-extension
233
+
234
+ # Static site generation (memory-bounded shards + blank-page gate)
235
+ npx cms-ssg-build
236
+
181
237
  # Generate favicon.ico, logo.png, og-image.jpg from source assets
182
238
  npx cms-generate-public-assets --site-dir ./site
183
239
 
184
- # Validate theme manifests
185
- npx cms-validate-themes
240
+ # Validate theme manifests (bundled + your site-local themes/)
241
+ npx cms-validate-themes --site-dir ./site
186
242
 
187
- # Validate extension manifests
188
- npx cms-validate-extensions --site-dir ./site
243
+ # Validate extension manifests (site-local extensions/ + named packages)
244
+ npx cms-validate-extensions --site-dir ./site @koehler8/cms-ext-compliance
189
245
  ```
190
246
 
247
+ (The Vite plugin also runs the same extension-manifest validation on every
248
+ build and fails loudly on an invalid manifest — the CLIs are for checking
249
+ outside a build.)
250
+
191
251
  ## Exports
192
252
 
253
+ The supported public API surface — these specifiers are covered by semver:
254
+
193
255
  ```js
194
- import cmsPlugin from '@koehler8/cms/vite'; // Vite plugin
256
+ // Build integration
257
+ import cmsPlugin from '@koehler8/cms/vite'; // Vite plugin
195
258
  import { createCmsApp } from '@koehler8/cms/app'; // App factory
259
+
260
+ // Utils commonly used by site components and extensions
196
261
  import { loadConfigData } from '@koehler8/cms/utils/loadConfig';
197
262
  import { resolveAsset, resolveMedia } from '@koehler8/cms/utils/assetResolver';
198
263
  import { useResponsiveImage } from '@koehler8/cms/utils/imageSources';
199
- // ... and all other utils/*, composables/*, components/*, etc.
264
+ import { trackEvent } from '@koehler8/cms/utils/analytics';
265
+ import { hasAcceptedConsent } from '@koehler8/cms/utils/cookieConsent';
266
+ import { formatTokenAmount } from '@koehler8/cms/utils/formatNumber';
267
+ import { resolveThemeColor } from '@koehler8/cms/utils/themeColors';
268
+
269
+ // Bundled components and composables (by file)
270
+ import Header from '@koehler8/cms/components/Header.vue';
271
+ import { useComingSoonConfig } from '@koehler8/cms/composables/useComingSoonConfig';
200
272
  ```
201
273
 
274
+ The extension manifest schema is exported at
275
+ `@koehler8/cms/extensions/manifest.schema.json`.
276
+
277
+ Other `utils/*`, `composables/*`, `components/*`, `themes/*`, and
278
+ `extensions/*` deep imports resolve too, but anything not listed above is
279
+ **internal** — it may move or change between minor versions.
280
+
202
281
  ## Environment Variables
203
282
 
204
283
  | Variable | Used By | Description |
205
284
  |----------|---------|-------------|
206
285
  | `VITE_SHOW_COOKIE_BANNER` | cookieConsent | Enable cookie consent banner |
286
+ | `VITE_APP_VERSION` | appInfo | Override the framework version string reported in the bundle |
207
287
  | `CMS_SITE_DIR` | generate-public-assets | Site directory path (build scripts) |
208
- | `FAVICON_BG` / `FAVICON_FG` | generate-public-assets | Favicon background/foreground colors |
288
+ | `FAVICON_BG` / `FAVICON_FG` | generate-public-assets | Fallback favicon/og colors (default: stable per-title pair) |
209
289
 
210
290
  ## License
211
291
 
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const scriptPath = path.resolve(__dirname, '..', 'scripts', 'create-extension.mjs');
7
+ await import(pathToFileURL(scriptPath).href);
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const scriptPath = path.resolve(__dirname, '..', 'scripts', 'create-site.mjs');
7
+ await import(pathToFileURL(scriptPath).href);
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const scriptPath = path.resolve(__dirname, '..', 'scripts', 'create-theme.mjs');
7
+ await import(pathToFileURL(scriptPath).href);
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // Thin wrapper that runs the memory-bounded SSG build orchestrator from the
3
+ // consuming site's cwd. The script does its own argument handling, spawns the
4
+ // per-shard `vite-ssg build` processes, and prints its own status.
5
+ // See scripts/ssg-build/index.mjs and SSG-MEMORY-PLAN.md.
6
+
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = path.dirname(__filename);
12
+
13
+ await import(path.resolve(__dirname, '..', 'scripts', 'ssg-build', 'index.mjs'));
@@ -4,14 +4,18 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
4
4
 
5
5
  const args = process.argv.slice(2);
6
6
  let siteDir = './site';
7
+ const packages = [];
7
8
  for (let i = 0; i < args.length; i++) {
8
9
  if (args[i] === '--site-dir' && args[i + 1]) {
9
10
  siteDir = args[i + 1];
10
11
  i++;
12
+ } else if (!args[i].startsWith('-')) {
13
+ packages.push(args[i]);
11
14
  }
12
15
  }
13
16
 
14
17
  process.env.CMS_SITE_DIR = path.resolve(process.cwd(), siteDir);
18
+ process.env.CMS_VALIDATE_PACKAGES = packages.join(',');
15
19
 
16
20
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
21
  const scriptPath = path.resolve(__dirname, '..', 'scripts', 'validate-extensions.mjs');
@@ -2,6 +2,17 @@
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
4
 
5
+ const args = process.argv.slice(2);
6
+ let siteDir = './site';
7
+ for (let i = 0; i < args.length; i++) {
8
+ if (args[i] === '--site-dir' && args[i + 1]) {
9
+ siteDir = args[i + 1];
10
+ i++;
11
+ }
12
+ }
13
+
14
+ process.env.CMS_SITE_DIR = path.resolve(process.cwd(), siteDir);
15
+
5
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
17
  const scriptPath = path.resolve(__dirname, '..', 'scripts', 'validate-themes.mjs');
7
18
  await import(pathToFileURL(scriptPath).href);
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "components": {
43
43
  "type": "array",
44
- "minItems": 1,
44
+ "minItems": 0,
45
45
  "items": {
46
46
  "type": "object",
47
47
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koehler8/cms",
3
- "version": "1.0.0-beta.8",
3
+ "version": "1.0.0",
4
4
  "description": "Vertex CMS — lightweight, config-driven Vue 3 framework with theming, extensions, and SSG support",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,9 +13,16 @@
13
13
  "bugs": {
14
14
  "url": "https://github.com/koehler8/cms/issues"
15
15
  },
16
- "keywords": ["vue", "cms", "vite", "ssg", "theming", "extensions", "config-driven"],
16
+ "keywords": [
17
+ "vue",
18
+ "cms",
19
+ "vite",
20
+ "ssg",
21
+ "theming",
22
+ "extensions",
23
+ "config-driven"
24
+ ],
17
25
  "scripts": {
18
- "postinstall": "node scripts/patch-lru-cache-tla.js",
19
26
  "test": "vitest run",
20
27
  "test:watch": "vitest",
21
28
  "test:coverage": "vitest run --coverage"
@@ -33,21 +40,21 @@
33
40
  "./components/*.vue": "./src/components/*.vue",
34
41
  "./components/*.js": "./src/components/*.js",
35
42
  "./components/*": "./src/components/*",
36
- "./router": "./src/router/index.js",
37
43
  "./themes/*.js": "./src/themes/*.js",
38
44
  "./themes/*": "./src/themes/*.js",
45
+ "./extensions/manifest.schema.json": "./extensions/manifest.schema.json",
39
46
  "./extensions/*.js": "./src/extensions/*.js",
40
47
  "./extensions/*": "./src/extensions/*.js",
41
- "./constants/*.js": "./src/constants/*.js",
42
- "./constants/*": "./src/constants/*.js",
43
- "./plugins/*.js": "./src/plugins/*.js",
44
- "./plugins/*": "./src/plugins/*.js",
45
48
  "./package.json": "./package.json"
46
49
  },
47
50
  "bin": {
48
51
  "cms-generate-public-assets": "./bin/cms-generate-public-assets.js",
52
+ "cms-ssg-build": "./bin/cms-ssg-build.js",
49
53
  "cms-validate-themes": "./bin/cms-validate-themes.js",
50
- "cms-validate-extensions": "./bin/cms-validate-extensions.js"
54
+ "cms-validate-extensions": "./bin/cms-validate-extensions.js",
55
+ "cms-create-theme": "./bin/cms-create-theme.js",
56
+ "cms-create-extension": "./bin/cms-create-extension.js",
57
+ "cms-create-site": "./bin/cms-create-site.js"
51
58
  },
52
59
  "files": [
53
60
  "src",
@@ -58,7 +65,8 @@
58
65
  "templates",
59
66
  "bin",
60
67
  "vite-plugin.js",
61
- "README.md"
68
+ "README.md",
69
+ "CHANGELOG.md"
62
70
  ],
63
71
  "peerDependencies": {
64
72
  "@vitejs/plugin-vue": "^6.0.0",
@@ -68,17 +76,29 @@
68
76
  "vue-router": "^5.0.0"
69
77
  },
70
78
  "dependencies": {
71
- "@unhead/vue": "^2.0.19",
72
- "ajv": "^8.17.1",
79
+ "@unhead/vue": "^2.1.15",
80
+ "ajv": "^8.20.0",
73
81
  "ajv-formats": "^3.0.1",
82
+ "pinia": "^3.0.1",
83
+ "sharp": "^0.35.3"
84
+ },
85
+ "optionalDependencies": {
74
86
  "canvas": "^3.2.0",
75
87
  "dotenv": "^17.2.3",
76
- "pinia": "^3.0.1",
77
88
  "png-to-ico": "^3.0.1"
78
89
  },
79
90
  "devDependencies": {
80
- "@vue/test-utils": "^2.4.6",
81
- "happy-dom": "^20.8.9",
82
- "vitest": "^4.1.4"
91
+ "@vitejs/plugin-vue": "^6.0.8",
92
+ "@vitest/coverage-v8": "^4.1.10",
93
+ "@vue/test-utils": "^2.4.10",
94
+ "happy-dom": "^20.9.0",
95
+ "vite": "^8.2.1",
96
+ "vitest": "^4.1.6",
97
+ "vue": "^3.5.39",
98
+ "vue-router": "^5.0.7"
99
+ },
100
+ "engines": {
101
+ "node": ">=20.19.0",
102
+ "npm": ">=10.8.0"
83
103
  }
84
104
  }
@@ -0,0 +1,11 @@
1
+ import { runScaffold } from './scaffold.mjs';
2
+
3
+ const result = await runScaffold({
4
+ kind: 'extension',
5
+ argv: process.argv.slice(2),
6
+ });
7
+
8
+ if (result.message) {
9
+ process[result.ok ? 'stdout' : 'stderr'].write(result.message + '\n');
10
+ }
11
+ process.exit(result.ok ? 0 : 1);
@@ -0,0 +1,11 @@
1
+ import { runScaffold } from './scaffold.mjs';
2
+
3
+ const result = await runScaffold({
4
+ kind: 'site',
5
+ argv: process.argv.slice(2),
6
+ });
7
+
8
+ if (result.message) {
9
+ process[result.ok ? 'stdout' : 'stderr'].write(result.message + '\n');
10
+ }
11
+ process.exit(result.ok ? 0 : 1);
@@ -0,0 +1,11 @@
1
+ import { runScaffold } from './scaffold.mjs';
2
+
3
+ const result = await runScaffold({
4
+ kind: 'theme',
5
+ argv: process.argv.slice(2),
6
+ });
7
+
8
+ if (result.message) {
9
+ process[result.ok ? 'stdout' : 'stderr'].write(result.message + '\n');
10
+ }
11
+ process.exit(result.ok ? 0 : 1);
@@ -6,13 +6,35 @@
6
6
  // {CMS_SITE_DIR}/content/{baseLocale}/site.json, output is written to
7
7
  // {dirname(CMS_SITE_DIR)}/public/.
8
8
 
9
- import 'dotenv/config';
10
-
11
9
  import fs from 'fs';
12
10
  import path from 'path';
13
11
  import { fileURLToPath } from 'url';
14
- import { createCanvas, registerFont } from 'canvas';
15
- import pngToIco from 'png-to-ico';
12
+
13
+ // dotenv is optional — without it, config comes from the process environment.
14
+ try {
15
+ await import('dotenv/config');
16
+ } catch {
17
+ /* optional dependency not installed */
18
+ }
19
+
20
+ // canvas (native) and png-to-ico are optionalDependencies: npm skips them
21
+ // when their platform build fails, so a consumer that never runs this
22
+ // command still installs cleanly. This command DOES need them — fail loudly
23
+ // with the fix instead of a bare module-not-found stack.
24
+ let createCanvas;
25
+ let registerFont;
26
+ let pngToIco;
27
+ try {
28
+ ({ createCanvas, registerFont } = await import('canvas'));
29
+ ({ default: pngToIco } = await import('png-to-ico'));
30
+ } catch (err) {
31
+ console.error('❌ cms-generate-public-assets requires the optional dependencies `canvas` and `png-to-ico`.');
32
+ console.error(' They were skipped (or failed to build) during install — canvas is a native module');
33
+ console.error(' and needs system libraries (cairo, pango) on platforms without prebuilds.');
34
+ console.error(' Install them explicitly: npm install canvas png-to-ico');
35
+ console.error(` Underlying error: ${err.message}`);
36
+ process.exit(1);
37
+ }
16
38
 
17
39
  const __filename = fileURLToPath(import.meta.url);
18
40
  const __dirname = path.dirname(__filename);
@@ -83,6 +105,37 @@ function luminance({ r, g, b }) {
83
105
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
84
106
  }
85
107
 
108
+ const FALLBACK_COLOR_PAIRS = [
109
+ ['#ffffff', '#000000'],
110
+ ['#000000', '#ffffff'],
111
+ ['#ff0000', '#ffffff'],
112
+ ['#0000ff', '#ffffff'],
113
+ ['#008000', '#ffffff'],
114
+ ['#ffff00', '#000000'],
115
+ ['#ff00ff', '#000000'],
116
+ ['#00ffff', '#000000'],
117
+ ['#1e90ff', '#ffffff'],
118
+ ['#ff8c00', '#000000'],
119
+ ['#4b0082', '#ffffff'],
120
+ ['#32cd32', '#000000'],
121
+ ['#8b0000', '#ffffff'],
122
+ ['#ffd700', '#000000'],
123
+ ['#ff69b4', '#000000'],
124
+ ['#2f4f4f', '#ffffff']
125
+ ];
126
+
127
+ // Deterministic per-site pick: the same title always yields the same pair,
128
+ // so generated assets keep their colors across deploys (public/ is rebuilt
129
+ // on every build).
130
+ function getStableColorPair(seed) {
131
+ const s = String(seed || '');
132
+ let hash = 5381;
133
+ for (let i = 0; i < s.length; i++) {
134
+ hash = ((hash * 33) ^ s.charCodeAt(i)) >>> 0;
135
+ }
136
+ return FALLBACK_COLOR_PAIRS[hash % FALLBACK_COLOR_PAIRS.length];
137
+ }
138
+
86
139
  // Try to register a professional-looking font if available
87
140
  function tryRegisterFonts() {
88
141
  const candidates = [
@@ -336,44 +389,16 @@ async function generateFavicon() {
336
389
  shouldGenerateOg = true;
337
390
  }
338
391
 
339
- const providedFavicon = resolveSiteAssetFile(['favicon.ico']);
340
- if (providedFavicon) {
341
- fs.copyFileSync(providedFavicon, icoPath);
342
- console.log(`✅ Found existing favicon; copied from ${providedFavicon}`);
343
- return;
344
- }
345
-
346
- console.log(`Generating favicon.ico using letter "${letter}" from site "${siteTitle}"`);
347
-
348
- function getRandomColorPair() {
349
- const colors = [
350
- ['#ffffff', '#000000'],
351
- ['#000000', '#ffffff'],
352
- ['#ff0000', '#ffffff'],
353
- ['#0000ff', '#ffffff'],
354
- ['#008000', '#ffffff'],
355
- ['#ffff00', '#000000'],
356
- ['#ff00ff', '#000000'],
357
- ['#00ffff', '#000000'],
358
- ['#1e90ff', '#ffffff'],
359
- ['#ff8c00', '#000000'],
360
- ['#4b0082', '#ffffff'],
361
- ['#32cd32', '#000000'],
362
- ['#8b0000', '#ffffff'],
363
- ['#ffd700', '#000000'],
364
- ['#ff69b4', '#000000'],
365
- ['#2f4f4f', '#ffffff']
366
- ];
367
- return colors[Math.floor(Math.random() * colors.length)];
368
- }
369
-
370
392
  let bg = process.env.FAVICON_BG;
371
393
  let fg = process.env.FAVICON_FG;
372
394
 
373
395
  if (!bg || !fg) {
374
- [bg, fg] = getRandomColorPair();
396
+ [bg, fg] = getStableColorPair(siteTitle);
375
397
  }
376
398
 
399
+ // The fallback og-image must generate before the provided-favicon early
400
+ // return: socialMeta emits og:image → /og-image.jpg on every page, so a
401
+ // site that ships its own favicon.ico but no og-image still needs one.
377
402
  if (shouldGenerateOg) {
378
403
  try {
379
404
  const ogBuffer = renderOgImageBuffer(siteTitle, { width: 1200, height: 630, bg, fg });
@@ -384,6 +409,15 @@ async function generateFavicon() {
384
409
  }
385
410
  }
386
411
 
412
+ const providedFavicon = resolveSiteAssetFile(['favicon.ico']);
413
+ if (providedFavicon) {
414
+ fs.copyFileSync(providedFavicon, icoPath);
415
+ console.log(`✅ Found existing favicon; copied from ${providedFavicon}`);
416
+ return;
417
+ }
418
+
419
+ console.log(`Generating favicon.ico using letter "${letter}" from site "${siteTitle}"`);
420
+
387
421
  const sizes = [256, 128, 64, 48, 32, 16];
388
422
  const pngBuffers = sizes.map((s) => renderLetterPng(letter, s, { bg, fg }));
389
423
 
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Compute the per-site variant cache directory.
3
+ *
4
+ * Layout:
5
+ * {siteRoot}/node_modules/.cache/@koehler8/cms/image-variants/{siteKey}/
6
+ *
7
+ * where siteKey is the first 8 hex chars of sha1(siteDir). The keying
8
+ * prevents cache collisions when @koehler8/cms is hoisted in a workspace
9
+ * (one framework install serves multiple sites — without per-site keys,
10
+ * concurrent builds would clobber each other).
11
+ *
12
+ * The cache lives under the SITE's node_modules (matching Vite's own
13
+ * node_modules/.vite/ convention) so each site's cache is local to its
14
+ * own project tree.
15
+ */
16
+
17
+ import crypto from 'node:crypto';
18
+ import path from 'node:path';
19
+
20
+ export function siteKeyFor(siteDir) {
21
+ return crypto
22
+ .createHash('sha1')
23
+ .update(path.resolve(siteDir))
24
+ .digest('hex')
25
+ .slice(0, 8);
26
+ }
27
+
28
+ export function computeVariantCacheDir(siteRoot, siteDir) {
29
+ const root = path.resolve(siteRoot);
30
+ const key = siteKeyFor(siteDir || siteRoot);
31
+ return path.join(
32
+ root,
33
+ 'node_modules',
34
+ '.cache',
35
+ '@koehler8',
36
+ 'cms',
37
+ 'image-variants',
38
+ key,
39
+ );
40
+ }