@koehler8/cms 1.0.0-beta.9 → 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 +33 -17
  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 +82 -66
  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/CHANGELOG.md ADDED
@@ -0,0 +1,1491 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+
5
+ First stable release. The changes below are the three implementation batches
6
+ from the 2026-08-15 architecture review (`ARCHITECTURE-REVIEW.md`):
7
+ release-readiness blockers, the breaking-change window, and the
8
+ stabilization pass.
9
+
10
+ ### Breaking changes (the 1.0 window)
11
+
12
+ All verified end-to-end on a scratch site built against this tree: SSG build,
13
+ rendered HTML inspection, and live browser hydration/storage/draft-gate
14
+ checks.
15
+
16
+ - **`site.trimInitialState` now defaults ON.** Pages embed only their own
17
+ config in `window.__INITIAL_STATE__` (measured 218 KB / 81% of page weight
18
+ saved on a profiled site). Opt out with `"trimInitialState": false`.
19
+ Rendered HTML is unaffected; the client soft-reloads the full config after
20
+ first paint so in-SPA navigation keeps working.
21
+ - **Browser-storage keys are namespaced under `cms_`** (`cms_locale`,
22
+ `cms_cookie_consent`, `cms_cookie_consent_timestamp`, `cms_session_id`,
23
+ `cms_attribution_v1`) via a single guarded helper. Reads fall back to the
24
+ old unprefixed keys and migrate them forward, so returning visitors keep
25
+ their consent choice and locale. All access now degrades safely in Safari
26
+ private mode.
27
+ - **Extension manifests validate at build time, not in the browser.** An
28
+ invalid `extension.config.json` now fails the build with the offending
29
+ fields; ajv/ajv-formats (~40 KB gz) no longer ship to visitors. The runtime
30
+ keeps a cheap structural check whose failure logs unconditionally.
31
+ - **The vestigial `/admin` route is gone** (was a pre-rendered duplicate of
32
+ the 404 page on every site, once per shard).
33
+ - **robots.txt no longer discloses draft URLs.** Per-page and `draftPaths`
34
+ `Disallow` lines were the discovery vector for unlinked drafts and blocked
35
+ Google from ever seeing the page's `noindex`. Deindexing now rests on the
36
+ noindex meta + sitemap omission + the gate; site-wide draft keeps its
37
+ blanket `Disallow: /`. Non-draft sites emit the canonical allow-all form.
38
+ - **Five components stop rendering placeholders without content.** `Plan`,
39
+ `Principles`, `Contact`, `About`, `Intro` now render nothing when their
40
+ `content.*` block is absent (matching `ComingSoon`); `AboutValue` stops
41
+ emitting an empty section shell. Fleet audit: every live page supplies its
42
+ content block, so nothing visible changes.
43
+ - **Unique section ids:** `AboutValue` → `#about-value`, `Intro` → `#intro`
44
+ (`About` keeps `#about`). Fleet audit: no page composes two of the trio and
45
+ no site's `#about` anchors target the bundled components.
46
+ - **One `Spacer`** (prop-driven, default 30px) replaces four copies;
47
+ `Spacer15/30/40/60` remain as aliases so existing pages keep working. Bare
48
+ `"Spacer"` now means 30px (previously aliased to the 15px variant; zero
49
+ fleet usage of the bare name).
50
+ - **New `site.analytics.consentMode`:** `"opt-out"` (default — analytics load
51
+ while consent is pending, today's behavior) or `"opt-in"` (analytics stay
52
+ off until explicit acceptance; the GDPR/ePrivacy posture for EU-audience
53
+ sites).
54
+
55
+ ### Release pipeline: publishing is now gated
56
+
57
+ The `Publish to npm` workflow previously ran zero checks — any `v*` tag
58
+ shipped whatever the tree held, and a tag that didn't match `package.json`
59
+ silently published the stale version. Publishing now requires a green test
60
+ job (`npm ci --ignore-scripts` + full Vitest suite) and an exact
61
+ tag↔`package.json` version match, on the Node version from `.nvmrc`.
62
+
63
+ ### Removed: obsolete consumer-install machinery
64
+
65
+ - **`postinstall` lru-cache patch deleted.** Upstream fixed the top-level
66
+ await the patch existed to strip (verified across every installed copy in
67
+ this repo and the consumer fleet: 10.4.3 / 11.3.x are clean). The patch was
68
+ pure liability: an unguarded `JSON.parse` walk over `node_modules` on every
69
+ consumer install, and a breakage under `--ignore-scripts` CI policies.
70
+ Consumers on current lockfiles need no changes.
71
+ - **`cms-migrate-image-variants` removed** — the one-time beta.26→27
72
+ migration completed fleet-wide, and run after 1.0 it would have written
73
+ `^1.0.0-beta.27` back into a consumer's package.json (a downgrade).
74
+ - **`scripts/migrate-to-content-dirs.mjs` removed** (one-time `site/config/`
75
+ → `site/content/` migration, long completed).
76
+ - **`scripts/check-overflow.mjs` removed** — imported `playwright`, which was
77
+ never a dependency; dead on arrival for any installer.
78
+
79
+ ### Exports map curated (the 1.0 semver surface)
80
+
81
+ - Removed `./plugins/*` (pointed at a directory that doesn't exist),
82
+ `./router`, and `./constants/*` (zero consumers fleet-wide).
83
+ - Added `./extensions/manifest.schema.json` so extension configs can
84
+ reference the schema through the package instead of raw `node_modules`
85
+ paths.
86
+ - README now documents the supported specifier list; deep imports beyond it
87
+ are declared internal.
88
+
89
+ ### Scaffolds brought to release state
90
+
91
+ - `@koehler8/cms` pins: `^1.0.0` across the site/theme/extension templates
92
+ (site was 24 betas stale).
93
+ - `build:ssg` now runs `cms-ssg-build` (bounded heap, sharding, blank-page
94
+ gate) instead of raw `vite-ssg build` behind a `NODE_OPTIONS` bandaid.
95
+ - Template `site.json` ships `"trimInitialState": true` and
96
+ `"trailingSlash": true` — new sites have no legacy payload or URL shape to
97
+ preserve — plus an empty `"theme"` key for discoverability.
98
+ - `.env.example` loses the web3 leftovers (`VITE_REGISTRY_ADDRESS`,
99
+ `VITE_RPC_READ`, `VITE_REOWN_PROJECT_ID`); documents `VITE_APP_VERSION`.
100
+
101
+ ### `cms-generate-public-assets` fixes
102
+
103
+ - A site providing its own `site/favicon.ico` but no og-image now still gets
104
+ a generated `public/og-image.jpg` (previously the provided-favicon early
105
+ return skipped og generation entirely, while every page's `og:image` meta
106
+ kept pointing at the missing file).
107
+ - Fallback favicon/og colors are now a stable hash of the site title instead
108
+ of `Math.random()` — no more brand favicon changing color on every deploy.
109
+ `FAVICON_BG`/`FAVICON_FG` still override.
110
+
111
+ ### Packaging & docs
112
+
113
+ - `engines.npm` relaxed to `>=10.8.0` for the published package (the strict
114
+ 10.8 pin remains fleet discipline via `.nvmrc`; external consumers on npm
115
+ 11 no longer get `EBADENGINE` warnings).
116
+ - `CHANGELOG.md` now ships in the npm tarball.
117
+ - README: current `site/content/{locale}/` layout in Quick Start,
118
+ `cms-ssg-build` as the documented SSG path, an honest note that the
119
+ validate CLIs currently only check the framework's own bundled manifests,
120
+ and a Draft Mode section stating the gate's security posture (unsalted
121
+ SHA-256 hash in the bundle, content in hydration JSON — pre-launch
122
+ convenience, not access control).
123
+
124
+ ### Stabilization batch — tooling that tells the truth, a11y, coverage
125
+
126
+ All verified end-to-end: full unit suite (720 tests / 56 files), a real SSG
127
+ build of a validation site against this tree, rendered-output inspection,
128
+ and live browser checks (hydration, focus trap, draft unlock).
129
+
130
+ **Failures are loud now:**
131
+
132
+ - A route whose page config fails to load during pre-render FAILS the build
133
+ (machine-grepable marker scanned by `cms-ssg-build`); previously it
134
+ shipped chrome around an empty `<main>` on a green build. The shard merge
135
+ also exits 1 instead of warning when no shard rendered the home page, and
136
+ the merged `dist/` gets a final blank-page scan.
137
+ - **Unknown `components[]` references fail the build** with page, locale,
138
+ index, and a did-you-mean suggestion — closing the fleet's #1 authoring
139
+ trap (production silently dropped the section). Fleet-audited: zero
140
+ violations exist today. Source-qualified strings (`site:Name`,
141
+ `slug:Name`) now also work at runtime, matching the documented syntax.
142
+ - `cms-validate-extensions` / `cms-validate-themes` actually validate the
143
+ site now (site-local `extensions/` + named packages; bundled + site-local
144
+ themes) through the same implementation the plugin runs at build, and
145
+ exit non-zero on failure. Previously both always exited 0 without
146
+ checking anything of the site's.
147
+ - The contact form: an unconfigured form (no `form.action`) renders
148
+ disabled and says so instead of posting to the page URL and claiming
149
+ success; the plugin warns at build time; network failures keep the
150
+ visitor's typed message; the anti-spam challenge no longer causes a
151
+ hydration mismatch on every load; one aria-live region instead of two.
152
+ - The draft gate over plain HTTP says "requires a secure connection"
153
+ instead of calling the correct password incorrect.
154
+
155
+ **Accessibility (backing the AA claim):**
156
+
157
+ - New `useFocusTrap` composable: DraftGate declared `aria-modal` without
158
+ trapping Tab — it traps now (verified in a live browser); ComingSoonModal
159
+ and IntroGate drop their duplicated private implementations.
160
+ - `prefers-reduced-motion` coverage completed (IntroGate, ComingSoonModal,
161
+ ComingSoon, StickyCTA, Portfolio) and bare `:focus` hover-pairs converted
162
+ to `:focus-visible`.
163
+ - DraftGate and NotFound are themeable: every theme manifest now feeds
164
+ `--brand-text` / `--brand-surface` (+muted/elevated/border), which the
165
+ two components consumed but nothing ever defined.
166
+
167
+ **Correctness & performance:**
168
+
169
+ - Image-variant cache: fixed the manifest poisoning that re-encoded every
170
+ under-width source on alternating builds; failed renders are no longer
171
+ recorded as existing; manifest and image writes are atomic.
172
+ - Responsive-image widths come from the pipeline's resolved matrix via the
173
+ virtual config — components can no longer request widths that don't
174
+ exist (About shipped a one-entry srcset for a 66vw slot).
175
+ - Multi-locale sitemaps emit every locale version as its own `<url>` entry;
176
+ JSON-LD sinks are `</script>`-breakout-safe and the Organization block is
177
+ real JSON (a `"` in a site title no longer ships as `&quot;`);
178
+ `og:locale` (+alternates) emitted; manifest icon stops claiming
179
+ `maskable`.
180
+ - Dev sessions stop churning git-tracked files: SEO files write only on
181
+ change and are never deleted mid-session; image edits trigger one reload
182
+ instead of two; the dev-server flag actually works under Vite 8.
183
+ - The framework version reaches the bundle as a compile-time constant —
184
+ the full package.json (deps, exports map) no longer ships as a string in
185
+ every site's main chunk.
186
+
187
+ **Coverage:** first real component tests (DraftGate, NotFound, Spacer +
188
+ aliases, the externally-consumed `ui/` trio, Contact), router-guard branch
189
+ tests, and a resolver spec. 720 tests across 56 files.
190
+
191
+ ### Release notes
192
+
193
+ - Cut alongside `@koehler8/cms-ext-compliance` 1.0.0; the site scaffold now
194
+ pins both at `^1.0.0`.
195
+ - This release moves the `latest` dist-tag off `1.0.0-beta.5`, where it had
196
+ been frozen since the first-ever publish (all betas shipped under the
197
+ `beta` tag).
198
+ - Consuming sites do not auto-upgrade: existing lockfiles keep beta.39
199
+ until each site bumps. Re-pin sites to `^1.0.0` as they're touched, and
200
+ give the first bumps a browser once-over (they pick up the
201
+ `trimInitialState` default and the component-gating changes).
202
+ - Version-history note: `1.0.0-beta.1`–`beta.4` were never published to
203
+ npm; `beta.5`–`beta.7` were published without changelog entries.
204
+
205
+ ## 1.0.0-beta.39
206
+
207
+ ### Feature: `site.robots.allowAiCrawlers` — durable AI-crawler allow-list in robots.txt
208
+
209
+ Found while bumping the fleet to beta.38: five consumer sites (site-cityofangels,
210
+ site-coastalcollective, site-jamieaustin, site-ocandme, site-resom) had hand-edited
211
+ `public/robots.txt` with an explicit `Allow: /` block per AI/LLM crawler (GPTBot,
212
+ ClaudeBot, PerplexityBot, CCBot, etc.). `robots.txt` is regenerated from config on
213
+ every `build:ssg`/`generate:public-assets` run (see `writeSeoFiles` in
214
+ `vite-plugin.js`), so those hand edits were silently reverted on the next local
215
+ build — no error, just quiet drift between the committed file and the working tree.
216
+ There was no config path that could express "also allow these crawlers," so the
217
+ only way site authors had to express this intent didn't survive a rebuild.
218
+
219
+ `buildRobotsTxt` (`src/utils/robotsGenerator.js`) now accepts an opt-in
220
+ `site.robots.allowAiCrawlers` flag. When true, it appends a dedicated block per
221
+ known AI/LLM crawler (grouped: OpenAI/ChatGPT, Anthropic/Claude, Perplexity,
222
+ Common Crawl, Google, Bing, Meta, Diffbot) — each carrying the **same** Disallow
223
+ list as the wildcard `User-agent: *` block, so a named crawler never sees more
224
+ than the default policy already grants (draft paths and `/admin` stay blocked for
225
+ them too). Skipped entirely when `site.draft === true`. Default is off — existing
226
+ sites are unaffected unless they opt in.
227
+
228
+ Consuming sites should set `"robots.allowAiCrawlers": true` in `site.json` instead
229
+ of hand-editing `public/robots.txt`, and stop tracking that file in git (remove any
230
+ `!public/robots.txt` `.gitignore` exception) — it's build output now, like
231
+ `sitemap.xml` and `manifest.json`.
232
+
233
+ ## 1.0.0-beta.38
234
+
235
+ ### Fix: `cms-ssg-build` now fails loudly instead of silently shipping blank pages
236
+
237
+ Found while investigating a report that one component (`PpFollowCta` on
238
+ site-poopee) wasn't rendering: the real bug was much bigger. On two separate
239
+ clean `npm run build:ssg` runs, **every page on the site** rendered as an
240
+ empty `<div id="app" data-server-rendered="true"><!----></div>` — no
241
+ warnings, no errors, exit code 0. The reported "one component is broken"
242
+ symptom was just the first thing someone happened to grep for; nothing on
243
+ the page had rendered.
244
+
245
+ Root cause is in Vue's own SSR renderer, not this codebase:
246
+ `@vue/server-renderer`'s `renderComponentVNode` gates a component's render on
247
+ `Promise.all(prefetches).catch(shared.NOOP)` — if the `onServerPrefetch` hook
248
+ (used by `usePageConfig`'s `syncPage()` to resolve page content during SSG)
249
+ ever rejects, the rejection is silently discarded and the renderer proceeds
250
+ to render the component anyway, using whatever default/empty reactive state
251
+ it held at that point. This is a rare, timing-sensitive condition (roughly
252
+ 2 failures in ~30 build attempts while reproducing it) that intermittent —
253
+ sharding, concurrency, and `site.trimInitialState` were all investigated as
254
+ possible correlating factors, but none could be confirmed as the sole
255
+ trigger despite extensive bisection.
256
+
257
+ Rather than chase a non-deterministic upstream race further, `cms-ssg-build`
258
+ now verifies its own output: after every shard (and the single-build
259
+ fallback path for small sites), it scans the rendered HTML for the exact
260
+ empty-root signature and fails the build immediately, printing every
261
+ affected route, if any page comes out blank. A successful build now means
262
+ the pages are actually populated, not just that no exception was thrown.
263
+ Consuming sites don't need to change anything — the check runs automatically
264
+ as part of the existing `cms-ssg-build` command. If it ever fires, re-running
265
+ the build is the correct next step (the failure has not reproduced twice in
266
+ a row in any testing so far).
267
+
268
+ New: `scripts/ssg-build/findEmptyRenders.mjs` (pure, unit-tested; split out
269
+ from `index.mjs` specifically so it doesn't trip `index.mjs`'s top-level
270
+ `main()` invocation — see `bin/cms-ssg-build.js` — when imported for testing
271
+ or reuse).
272
+
273
+ ## 1.0.0-beta.37
274
+
275
+ ### Fix: `site.trimInitialState` now trims each page to its **own** config
276
+
277
+ The opt-in SSG payload trim (embed only the current page's config in
278
+ `window.__INITIAL_STATE__` instead of the whole site's) resolved the wrong page
279
+ under **vite-ssg 28**, which passes the route being pre-rendered as
280
+ `ctx.routePath` (a string) and pushes the router only *after* the app-setup
281
+ callback runs. `extractCurrentRoute()` read `ctx.route` (never set by vite-ssg)
282
+ and fell back to `router.currentRoute` — still vue-router's `START_LOCATION`
283
+ (`/`) at that point, identically on every page. Net effect:
284
+
285
+ - Sites **without** a custom `404.json` (so `/` resolves to `home`) trimmed
286
+ every page to **home's** config — small blob and `pagesPartial` set, but the
287
+ wrong page embedded.
288
+ - Sites **with** a path-less custom `pages/404.json` also default it to `/`,
289
+ making `/` ambiguous — `resolvePageIdForRoute` returned null and **nothing
290
+ trimmed**, so the full-site config shipped on every page.
291
+
292
+ Fixes:
293
+ - `main.js` — `extractCurrentRoute()` resolves `ctx.routePath` through
294
+ `router.resolve()` (a pure match, no navigation) to recover the real path and
295
+ `params.locale`. Off-SSG behavior is unchanged (falls back to
296
+ `router.currentRoute`).
297
+ - `loadConfig.js` — `resolvePageIdForRoute()` no longer treats a path-less
298
+ `404` page as living at `/` (removing the home/404 collision), and maps the
299
+ `/404` SSG route to a custom path-less `404` page so it trims too.
300
+
301
+ Verified on a single-locale site with a custom 404 (every page — incl. home and
302
+ 404 — trims to its own config, ~94% smaller hydration blob) and a 15-locale site
303
+ (base and locale-prefixed content pages trim to their own page; previously all
304
+ embedded home). Inert for sites that don't opt into `trimInitialState`.
305
+
306
+ ## 1.0.0-beta.36
307
+
308
+ ### `cms-ssg-build` — memory-bounded SSG render (Phase 2: the durable fix)
309
+
310
+ The durable fix for the SSG-build heap OOM (see
311
+ [`SSG-MEMORY-PLAN.md`](./SSG-MEMORY-PLAN.md)). vite-ssg renders every route in
312
+ one Node process and leaves a fixed retained cost per route, so peak heap scales
313
+ with total routes (pages × locales) and large sites OOM. This adds an **opt-in**
314
+ `cms-ssg-build` command that renders the routes as **N separate `vite-ssg build`
315
+ processes**, each handling a slice of the routes and writing to its own outDir,
316
+ then merges the shards into `dist/`. Because each shard is its own process,
317
+ memory is reclaimed between shards — **peak heap is bounded by slice size, not
318
+ total route count**, no matter how large the site grows.
319
+
320
+ Adopt it by pointing `build:ssg` at the new command (and dropping the interim
321
+ `--max-old-space-size=8192` flag, which is no longer needed):
322
+
323
+ ```jsonc
324
+ // package.json
325
+ "build:ssg": "cms-ssg-build" // was: NODE_OPTIONS=... vite-ssg build
326
+ "build:ssg": "cms-ssg-build --shard-size 150 --heap 2048" // tunable
327
+ ```
328
+
329
+ Details:
330
+ - **Sequential by default** — one shard live at a time gives the *lowest* peak
331
+ memory (the whole point) and avoids racing on the plugin's shared site-root
332
+ writes. `--concurrency` exists but is experimental.
333
+ - **Small sites are unaffected** — when the route count fits in a single shard,
334
+ it runs one ordinary `vite-ssg build`.
335
+ - **Framework hooks** (env-gated, inert for a normal build): the plugin's
336
+ `includedRoutes` honors `CMS_SSG_SHARD=k/N` (new pure, unit-tested
337
+ `applyRouteShard`; `/404` + `/admin` stay in every shard so each emits its own
338
+ `404.html`), and the client build honors `CMS_SSG_OUTDIR`.
339
+ - **Output fidelity** verified on a real 60-route multi-locale site: the merged
340
+ `dist/` is identical to a normal build **except** for vite-ssg's own per-run
341
+ `modulepreload` chunk-selection non-determinism — two ordinary back-to-back
342
+ `vite-ssg build` runs already differ the same way (same chunks, equivalent
343
+ selection, nothing dropped). Sharding introduces no additional differences.
344
+ - **Trade-off:** each shard rebuilds the bundle, so the build does more total
345
+ work than a single run — an acceptable, opt-in cost for the sites large enough
346
+ to need the bound. A future optimization can build the bundle once and reuse
347
+ it across shards.
348
+
349
+ ## 1.0.0-beta.35
350
+
351
+ ### SSG build memory — Phase 1 mitigations for the per-route heap accumulation
352
+
353
+ Large multi-locale sites began failing the Amplify SSG build with `FATAL ERROR:
354
+ … JavaScript heap out of memory` (exit 134). Profiling traced it to vite-ssg's
355
+ in-process render leaving a fixed retained cost per route (one jsdom document
356
+ survives each page inside vite-ssg's own loop), so peak heap scales with total
357
+ routes = pages × locales. This release ships the low-risk, output-preserving
358
+ mitigations; the durable bound (a sharded/recycled render) is designed in
359
+ [`SSG-MEMORY-PLAN.md`](./SSG-MEMORY-PLAN.md).
360
+
361
+ **Render concurrency default.** The plugin now sets `ssgOptions.concurrency: 8`
362
+ (vite-ssg defaults to 20). Output is unchanged; a smaller pool trims the
363
+ transient working set layered on top of the per-route accumulation and makes
364
+ peak memory more predictable on large sites.
365
+
366
+ **Opt-in `site.trimInitialState`.** With `"trimInitialState": true` in
367
+ `site.json`, each pre-rendered page embeds only its **own** page's config in
368
+ `window.__INITIAL_STATE__` instead of every page's. The full-site config blob is
369
+ byte-identical on every page and, on content-heavy sites, can be the majority of
370
+ the page weight (measured at ~213 KB / 81% of a 44-page site's HTML). The
371
+ rendered HTML body is unchanged — the SSR render resolves each page from its own
372
+ `loadConfigData()` call, not from `initialState` — so this only shrinks the
373
+ serialized hydration blob: an SEO/payload win as well as a smaller per-route
374
+ jsdom footprint. **Default is off** (byte-identical to prior output). When on,
375
+ the client lazy-loads the full config in the background after first paint (soft,
376
+ no flash) so in-SPA navigation to other pages still resolves; the current page
377
+ can never be trimmed away (the route→page match falls back to the full config on
378
+ any ambiguity). New helpers `trimConfigToPage` / `resolvePageIdForRoute` in
379
+ `utils/loadConfig` are pure and unit-tested.
380
+
381
+ **Scaffold heap bridge.** Sites scaffolded via `cms-create-site` now get
382
+ `NODE_OPTIONS=--max-old-space-size=4096` in `build:ssg` as a preventive default,
383
+ so a growing multi-locale site doesn't silently hit Node's ~2 GB ceiling. This
384
+ is a bridge and is intended to be retired once the durable fix lands.
385
+
386
+ ## 1.0.0-beta.34
387
+
388
+ ### Fix: language switcher keeps you on the same page
389
+
390
+ The locale dropdown in the bundled `Header` now preserves the current path when
391
+ switching languages — selecting Español on `/the-wait` goes to `/es/the-wait`
392
+ (previously it bounced to the locale home, `/es`). Slugs are shared across
393
+ locales, so the current path's leading locale segment is stripped and the target
394
+ locale is re-prefixed. Honors `site.trailingSlash`. Homepage behavior is
395
+ unchanged (still `/` ↔ `/{locale}`).
396
+
397
+ ### Fix: `<html lang>` reflects the page locale in prerendered HTML
398
+
399
+ `usePageMeta` now sets `htmlAttrs.lang` during SSR/SSG, so the on-disk HTML for a
400
+ `/{locale}/...` page ships `<html lang="{locale}">` instead of the empty/base
401
+ value (previously the router guard only set it client-side, after hydration).
402
+ Base-locale pages use the configured base locale. Improves accessibility and
403
+ clears `hreflang` / `<html lang>` mismatch warnings in Search Console.
404
+
405
+ ## 1.0.0-beta.33
406
+
407
+ ### Add: opt-in trailing-slash canonical URLs (`site.trailingSlash`)
408
+
409
+ New `site.trailingSlash` boolean in `site.json` (default **off**). When enabled,
410
+ every non-root canonical URL gets a trailing slash — `<link rel="canonical">`,
411
+ `og:url`, `hreflang` alternates, `sitemap.xml` `<loc>`/alternates, breadcrumb
412
+ `item` URLs, and the locale switcher all emit `/about/`, `/de/`, `/de/about/`.
413
+ The root (`/`) is unchanged.
414
+
415
+ This aligns the framework's emitted URLs with how a static host serving nested
416
+ `dirStyle` output (`/about/index.html`) actually serves the page: `/about/` at
417
+ `200`, with `/about` `301`-redirecting to it. Without the flag the canonical
418
+ named the no-slash form, so once the SSG host rules are in place (the `/404.html`
419
+ catch-all from the beta.32-era provisioning template) the canonical pointed at a
420
+ URL that redirects. The flag closes that gap — it matters for multi-page sites
421
+ (per-route landing pages); single-page sites whose only sub-pages are compliance
422
+ boilerplate can stay on the default.
423
+
424
+ Single source of truth: `buildCanonicalUrl` gained a `trailingSlash` option, and
425
+ the four consumers (`usePageMeta`, `sitemapGenerator`, `breadcrumbs`, the
426
+ `Header` locale switcher) read `site.trailingSlash` and thread it through.
427
+ Default (`false`) output is byte-identical to before; a sitemap consistency test
428
+ asserts every non-root URL ends in `/` under the flag.
429
+
430
+ **Pair with the host rules.** `site.trailingSlash` and the SSG Amplify catch-all
431
+ (`/<*>` → `/404.html` `404`, not the SPA `/index.html` rewrite) are a matched
432
+ set — enable them together so the canonical names the URL the host serves at 200.
433
+
434
+ ## 1.0.0-beta.32
435
+
436
+ ### Fix: robots.txt no longer blocks sitemap-listed compliance pages
437
+
438
+ `robotsGenerator` hard-coded `Disallow: /privacy`, `/terms`, `/cookies` as
439
+ framework defaults, while `sitemapGenerator` lists those same compliance pages
440
+ in `sitemap.xml`. Every site therefore advertised the compliance pages for
441
+ indexing (sitemap) while simultaneously forbidding their crawl (robots.txt) —
442
+ surfacing as "Blocked by robots.txt" / "pages in a sitemap" errors in Search
443
+ Console across the fleet on 2026-06-09.
444
+
445
+ The framework default is now `['/admin']` only. `/admin` is non-public and never
446
+ appears in the sitemap, so no contradiction remains. Compliance pages are
447
+ public, footer-linked, emit correct canonicals, and are meant to be indexed —
448
+ they stay crawlable. Draft gating (`site.draft`, `site.draftPaths`, page
449
+ `draft`) is unchanged and still emits its Disallow lines (those pages are also
450
+ omitted from the sitemap, so they stay consistent).
451
+
452
+ A new robots/sitemap consistency invariant test asserts that no URL listed in
453
+ the sitemap can ever be `Disallow`'d by the robots output generated from the
454
+ same config — making this class of contradiction impossible to reintroduce.
455
+
456
+ ## 1.0.0-beta.31
457
+
458
+ ### Fix: White-flash on page load across all sites (synchronous first client render)
459
+
460
+ Every page rendered its body through `usePageConfig`, which on the client fired
461
+ its config load **un-awaited**. Because page content is loaded via lazy
462
+ `import.meta.glob` dynamic-import chunks, the first client render produced an
463
+ empty `<main>` that replaced the prerendered HTML until the async load resolved
464
+ — a visible paint → blank → paint flash on every page, worst on slower (mobile)
465
+ CPUs.
466
+
467
+ The server-resolved config is already serialized into `initialState.siteConfig`
468
+ by vite-ssg. We now bridge it into a synchronous cache (`primeConfigSync` /
469
+ `peekConfigSync` in `loadConfig.js`, primed from `main.js` before mount) that
470
+ `usePageConfig` reads on its first client render (`hydrateFromSyncCache`), so
471
+ that render reproduces the prerendered DOM instead of blanking. A returning
472
+ visitor's saved `localStorage` locale is preserved via a soft post-render
473
+ reconcile; a cache miss falls back to the previous async path.
474
+
475
+ Verified on a real SSG build of `site-erea`: the rendered `#app` content held at
476
+ ~99% of its prerendered size throughout mount (vs. collapsing to ~0.3% on
477
+ beta.30) — flash eliminated on desktop and mobile, console clean.
478
+
479
+ ### Fix: Scaffold template `.env.example` swallowed by `.env*` gitignore
480
+
481
+ The site scaffolder copies `templates/scaffolds/site/.env.example`, but the
482
+ broad `.env*` rule in `.gitignore` meant the template was never committed.
483
+ Fresh clones / CI lacked it, so `cms-create-site` produced an incomplete
484
+ scaffold and the scaffold test failed off a clean checkout. Added a `.gitignore`
485
+ negation and committed the placeholder.
486
+
487
+ No site-side migration needed — bump cms and rebuild.
488
+
489
+ Tests: 596 passing.
490
+
491
+ ## 1.0.0-beta.30
492
+
493
+ ### Chore: minor dependency sweep
494
+
495
+ Targeted bumps within existing semver ranges — no API or behavior change.
496
+
497
+ - `@unhead/vue` 2.0.19 → 2.1.15
498
+ - `ajv` 8.17.1 → 8.20.0
499
+ - `@vue/test-utils` 2.4.6 → 2.4.10 (dev)
500
+ - `happy-dom` 20.8.9 → 20.9.0 (dev)
501
+ - `vitest` 4.1.4 → 4.1.6 (dev)
502
+ - `vite` 8.0.8 → 8.0.13 (lockfile, peer)
503
+ - `@vitejs/plugin-vue` 6.0.5 → 6.0.7 (lockfile, peer)
504
+ - `vue` 3.5.32 → 3.5.34 (lockfile, peer; added to devDependencies to pin dev-time install)
505
+ - `vue-router` 5.0.4 → 5.0.7 (lockfile, peer; same reason)
506
+
507
+ Two majors deferred for a separate release: `@unhead/vue` 3.x (touches `usePageMeta`,
508
+ SEO emission) and `sharp` 0.34.x (touches the image-variants pipeline).
509
+
510
+ Tests: 588 passing.
511
+
512
+ No site-side migration needed — bump cms and rebuild.
513
+
514
+ ## 1.0.0-beta.29
515
+
516
+ ### Fix: Hero background image silently lost during Vue's :style normalization
517
+
518
+ `usePromoBackgroundStyles` was emitting a string with two `background-image`
519
+ declarations — a `url()` fallback first, then an `image-set()` upgrade with
520
+ format negotiation. The pattern is the standard CSS progressive-enhancement
521
+ chain: browsers that can parse `image-set()` (with `type()` argument) use it;
522
+ browsers that can't fall through to the `url()`. **But Vue 3's `:style`
523
+ parses string bindings into a normalized object keyed by property name,
524
+ collapsing duplicate `background-image` declarations to a single entry —
525
+ only the last one (the `image-set()`) survives.** The fallback chain was
526
+ silently truncated.
527
+
528
+ Symptom: hero background images missing in any browser that can't fully
529
+ parse `image-set()` with the `type()` argument. Browsers that can parse it
530
+ saw the bg fine — but a partial-parse failure (rare but possible across
531
+ older Safari, some embedded WebViews) left the section bg-less, falling
532
+ through to the surface color.
533
+
534
+ **Fix:** the composable now returns an OBJECT with two CSS custom
535
+ properties (`--promo-bg-fallback` and `--promo-bg-set`) instead of a string.
536
+ Vue keeps both as distinct entries. `Hero.vue`'s `.promo-surface` rule
537
+ declares `background-image` twice — once with each var — so browsers walk
538
+ the natural CSS cascade. The first declaration always parses; the second
539
+ overrides only when the browser supports `image-set()`.
540
+
541
+ Tests: 588 passing (8 in `usePromoBackgroundStyles.spec.js` rewritten for
542
+ the new object contract).
543
+
544
+ No site-side migration needed — bump cms and rebuild.
545
+
546
+ ## 1.0.0-beta.28
547
+
548
+ ### Fix: pipeline no longer generates content-duplicate variants for small sources
549
+
550
+ beta.27's in-plugin pipeline still asked sharp to generate every
551
+ configured width per source, relying on `withoutEnlargement: true` to
552
+ clamp oversize requests. The clamping worked, but produced
553
+ **content-identical files** for every width above the source's actual
554
+ width: a 1920×1080 source asked for at widths [1920, 2560] generated
555
+ two byte-identical files. Vite content-hashes assets and emits ONE
556
+ physical file under one of the names — but the asset URL map still
557
+ registers entries for every width's import path. Result: requests for
558
+ `/assets/promo-2560-{hash}.jpg` 404 because Vite emitted that content
559
+ under `/assets/promo-1920-{hash}.jpg` instead.
560
+
561
+ Symptoms: hero background images missing on sites whose source promo
562
+ image is smaller than the largest configured width. We hit it on most
563
+ of the migrated sites once they bumped to beta.27 — the placeholder
564
+ promo.jpg files were 72×72.
565
+
566
+ **Fix:** the reconcile orchestrator reads each source's dimensions
567
+ before rendering and skips outputs whose requested width exceeds
568
+ source.width. The pipeline naturally falls back to the bare
569
+ `img/{name}.{ext}` URL (which exists because the original lives in the
570
+ flat dir) for sources too small to generate any variant. Stale
571
+ oversize variants from a previous run get evicted from the cache.
572
+
573
+ Also: the cache-up-to-date check now compares against the manifest's
574
+ recorded `variants` list rather than the planner's full output set.
575
+ This prevents tiny-source sites from re-rendering on every build (the
576
+ old check thought a missing variant meant we needed to retry).
577
+
578
+ Tests: 586 passing (3 added: skip-oversize-widths, only-fulfillable-widths,
579
+ no-thrash-on-tiny-source).
580
+
581
+ No site-side migration needed — bump cms and rebuild.
582
+
583
+ ## 1.0.0-beta.27
584
+
585
+ ### Image-variant pipeline moves into the Vite plugin (BREAKING for sites)
586
+
587
+ The variant generation that was a separate `cms-generate-image-variants`
588
+ build step is now part of the Vite plugin's lifecycle. Variants generate
589
+ into `node_modules/.cache/@koehler8/cms/image-variants/{siteKey}/` at
590
+ build/dev time, get picked up by a third `import.meta.glob` in the
591
+ asset-resolver virtual module, and surface through Vite's normal asset
592
+ pipeline (hashed, emitted to `dist/assets/`). Source trees no longer
593
+ contain the `_source/` directory or any committed variant files.
594
+
595
+ Why: beta.22 introduced `_source/` as a separation between authored
596
+ originals and generated variants. beta.26 patched the resulting
597
+ `resolveAsset('img/logo.png')` breakage by also auto-copying originals
598
+ to the flat dir — leaving byte-identical duplicates and ~hundreds of
599
+ committed variant files per site. beta.27 eliminates both: originals
600
+ live in `site/assets/img/` directly (one canonical location), variants
601
+ are pure build artifacts that never touch the source tree.
602
+
603
+ **Breaking changes:**
604
+
605
+ - **`cms-generate-image-variants` CLI is removed.** Sites with
606
+ `npm run generate:image-variants` in their `amplify.yml` or
607
+ `package.json` will fail at the bare-command lookup until they
608
+ remove those references.
609
+ - **The `_source/` convention is deprecated.** The pipeline reads only
610
+ from flat `site/assets/img/`. Sites that still have files only in
611
+ `_source/` will see broken image URLs (the plugin emits a loud warning
612
+ with migration instructions on every build).
613
+
614
+ **Migration: run `npx @koehler8/cms migrate-image-variants` from each
615
+ site directory.** It promotes `_source/` files to flat dir, deletes the
616
+ beta.26 auto-copy duplicates, untracks committed variants, drops the
617
+ script entries from `package.json` and `amplify.yml`, bumps the cms
618
+ range to `^1.0.0-beta.27`, and runs `npm install`. Idempotent — safe
619
+ to re-run.
620
+
621
+ **End state per site:**
622
+ - Source tree: `site/assets/img/logo.png`, `site/assets/img/hero.jpg`. That's it.
623
+ - No `_source/` directory.
624
+ - No committed variant files.
625
+ - No `generate:image-variants` script.
626
+ - No preBuild step in `amplify.yml`.
627
+
628
+ **Architectural notes:**
629
+
630
+ - `buildStart` hook generates variants into the cache dir. Mtime-skip
631
+ cache; orphan eviction; manifest-tracked. Cache survives between the
632
+ vite-ssg double build (SSR + client passes) so the second build is
633
+ stat-only.
634
+ - The cache mirrors the site's `assets/` layout (`{cacheDir}/assets/img/...`),
635
+ so the existing `normalizeAssetKey` strips the same `assets/` prefix
636
+ and surfaces variants under canonical `img/...` keys. No resolver
637
+ code change needed.
638
+ - Custom widths/formats/quality remain configurable via `site.imageVariants`
639
+ in `site.json`. Defaults unchanged: 6 widths × 3 formats per source.
640
+ - Sharp is lazy-imported inside the reconcile orchestrator — only loads
641
+ when there's actual rendering work, so cold dev-mode start with a
642
+ warm cache pays no sharp cost.
643
+ - Dev mode: a debounced watcher on `site/assets/img/**` re-runs the
644
+ pipeline and triggers a full-reload when source images change. Drop
645
+ a new image, refresh — variants materialize automatically.
646
+ - The classifier distinguishes originals from variant-shaped files by
647
+ checking whether the trailing width number is in the configured
648
+ widths set. So `team-2024.jpg` (2024 not in defaults) is an original;
649
+ `logo-320.webp` is a variant. Edge case escape: rename source files
650
+ that conflict with the variant pattern.
651
+
652
+ Tests: 584 passing (45 new for the lifted pipeline modules + classifier;
653
+ 19 obsolete tests for the old `_source/`-based pipeline removed).
654
+
655
+ ## 1.0.0-beta.26
656
+
657
+ ### Fix: image-variant pipeline now also copies the source to the flat dir
658
+
659
+ beta.22 introduced `cms-generate-image-variants`: drop originals at
660
+ `site/assets/img/_source/{name}.{ext}` and the script generates
661
+ `{name}-{width}.{format}` variants back into `site/assets/img/`. Sites
662
+ that adopted the convention discovered a knock-on breakage: every call
663
+ site that consumed an asset by its bare path — `resolveAsset('img/logo.png')`
664
+ in the bundled `Header`, the `${basePath}.${format}` fallback inside
665
+ `useResponsiveImage`, custom site components calling `resolveAsset` on
666
+ `'img/hero.jpg'` directly — started 404'ing because the bare file no
667
+ longer existed in the flat dir. The variant URLs all worked, but every
668
+ bare-path lookup was broken until each site manually maintained a
669
+ duplicate copy of the original at the flat path.
670
+
671
+ **Fix:** the pipeline now writes a byte-identical copy of each source
672
+ file to the flat dir alongside its variants, preserving the source's
673
+ filename and extension. So `_source/logo.png` → `logo.png` (in flat dir)
674
+ plus `logo-{w}.{avif,webp,jpg}` variants. mtime-skipping applies to the
675
+ copy too — re-runs only re-copy when the source has changed.
676
+
677
+ This restores backward compatibility for `resolveAsset('img/{name}.{ext}')`
678
+ call sites and gives `useResponsiveImage`'s no-suffix fallback a real
679
+ file to land on for sources that are smaller than every configured
680
+ width (which would otherwise produce content-identical clamped variants
681
+ that Vite asset-deduplicates by hash, breaking variant URLs).
682
+
683
+ **No site-side migration needed beyond bumping `@koehler8/cms` and
684
+ re-running `npm run generate:image-variants`.** The first re-run after
685
+ the bump produces the flat-dir copies; the next deploy serves them.
686
+
687
+ Tests: 558 passing (1 added for the copy-byte-identicality guarantee).
688
+
689
+ ## 1.0.0-beta.25
690
+
691
+ ### Fix: switching back to base locale didn't actually switch
692
+
693
+ beta.24 made the Header dropdown link the base locale to `/` instead of
694
+ `/{baseLocale}`. The URL changed correctly, but the page rendered in the
695
+ *previously selected* locale. Two interlocking causes:
696
+
697
+ 1. **`Header.handleLocaleLinkClick` didn't write `localStorage.locale`.**
698
+ `loadConfigData` falls back to `localStorage.getItem('locale')` when
699
+ no explicit locale param is present. Navigating to `/` (the base-
700
+ locale URL) provides no locale, so `loadConfigData` read the stale
701
+ `'de'` (or whatever the previous locale was) from localStorage and
702
+ loaded the wrong config tree. The router's `applyRouterGuards` only
703
+ wrote localStorage when a `:locale` route param matched — base-locale
704
+ navigation never matched, so the storage went unwritten.
705
+ 2. **Router auto-redirect bounced back to the prior locale.** The
706
+ guard's "if user lands on `/` and browser language is `de`, redirect
707
+ to `/de`" detection ran on every navigation, not just first-time
708
+ visits. Clicking "EN" on `/de` navigated to `/`, the guard saw the
709
+ browser was set to German, redirected back to `/de`, and the user
710
+ never saw English.
711
+
712
+ **Fixes:**
713
+
714
+ - `Header.handleLocaleLinkClick` now writes `localStorage.setItem('locale',
715
+ locale)` for every dropdown click, including the base locale. The
716
+ user's explicit choice persists across navigation.
717
+ - `applyRouterGuards`'s browser-language auto-detect early-returns when
718
+ any `localStorage.locale` value is already set. The auto-redirect runs
719
+ only on truly-fresh visits with no stored preference.
720
+
721
+ Tests: 557 still passing.
722
+
723
+ ## 1.0.0-beta.24
724
+
725
+ ### Fix: Header locale dropdown sent base-locale clicks to a 404
726
+
727
+ After beta.19's URL-space cleanup, `/en` (when `en` is the base locale)
728
+ no longer pre-renders or routes — the canonical URL is `/`. But the
729
+ bundled Header's locale dropdown still hardcoded its links to
730
+ `/${locale}` for every option, so clicking "EN" on a multi-locale site
731
+ landed on a 404. Reported on coastalcollective.life after its beta.23
732
+ bump restored the dropdown.
733
+
734
+ **Two fixes:**
735
+
736
+ 1. `Header.vue` now builds the locale link via `localeHref(locale)`:
737
+ the base locale points at `/`, others at `/{locale}`. Reads
738
+ `baseLocale` from the `loadConfig` singleton (already populated by
739
+ the vite-plugin's virtual config module since beta.19).
740
+ 2. `applyRouterGuards` adds a beforeEach guard that catches in-SPA
741
+ navigation to `/{baseLocale}/...` and rewrites to the canonical
742
+ unprefixed path. So even if a component or external link inside the
743
+ site tries to go to `/en/about`, it lands on `/about` instead of the
744
+ 404 page.
745
+
746
+ **Direct external hits to `/{baseLocale}/...` URLs still 404** — the
747
+ SSG doesn't pre-render those paths and the SPA can only redirect
748
+ client-side after the page boots. For sites with inbound external
749
+ links to old `/{baseLocale}/*` URLs, add an Amplify customRule:
750
+
751
+ ```
752
+ Source: /<baseLocale>/<*> Target: /<*> Status: 301
753
+ ```
754
+
755
+ (e.g. for an English-base site: `/en/<*>` → `/<*>`.) Add it in the
756
+ Amplify console under "Rewrites and redirects". Documented in
757
+ `CLAUDE.md` "URL hygiene".
758
+
759
+ Tests: 557 still passing.
760
+
761
+ ## 1.0.0-beta.23
762
+
763
+ ### Fix: locale discovery missed translation-only locale dirs
764
+
765
+ beta.19 introduced on-disk locale discovery to replace the
766
+ hardcoded-15-locales fanout. The check required `site.json` to be
767
+ present in each locale dir. That was overly strict: many sites author
768
+ per-locale page overrides (`pages/home.json` etc.) and a per-locale
769
+ `shared.json` without overriding the site metadata, since the brand
770
+ name typically stays the same across languages. Those locales were
771
+ silently dropped from `availableLocales` after beta.19, which made:
772
+
773
+ - The bundled `Header` locale dropdown disappear (it hides when
774
+ `availableLocales.length <= 1`).
775
+ - Per-locale URLs stop pre-rendering.
776
+ - `hreflang` alternates collapse to a single locale.
777
+
778
+ `coastalcollective.life` exhibited all three after its beta.22 bump.
779
+
780
+ **Fix:** discovery now treats a locale directory as present if any of
781
+ `site.json`, `shared.json`, or a `pages/` subdir exists — matching the
782
+ pre-beta.19 glob-based behavior. Sites with the translation-only
783
+ scaffolding pattern (no per-locale `site.json`) start being recognized
784
+ again with no per-site changes.
785
+
786
+ Tests: 557 still passing. Verified end-to-end on a coastal-pattern
787
+ fixture (locale dirs with `shared.json` + `pages/home.json`, no
788
+ `site.json`): all locales discovered, pre-rendered, and hreflang
789
+ alternates emit correctly.
790
+
791
+ ## 1.0.0-beta.22
792
+
793
+ ### Image-variant pipeline + auto-generated breadcrumb JSON-LD
794
+
795
+ **Image-variant pipeline (Tier-3 #1)**
796
+
797
+ `useResponsiveImage` has been wired into the bundled components for ages,
798
+ but the build never produced the variant files it expects. Sites were
799
+ shipping a single full-size JPEG and `srcset` rendered empty. New script
800
+ fixes that by generating the matrix at build time.
801
+
802
+ - New `scripts/generate-image-variants.js` + `bin/cms-generate-image-variants.js`.
803
+ - New convention: drop originals at `site/assets/img/_source/{name}.{ext}`.
804
+ The script reads them and writes `{name}-{width}.{format}` variants
805
+ back to `site/assets/img/`. Subdirectory layout is preserved.
806
+ - Default matrix: widths `[320, 640, 960, 1280, 1920, 2560]` × formats
807
+ `[avif, webp, jpg]` = 18 variants per source. Configurable via
808
+ `site.imageVariants.{widths, formats, quality}`.
809
+ - Sources smaller than a requested width are clamped (no enlargement).
810
+ - mtime-based skipping: re-running the script only regenerates variants
811
+ whose source has changed since the last run.
812
+ - Powered by [`sharp`](https://sharp.pixelplumbing.com/) (new dependency).
813
+ Native binaries prebuilt for darwin/linux x64+arm64 — no system
814
+ libraries to install on AWS Amplify.
815
+
816
+ **Wire-up in consumer sites** (one-time):
817
+
818
+ 1. Move existing flat `site/assets/img/*.{jpg,png}` originals into
819
+ `site/assets/img/_source/`.
820
+ 2. Add to `package.json` scripts:
821
+ `"generate:image-variants": "cms-generate-image-variants --site-dir ./site"`.
822
+ 3. Add to `amplify.yml` preBuild commands:
823
+ `npm run generate:image-variants` (before `generate:public-assets`
824
+ and `build:ssg`).
825
+ 4. First build generates the matrix; subsequent builds reuse it.
826
+
827
+ **Auto-generated breadcrumb JSON-LD (Tier-3 #6)**
828
+
829
+ `usePageMeta` now emits a `BreadcrumbList` schema for any non-home,
830
+ non-draft, non-404 page with `site.url` set. Drives Google's breadcrumb
831
+ display in search results without authors having to write the schema.
832
+
833
+ - New `src/utils/breadcrumbs.js` (pure helper).
834
+ - Path → segment list → `ListItem` entries. First entry is always
835
+ `Home` linked at the canonical home URL; subsequent entries are each
836
+ prefix path of the current URL.
837
+ - **Label resolution priority** per intermediate path:
838
+ 1. If a page exists at that path, use its `meta.title`.
839
+ 2. Otherwise, format the slug (`our-team` → `Our Team`).
840
+ - The leaf entry (current page) DOES include its `item` URL — Google
841
+ accepts both forms; including is more interoperable.
842
+ - **Skipped on**: home, drafts, 404s, missing `site.url`, single-locale
843
+ home page with `/` path.
844
+ - **Per-page opt-out**: `pages/{id}.json` `meta.breadcrumbs: false`.
845
+ - Locale-prefixed URLs: non-base locales render breadcrumb item URLs
846
+ with the `/{locale}/...` prefix; the path-segment hierarchy itself
847
+ doesn't include the locale prefix as a step.
848
+
849
+ **New files:**
850
+
851
+ - `scripts/generate-image-variants.js` — orchestrator + sharp pipeline
852
+ - `bin/cms-generate-image-variants.js` — CLI wrapper
853
+ - `src/utils/breadcrumbs.js` — `buildBreadcrumbList(...)`
854
+ - `tests/scripts/generate-image-variants.spec.js` (18 tests, fixture-driven)
855
+ - `tests/utils/breadcrumbs.spec.js` (17 tests)
856
+
857
+ **Edits:**
858
+
859
+ - `package.json` — add `sharp ^0.33.5` dep + new bin entry
860
+ - `src/composables/usePageMeta.js` — appends BreadcrumbList JSON-LD after
861
+ any author-supplied `jsonld` blocks. Authors can override by emitting
862
+ their own hand-authored `BreadcrumbList` in `pages/{id}.json` `jsonld`.
863
+
864
+ **Caveats:**
865
+
866
+ - **Migration is mandatory** for the image pipeline to actually do
867
+ anything. Sites that don't move originals into `_source/` keep
868
+ shipping single-size JPEGs (no regression — `useResponsiveImage`
869
+ already falls back gracefully — but no improvement either).
870
+ - **AVIF encoding is slow.** A 2560-wide AVIF can take 1–3 seconds per
871
+ source. Run on developer machines or CI before pushing; don't
872
+ regenerate on every Amplify build (mtime-skip handles repeat runs).
873
+ - **`sharp`'s install size** is ~50MB. Acceptable trade for the format
874
+ support (`canvas`-based generation can't produce AVIF).
875
+ - **Breadcrumb schema is opt-out, not opt-in.** Sites that don't want
876
+ breadcrumb display in Google should set `meta.breadcrumbs: false` on
877
+ pages where it's misleading (rare).
878
+
879
+ Tests: 557 passing (18 new for image-variants, 17 for breadcrumbs, 8
880
+ added to usePageMeta).
881
+
882
+ ## 1.0.0-beta.21
883
+
884
+ ### Per-page JSON-LD, Web App Manifest, and Search Console verification hooks
885
+
886
+ Three Tier-2 SEO/PWA additions, all opt-in and additive:
887
+
888
+ **Per-page JSON-LD structured data**
889
+
890
+ - New `src/utils/jsonLd.js` builds `<script type="application/ld+json">`
891
+ blocks from per-page and per-site `jsonld` keys.
892
+ - Authors add either an object or an array of objects in `pages/{id}.json`:
893
+
894
+ ```json
895
+ "jsonld": {
896
+ "@type": "RealEstateAgent",
897
+ "name": "Jamie Austin",
898
+ "telephone": "+1-310-806-3199",
899
+ "image": "https://ocandme.com/img/jamie.jpg"
900
+ }
901
+ ```
902
+
903
+ or several:
904
+
905
+ ```json
906
+ "jsonld": [
907
+ { "@type": "Article", "headline": "..." },
908
+ { "@type": "BreadcrumbList", "itemListElement": [ ... ] }
909
+ ]
910
+ ```
911
+
912
+ - Site-wide additions (e.g. a `WebSite` with sitelinks-search-box) live
913
+ under `site.jsonld` in `site.json`. Page blocks append after site blocks.
914
+ - `@context` is auto-injected with `https://schema.org` if the author
915
+ omits it.
916
+ - Drafts and 404 pages skip JSON-LD entirely (gated/missing content
917
+ shouldn't make schema.org claims).
918
+
919
+ **Web App Manifest (`/manifest.json`)**
920
+
921
+ - The plugin's `writeSeoFiles` now generates `siteRoot/public/manifest.json`
922
+ from `site.title`, `site.description`, and an optional `site.manifest`
923
+ block. Defaults work out of the box: `start_url=/`, `display=standalone`,
924
+ fallback theme/background `#ffffff`, icons referencing
925
+ `/favicon-256.png` and `/favicon.ico` (both written by
926
+ `cms-generate-public-assets`).
927
+ - Per-site overrides via `site.manifest.{name, shortName, themeColor,
928
+ backgroundColor, startUrl, display, lang, description, icons[]}`.
929
+ - `templates/index.html` now emits `<link rel="manifest"
930
+ href="/manifest.json">` and `<meta name="theme-color" content="...">` so
931
+ iOS/Android Add-to-Home-Screen works and the mobile chrome bar takes
932
+ the brand color.
933
+
934
+ **Search Console / webmaster verification meta**
935
+
936
+ - New `site.siteVerification` object in `site.json` keyed by platform:
937
+
938
+ ```json
939
+ "siteVerification.google": "abc123token",
940
+ "siteVerification.bing": "...",
941
+ "siteVerification.pinterest": "...",
942
+ "siteVerification.facebook": "...",
943
+ "siteVerification.yandex": "..."
944
+ ```
945
+
946
+ - `usePageMeta` emits one `<meta name="...">` per token using the
947
+ platform's standard meta name (`google-site-verification`,
948
+ `msvalidate.01`, `p:domain_verify`, `facebook-domain-verification`,
949
+ `yandex-verification`).
950
+ - Empty/whitespace tokens and unknown platforms are silently skipped.
951
+ - Emitted on every page (verification platforms typically only check
952
+ homepage; emitting site-wide is harmless and matches industry default).
953
+
954
+ **New files:**
955
+
956
+ - `src/utils/jsonLd.js` — `buildJsonLdScripts(...)` (12 tests)
957
+ - `src/utils/webAppManifest.js` — `buildWebAppManifest(...)` (15 tests)
958
+ - `tests/utils/jsonLd.spec.js`, `tests/utils/webAppManifest.spec.js`
959
+
960
+ **Edits:**
961
+
962
+ - `src/composables/usePageMeta.js` — now also emits `script: [...]` for
963
+ JSON-LD and additional verification `<meta>` tags via `useHead`.
964
+ - `vite-plugin.js` — `writeSeoFiles` writes `manifest.json` alongside
965
+ `robots.txt` and `sitemap.xml`. `extractSiteMetadata` exposes
966
+ `manifestThemeColor` for the template.
967
+ - `templates/index.html` — adds `<link rel="manifest">` and
968
+ `<meta name="theme-color">`.
969
+
970
+ **Caveats:**
971
+
972
+ - **Theme color in `<meta name="theme-color">` is static per build** (read
973
+ from `site.manifest.themeColor`). It doesn't auto-derive from theme
974
+ tokens — sites should set it explicitly to match their brand.
975
+ - **No JSON-LD validation.** Whatever the author writes is emitted as-is.
976
+ Use Google's Rich Results Test to validate before relying on rich
977
+ results.
978
+ - **PWA-installable scope.** Just shipping `manifest.json` doesn't make
979
+ the site fully PWA-installable; that requires a service worker, which
980
+ is out of scope. The manifest covers what most marketing sites need
981
+ (Add to Home Screen, splash colors, app-like icon).
982
+
983
+ Tests: 515 passing (12 new for `jsonLd`, 15 for `webAppManifest`, 9
984
+ added to `usePageMeta` for JSON-LD + verification).
985
+
986
+ ## 1.0.0-beta.20
987
+
988
+ ### Per-page Open Graph + Twitter Card meta and a real 404 page
989
+
990
+ Two SEO/UX gaps closed in one release:
991
+
992
+ 1. **Per-page link previews**: every page now emits its own `og:title`,
993
+ `og:description`, `og:url`, `og:image`, `og:type`, `og:site_name`,
994
+ plus the matching `twitter:*` tags. Sharing `/about` to iMessage,
995
+ Slack, X, etc. now shows the page-specific card instead of the
996
+ site-wide one. Sites/pages can override the image and og:type per
997
+ page via `meta.image` / `meta.ogType` / `meta.twitterCard`.
998
+ 2. **Real 404 page**: unknown URLs render a bundled `NotFound`
999
+ component with HTTP 404 (via Amplify's auto-served `dist/404.html`)
1000
+ instead of silently rendering the home page with HTTP 200.
1001
+
1002
+ **Schema additions** (all optional):
1003
+
1004
+ - `pages/{id}.json`
1005
+ - `meta.image` — per-page OG/Twitter image (relative path or absolute
1006
+ URL). Falls back to `site.image` then to the static `/og-image.jpg`.
1007
+ - `meta.ogType` — overrides default `"website"` (e.g. `"article"`).
1008
+ - `meta.twitterCard` — overrides default `"summary_large_image"`.
1009
+ - `site.json`
1010
+ - `image` (or `ogImage`) — site-wide OG image fallback when a page
1011
+ doesn't set its own.
1012
+ - `pages/404.json` (optional) — a custom 404 page. If present,
1013
+ `selectPage` resolves to it on no-match. Its `components[]` can list
1014
+ Header/Footer/etc. for full chrome. If absent, a synthesized sentinel
1015
+ renders just the bundled `NotFound` component (no chrome).
1016
+ - `shared.json` / `pages/404.json` `content.notFound`
1017
+ - `heading` — defaults to "Page not found"
1018
+ - `body` — defaults to "We couldn't find the page you're looking for."
1019
+ - `homeLabel` — defaults to "Return home"
1020
+ - `homeHref` — defaults to "/"
1021
+
1022
+ **Behavior:**
1023
+
1024
+ - `usePageMeta` now produces `<title>`, `<meta name="description">`,
1025
+ `<meta property="og:*">`, `<meta name="twitter:*">`, `<link
1026
+ rel="canonical">`, and `<link rel="alternate" hreflang>` per page.
1027
+ The static `templates/index.html` no longer hardcodes any of those
1028
+ (was previously: `<title>` + meta description + 9 OG/Twitter tags
1029
+ rendered with site-wide constants on every page). The site-wide JSON-LD
1030
+ Organization snippet stays in the template — that's per-page invariant.
1031
+ - 404 pages get `<title>Page not found — {Site}</title>`, empty
1032
+ description, `<meta name="robots" content="noindex, nofollow">`, no
1033
+ canonical, no hreflang, no OG/Twitter (the URL the visitor typed
1034
+ shouldn't generate a misleading link card if shared).
1035
+ - The vite-plugin pre-renders `/404` (via `staticRoutes`) and copies
1036
+ `dist/404/index.html` → `dist/404.html` after rendering, in
1037
+ `ssgOptions.onFinished`. AWS Amplify auto-serves the top-level
1038
+ `404.html` for unmatched URLs with HTTP 404 status — no per-site
1039
+ config needed.
1040
+ - `usePageConfig.selectPage` returns a not-found sentinel
1041
+ (`{ id: '__not_found__', isNotFound: true, components: [{ name:
1042
+ 'NotFound' }] }`) when the requested path doesn't match any page and
1043
+ isn't `/`. Sites can override by authoring `pages/404.json`. The
1044
+ previous behavior — "fall through to home content with HTTP 200" — is
1045
+ gone. **This is a breaking change in observable behavior**:
1046
+ previously, hitting `/this-doesnt-exist` rendered the home page; now
1047
+ it renders a 404. Any site that depended on the old behavior should
1048
+ audit (rare; the old behavior was almost always wrong for SEO).
1049
+
1050
+ **New files:**
1051
+
1052
+ - `src/utils/socialMeta.js` — `buildSocialMeta(...)` returns the
1053
+ per-page OG/Twitter meta entries. Pure; tested in isolation.
1054
+ - `src/components/NotFound.vue` — bundled fallback rendered for the
1055
+ `__not_found__` sentinel. Theme-token styled, WCAG 2.2 AA (real
1056
+ heading hierarchy, focus-visible, prefers-reduced-motion, ≥24×24 hit
1057
+ area on the home link).
1058
+ - `tests/utils/socialMeta.spec.js` — 19 cases covering the meta-tag
1059
+ emission paths.
1060
+
1061
+ **Edits:**
1062
+
1063
+ - `src/composables/usePageMeta.js` — extended with OG/Twitter emission
1064
+ via `buildSocialMeta` and `isNotFound` handling. Drafts already
1065
+ skipped canonical/hreflang; now they (and 404s) also skip OG/Twitter.
1066
+ - `src/composables/usePageConfig.js` — `selectPage` returns a
1067
+ `__not_found__` sentinel for any non-root path that doesn't match a
1068
+ page. Sites can author `pages/404.json` to customize.
1069
+ - `vite-plugin.js` — adds `/404` to `includedRoutes`; new
1070
+ `ssgOptions.onFinished` callback copies `dist/404/index.html` →
1071
+ `dist/404.html` after pages are rendered.
1072
+ - `templates/index.html` — removes the EJS-rendered `<title>`,
1073
+ `<meta description>`, og:*, and twitter:* lines. Adds an inline
1074
+ comment noting that those are now per-page emissions.
1075
+ - `tests/composables/usePageMeta.spec.js` — adds 8 OG/Twitter cases
1076
+ + 7 NotFound cases.
1077
+
1078
+ Tests: 471+ passing (19 new for socialMeta, 8 added to usePageMeta for
1079
+ OG/Twitter, 7 for 404 handling).
1080
+
1081
+ **Caveats:**
1082
+
1083
+ - **HTTP 404 status depends on the host.** The framework writes
1084
+ `dist/404.html`; making it serve with a 404 status code is the host's
1085
+ job. AWS Amplify does this automatically when `404.html` exists at
1086
+ the root of the deployed site. Other hosts (Netlify, Cloudflare
1087
+ Pages, S3) need their own configuration — document per host as needed.
1088
+ - **Sites that customize `templates/index.html`** (rare — there's no
1089
+ documented way) lose the site-wide OG/Twitter site-wide fallback.
1090
+ In practice no consumer site does this; the template is internal.
1091
+
1092
+ ## 1.0.0-beta.19
1093
+
1094
+ ### Canonical URLs — collapse the URL space to one per page
1095
+
1096
+ Pages used to render at multiple URLs (`/about`, `/en/about`, `/de/about`,
1097
+ …), all serving identical content. This release picks one canonical URL
1098
+ per page, stops emitting the duplicates, and adds the SEO link tags that
1099
+ let crawlers handle the remaining edge cases. Wins: cleaner SEO,
1100
+ smaller `dist/` for sites that scaffolded extra locales, no more
1101
+ duplicate-content risk in Search Console.
1102
+
1103
+ **URL space rule (matches Google / Stripe / GitHub conventions):**
1104
+
1105
+ - Base locale → `/path` (no prefix).
1106
+ - Non-base locales actually populated on disk → `/{locale}/path`.
1107
+ - Trailing slash → no slash; recommended Amplify 301 (see "URL hygiene"
1108
+ in `CLAUDE.md`).
1109
+
1110
+ **Breaking changes:**
1111
+
1112
+ - **URL space**: `/{baseLocale}/path` (e.g. `/en/about` on a US site)
1113
+ no longer pre-renders, no longer routes via the locale layout, and is
1114
+ no longer listed in `sitemap.xml`. Direct hits fall into the SPA
1115
+ catch-all → home fallback (HTTP 200) until the separate 404-page
1116
+ feature lands. Sites with inbound external links to old URLs should
1117
+ add an Amplify 301 rule from `/<baseLocale>/<*>` → `/<*>`.
1118
+ - **SSG output**: non-base locale URLs (`/de/about`, etc.) are emitted
1119
+ only when `site/content/{locale}/site.json` exists. Empty locale dirs
1120
+ no longer produce phantom localized routes. Expect `dist/` to shrink
1121
+ for any site that scaffolded all 15 supported locales without filling
1122
+ them in.
1123
+ - **Plugin API**: the `locales` plugin option is removed (auto-discovered
1124
+ from disk now). Verified by grep — no consumer site passes it.
1125
+
1126
+ **Added:**
1127
+
1128
+ - `<link rel="canonical" href="...">` per page in the rendered `<head>`.
1129
+ Drafts skip it (gated content shouldn't advertise an authoritative URL).
1130
+ Sites without a configured `site.url` skip it (can't build absolute).
1131
+ - `<link rel="alternate" hreflang="...">` per available locale plus
1132
+ `x-default` for multi-locale sites. Single-locale sites emit none.
1133
+ - `<xhtml:link rel="alternate">` annotations in `sitemap.xml` for
1134
+ multi-locale sites. Single-locale output is byte-identical to before.
1135
+ - New `src/utils/canonicalUrl.js` — single source of truth for the URL
1136
+ formula, used by `usePageMeta`, `sitemapGenerator`, and any future
1137
+ consumer that needs an absolute canonical URL.
1138
+ - New exports from `virtual:cms-config-loader` and the `loadConfig.js`
1139
+ singleton: `baseLocale` (the active site's base locale, build-time
1140
+ string literal). `availableLocales` is now also a build-time literal
1141
+ derived from on-disk content discovery (was previously discovered from
1142
+ Vite glob keys).
1143
+ - New helper `buildRoutes(localePrefixes)` exported from
1144
+ `src/router/index.js` — used by tests to validate the locale regex
1145
+ generation; default `routes` constant computes the prefixes from the
1146
+ loadConfig singletons at module load time.
1147
+
1148
+ **Caveats:**
1149
+
1150
+ - **No true 404 yet.** Stale `/en/about`-style URLs render the home
1151
+ page (HTTP 200) instead of returning a 404. The 404-page feature is
1152
+ separate work; pair the two for a fully clean URL space.
1153
+ - **Sitemap-index for very large sites** is still future work. Today's
1154
+ sitemap is single-file; sites with 1000+ pages or many locales may
1155
+ want a sitemap-index later.
1156
+ - **Multi-locale sites with empty/sparse locale dirs.** This PR's
1157
+ on-disk discovery only counts dirs that contain `site.json`. Some
1158
+ sites (notably the 9 with all 15 locale dirs scaffolded) may have
1159
+ `de/site.json` etc. with placeholder-only content. Those are still
1160
+ emitted today; cleanup is per-site work — drop the dirs you don't
1161
+ use.
1162
+
1163
+ Tests: 445 passing (12 new for `canonicalUrl`, 7 added to
1164
+ `sitemapGenerator`, 9 added to `usePageMeta`, 13 in router after
1165
+ refactor).
1166
+
1167
+ ## 1.0.0-beta.18
1168
+
1169
+ ### Draft mode — `noindex` + password gate for in-progress content
1170
+
1171
+ A site, a URL prefix, or an individual page can be marked as "draft" so
1172
+ search engines stay out and casual visitors hit a password gate instead of
1173
+ the page contents. Designed for the iteration window before a site or
1174
+ section goes public — not for protecting confidential data. **Static-host
1175
+ caveat**: the gate is client-side; image and asset URLs in `dist/` remain
1176
+ publicly fetchable regardless of whether the page that references them is
1177
+ gated. If you need real privacy for assets, host them elsewhere.
1178
+
1179
+ **Schema additions** (all flat-key, optional, default off):
1180
+
1181
+ - `site.json`
1182
+ - `"draft": true` — marks the whole site as draft.
1183
+ - `"draftPaths[0]": "/hidden/"` — URL prefixes (path-segment-aware: `/blog`
1184
+ matches `/blog/2026/post` but not `/blog-archive`; case-sensitive).
1185
+ - `"draftPassword": "..."` — one shared password for all drafts on the
1186
+ site. The plugin replaces this field with a SHA-256 `draftPasswordHash`
1187
+ at bundle time; the plaintext never reaches `dist/` or
1188
+ `__INITIAL_STATE__`. Empty/missing is a deliberate fail-safe: the gate
1189
+ still appears but accepts any input including empty.
1190
+ - `pages/{id}.json`
1191
+ - `"draft": true` — marks a single page as draft.
1192
+ - `"draft": false` — explicitly publishes a page even when the site or a
1193
+ matching `draftPaths` prefix says draft (page-level wins both ways).
1194
+
1195
+ **Behavior:**
1196
+
1197
+ - **Robots & SEO**: every draft page emits `<meta name="robots"
1198
+ content="noindex, nofollow">` and a generic `<title>Draft — {Site}</title>`
1199
+ (the page's real title and description are suppressed for drafts so the
1200
+ slug doesn't leak via `<title>` / `<meta description>`). The plugin
1201
+ generates `robots.txt` and `sitemap.xml` dynamically from the inflated
1202
+ config on every build — draft prefixes/pages get `Disallow:` lines and
1203
+ are skipped from the sitemap entirely. (Replaces the previous static
1204
+ `cms/public/robots.txt`.)
1205
+ - **Runtime gate**: when a draft page is rendered, `<DraftGate>` replaces
1206
+ the page body (no Header, no Footer, no skip-link) so the SSG-rendered
1207
+ HTML on disk contains only the gate, not the content. Unlock persists
1208
+ in `sessionStorage` site-wide for the tab — closing the tab re-engages
1209
+ the gate.
1210
+ - **Password is hashed at build time**: site.json's plaintext
1211
+ `draftPassword` is replaced with `draftPasswordHash` (SHA-256 hex) by a
1212
+ Vite `transform` hook before any JSON is imported. At runtime,
1213
+ `useDraftGate.attemptUnlock(input)` SHA-256-hashes the input and
1214
+ compares hashes. Plaintext password is in the source repo only — never
1215
+ in `dist/` or `__INITIAL_STATE__`.
1216
+ - **SSR**: the SSR pre-render pass keeps `isUnlocked: false`, so
1217
+ `dist/<path>/index.html` is the gate HTML even after build. This is
1218
+ what crawlers and `curl` see.
1219
+
1220
+ **New files:**
1221
+
1222
+ - `src/utils/draftMode.js` — `isPathDraft`, `pathMatchesPrefix`,
1223
+ `normalizeDraftPath`, `getDraftPasswordHash`. The predicate is shared
1224
+ between the runtime gate, the meta-tag injection, and the plugin's
1225
+ robots.txt/sitemap.xml emitters.
1226
+ - `src/utils/sha256.js` — `sha256Hex(input)` over Web Crypto. Used by both
1227
+ the build-time transform (Node 20.19's globalThis.crypto.subtle) and
1228
+ the runtime gate (browser SubtleCrypto).
1229
+ - `src/utils/sitemapGenerator.js` — `buildSitemap(siteConfig)` returns the
1230
+ XML or '' when `site.url` is missing.
1231
+ - `src/utils/robotsGenerator.js` — `buildRobotsTxt(siteConfig, sitemapUrl)`.
1232
+ - `src/composables/useDraftGate.js` — reactive gate state.
1233
+ `attemptUnlock` is async (it hashes user input).
1234
+ - `src/components/DraftGate.vue` — inline (not body-teleported) modal,
1235
+ theme-token-driven CSS, WCAG 2.2 AA: real `<label>`, `:focus-visible`,
1236
+ `aria-live` error region, `prefers-reduced-motion` honored.
1237
+
1238
+ **Edits:**
1239
+
1240
+ - `src/composables/usePageMeta.js` — emits the noindex meta when
1241
+ `isPathDraft` is true and replaces the `<title>` / `<meta description>`
1242
+ with generic stand-ins so the page slug and explicit `meta.title` /
1243
+ `meta.description` don't leak in the SSG-rendered HTML.
1244
+ - `src/composables/usePageConfig.js` — `selectPage()` now propagates the
1245
+ page's `draft` flag onto `currentPage` so the gate, the meta, and any
1246
+ other consumer can read it.
1247
+ - `src/components/Home.vue` — wraps the page render in
1248
+ `v-if="!isDraft || isUnlocked"` and renders `<DraftGate>` in its place
1249
+ when locked.
1250
+ - `vite-plugin.js` — generates `siteRoot/public/robots.txt` and
1251
+ `siteRoot/public/sitemap.xml` after `syncPublicDir`. Sitemap is dropped
1252
+ when `site.url` is missing or every page is draft. Adds a `transform`
1253
+ hook that hashes `site.json`'s plaintext `draftPassword` (replacing it
1254
+ with `draftPasswordHash`) before the JSON is bundled.
1255
+ - `cms/public/robots.txt` — **removed** (the plugin owns it now).
1256
+
1257
+ **What this protects against:**
1258
+
1259
+ - ✓ Search engines indexing draft content (noindex meta, sitemap exclusion,
1260
+ robots.txt Disallow, generic `<title>`).
1261
+ - ✓ Casual visitors who type a draft URL — they see only the gate.
1262
+ - ✓ `view-source` revealing the plaintext password — the plugin replaces
1263
+ it with a SHA-256 hash before bundling.
1264
+ - ✓ `view-source` revealing the page slug or explicit `meta.title` /
1265
+ `meta.description` via the rendered `<title>` / meta tags.
1266
+
1267
+ **What it does NOT protect against — known v1 bounds:**
1268
+
1269
+ - **`view-source` of any page reveals draft pages' `meta` and `content`
1270
+ via `__INITIAL_STATE__`.** Pinia + ViteSSG serialize the full inflated
1271
+ `siteConfig` (every page's content, meta, components) into every rendered
1272
+ HTML file for hydration. So `view-source /about` shows you the full meta
1273
+ and content of every draft page on the site. Stripping draft pages'
1274
+ content from the bundle would require lazy-loading them after unlock —
1275
+ a major architecture change. For "iterate before launch / not ultra
1276
+ secure," current bound is appropriate; for confidential content, it isn't.
1277
+ - **`robots.txt` lists every draft path.** This is intentional (search
1278
+ engines need to know what NOT to crawl) but does mean the URL list is
1279
+ publicly readable. Don't put secret paths in `draftPaths`.
1280
+ - **Asset URLs aren't gated.** `dist/img/foo.jpg` is reachable to anyone
1281
+ who knows the URL even if the page that references it is draft.
1282
+ - **Hash brute-force.** Weak passwords (e.g. `letmein`) fall to a rainbow
1283
+ table in seconds. The hash blocks "view source → password" but not a
1284
+ determined attacker. Use a long random password if it matters.
1285
+ - **noindex doesn't un-index.** If a page was previously indexed, adding
1286
+ noindex tells Google to drop it on the next crawl, which can be days or
1287
+ weeks. Use Search Console URL removal if you need it gone now.
1288
+ - **Public-page links to draft URLs leak the URL.** v1 doesn't rewrite
1289
+ links; if a public page `<a>`s into a draft path, the URL appears in
1290
+ the published HTML.
1291
+ - **Locale interaction.** `site.draft: true` in the base locale propagates
1292
+ to all locales. To publish only one locale, set `"draft": false` in the
1293
+ per-locale `site.json`.
1294
+
1295
+ Tests: 414 passing (24 new for `draftMode`, 13 for `sitemapGenerator`,
1296
+ 13 for `robotsGenerator`, 9 added to `usePageMeta`, 17 for `useDraftGate`).
1297
+
1298
+ ## 1.0.0-beta.17
1299
+
1300
+ ### WCAG 2.2 AA pass on the bundled components and base theme
1301
+
1302
+ Audited the framework against WCAG 2.2 Level AA and applied the fixes that block conformance. Sites using `theme: "base"` and the bundled components inherit the improvements; sites with their own theme keep their palette but pick up the structural fixes (skip link, form labels, focus management, target sizes, semantic landmarks).
1303
+
1304
+ **Base theme palette** (`themes/base/theme.config.js`)
1305
+
1306
+ Re-balanced every text-on-bg combination the bundled components actually render. The previous values for `palette.primary` (`#4f6cf0`), `palette.accent` (`#f18f3b`), `field.inputPlaceholder` (composited 64% alpha), and the status colors all failed AA at 4.5:1 by varying margins.
1307
+
1308
+ - `palette.primary` `#4f6cf0` → `#4361dd` (4.43 → 4.91 on white).
1309
+ - `palette.accent` `#f18f3b` → `#b45a00` (2.40 → 4.51 on white). The original orange is preserved as `palette.accentDecorative` for fills/icons only.
1310
+ - `field.inputPlaceholder` `rgba(84, 98, 123, 0.64)` → solid `#54627b` (2.82 → 5.66 on white). Same change in `utility.inputPlaceholder` and the `.ui-form-control::placeholder` fallback in `base.css`.
1311
+ - Status `palette.{success,warning,critical}` retained as fill colors; new sibling tokens `successText` / `warningText` / `criticalText` (5.99 / 5.13 / 5.43 on white) for status messaging where the value is rendered as text.
1312
+ - Primary CTA gradient lighter stop darkened so white-on-gradient passes across the full button surface.
1313
+ - New top-level token blocks for surfaces that pair text with a non-default background — `tokens.hero`, `tokens.footer`, `tokens.plan` — exposed as `--brand-hero-*`, `--brand-footer-*`, `--brand-plan-*` CSS variables (see `buildCssVarMap.js`). These eliminate the "fallback chain reaches a token designed for a different surface" trap that left FooterMinimal text invisible on its own dark background, the Hero headline invisible on the dark hero surface, and the Plan card title at 1.05:1 on its dark card.
1314
+
1315
+ **Built-in components**
1316
+
1317
+ - `Home.vue` — adds a focus-visible "Skip to main content" link as the first focusable element on the page, gives `<main>` `id="main-content"` + `tabindex="-1"`, and sets `html { scroll-padding-top }` so the sticky header doesn't obscure focused targets after in-page navigation. The duplicate `<main>` previously emitted by `templates/index.html` is gone.
1318
+ - `templates/index.html` — drops the outer `<main>` wrapper and adds a `<noscript>` style override that forces scroll-reveal targets visible when JS doesn't run.
1319
+ - `Header.vue` — `<nav aria-label="Primary">`, descriptive `aria-label` on the home link, locale dropdown gains ESC-to-close and focus-out dismissal with focus restoration to the trigger button. Removed the mismatched `aria-haspopup="true"` on the disclosure (the dropdown isn't a `role="menu"`).
1320
+ - `Hero.vue` — promotes the headline from `<h3>` to `<h1>` (every page needs exactly one h1) and demotes the eyebrow `<h2>` to a `<p>` so the document outline isn't filled with non-heading copy. Reads from `--brand-hero-text` / `--brand-hero-text-on-dark` so the dark hero surface gets light text by default.
1321
+ - `Contact.vue` — every input now has a real `<label>`, plus visible required `*` indicators, `aria-required="true"`, `autocomplete` hints (`name`, `email`, `tel`), and a `role="status"` `aria-live="polite"` `#msg` region so submission feedback is announced. The challenge input is wired to its question + hint via `aria-describedby` and gets `aria-invalid="true"` on a wrong answer.
1322
+ - `ComingSoonModal.vue` — captures `previousFocusedElement` on open and restores focus on close (matching `IntroGate`), close button bumped to a 32×32 hit area with AA-safe color (`#595959`) and a visible `:focus-visible` outline. Adds a global `keydown` ESC handler so dismissal works regardless of where focus is inside the dialog.
1323
+ - `Footer.vue` — adds a visually-hidden "(opens in a new tab)" suffix to external links, removes the redundant `aria-label="Return home"` on the logo (the alt now reads `${siteName} – home`).
1324
+ - `FooterMinimal.vue`, `Plan.vue`, `Team.vue`, `Portfolio.vue`, `StickyCTA.vue` — re-pointed text colors at the new chrome tokens so default rendering passes contrast; `Portfolio` cards' overlay link now reads `View ${project.title}` instead of the generic "Visit portfolio link"; `Portfolio` filters got a visible `:focus-visible` outline; `BackToTop` got the same.
1325
+
1326
+ **Migration**
1327
+
1328
+ Sites on `theme: "base"` will see the palette shift slightly more saturated/darker — primary blue and accent orange both deepen by a perceptual notch. Sites with custom themes are unaffected by the palette change but inherit every structural/component fix automatically. If a site relied on the old `palette.accent` orange as decorative fill, it can read `palette.accentDecorative` instead (the original `#f18f3b` is preserved under that key).
1329
+
1330
+ `Hero.vue`'s `<h2>` eyebrow → `<p>` and `<h3>` headline → `<h1>` is a semantic change: any CSS scoped at `:deep(.hero-eyebrow h2)` or `:deep(.hero-headline h3)` from a wrapping component would break. The class names (`.hero-eyebrow__text`, `.hero-headline`) are unchanged and remain the right hooks.
1331
+
1332
+ `templates/index.html` no longer wraps `#app` in a `<main>` — sites that override the template should remove their own outer `<main>` so the framework's `<main id="main-content">` (now in `Home.vue`) is the only landmark.
1333
+
1334
+ Tests: 336 passing (no test changes needed — the contracts are unchanged).
1335
+
1336
+ ## 1.0.0-beta.16
1337
+
1338
+ ### `cms-create-site` CLI
1339
+
1340
+ Third bin in the scaffolding family (alongside `cms-create-theme` and `cms-create-extension`). Bootstraps a complete new site repo from one command:
1341
+
1342
+ ```bash
1343
+ npx cms-create-site cityofangels
1344
+ # → site-cityofangels/{package.json, vite.config.js, amplify.yml, .nvmrc,
1345
+ # .gitignore, .env.example, README.md, CLAUDE.md, site/content/...}
1346
+ ```
1347
+
1348
+ The scaffolded repo includes a substantial **`CLAUDE.md` template** with a "where things go" navigation table covering content / components / themes / extensions / locales / pages — so future Claude sessions opening the repo immediately have the right mental model. The same template includes the lockfile/npm-version rules (matching what sister sites already document) and pointers at the other two scaffolding CLIs.
1349
+
1350
+ The site template ships with a working home page (`Header` + `Hero` + `Footer`) using bundled CMS components and placeholder copy — `npm run dev` produces a live page from the moment scaffolding finishes, so the site owner can see the foundation before customizing.
1351
+
1352
+ ### Scaffold internals
1353
+
1354
+ - `runScaffold` now accepts `kind: 'site'` alongside `theme` and `extension`. Default target dir is `<cwd>/site-<slug>/` (matches the conventional `koehler8/site-<slug>` repo naming) instead of `themes/<slug>` or `extensions/<slug>`.
1355
+ - `collectTemplateFiles` no longer skips dotfiles wholesale. Site templates legitimately include `.nvmrc`, `.gitignore`, `.env.example`. Only OS junk (`.DS_Store`, `Thumbs.db`, `.git`) is now skipped.
1356
+
1357
+ 5 new specs in `tests/scripts/scaffold.spec.js` cover the site scaffold (336/336 passing).
1358
+
1359
+ ## 1.0.0-beta.15
1360
+
1361
+ ### Remove built-in `Loading…` placeholder; expose loading state via inject
1362
+
1363
+ The `Home.vue` page wrapper no longer renders an inline `<div class="page-loading-placeholder">Loading…</div>` (or its sibling `page-error-message`) at all. The element was a generic default that consumer sites either had to live with, restyle, or hide via critical CSS. The hydration-tick guard added in beta.9 had already prevented the flash on SSG-rendered pages, but the markup is now removed at the source — sites no longer need to know it ever existed.
1364
+
1365
+ In its place, `Home` now provides two refs via `provide()` so sites can build their own loading / error UI as a normal component:
1366
+
1367
+ - `inject('pageIsLoading')` — `Ref<boolean>` reflecting whether `usePageConfig`'s `syncPage()` is in flight.
1368
+ - `inject('pageLoadError')` — `Ref<Error|null>` set when a page-load promise rejects.
1369
+
1370
+ **To add a preloader / error UI to a site:**
1371
+
1372
+ - Drop a `site/components/Preloader.vue` (or `ErrorBanner.vue`, etc.) that injects the refs and renders conditionally on their value, then list the component in any page's `components[]` array. Auto-globbed since beta.10.
1373
+ - Or use the bundled `Preloader` component (currently a four-dot placeholder shell — sites typically override it via `site/components/Preloader.vue`).
1374
+
1375
+ **Migration**: sites that were carrying a `.page-loading-placeholder { display: none !important }` rule in their critical CSS to suppress the flash can drop it. The element no longer exists in the DOM.
1376
+
1377
+ ## 1.0.0-beta.14
1378
+
1379
+ ### Scaffolding CLIs: `cms-create-theme` and `cms-create-extension`
1380
+
1381
+ Two new bin scripts that scaffold a starter theme or extension into the current site, replacing the multi-file copy/paste dance with a single command.
1382
+
1383
+ ```bash
1384
+ npx cms-create-theme coastal
1385
+ # → themes/coastal/{package.json, index.js, theme.config.js, theme.css, README.md}
1386
+ # → prints next-step instructions for wiring into package.json + vite.config.js
1387
+
1388
+ npx cms-create-extension realestate
1389
+ # → extensions/realestate/{package.json, extension.config.json, index.js, README.md}
1390
+ # → ships with components: [] (no stub needed, since 1.0.0-beta.11)
1391
+ ```
1392
+
1393
+ Flags:
1394
+ - `--out <dir>` — override the default target (`themes/<slug>/` or `extensions/<slug>/`).
1395
+ - `--force` — overwrite an existing target directory.
1396
+ - `--help` / `-h` — show usage.
1397
+
1398
+ The generated theme manifest validates against `themeValidator` immediately (a slate-and-sand placeholder palette — meant to be replaced). The generated extension manifest declares `components: []` and the README explains how to add components plus when to prefer `site/components/` instead.
1399
+
1400
+ The CLI **does not** modify your site's `package.json` or `vite.config.js` — wiring is left to a copy/paste from the printed next-steps message, since automatic edits risk corrupting custom formatting and are harder to reverse than three lines of JSON.
1401
+
1402
+ ## 1.0.0-beta.13
1403
+
1404
+ ### `Header` slots + content-driven logo / nav
1405
+
1406
+ The bundled `Header` component now exposes three named slots and three optional content keys, so sites can customise it without forking the component or carrying CSS hacks.
1407
+
1408
+ **Slots** (for sites that wrap `Header` in a parent component):
1409
+
1410
+ - `<template #logo>` — completely overrides the logo `<a>` contents. Receives `{ src, text, siteName }` as scoped slot props.
1411
+ - `<template #nav>` — overrides the nav `<ul>` rendering. Receives `{ items }` as a scoped slot prop.
1412
+ - `<template #actions>` — extra content rendered inside the actions cluster, before the locale dropdown.
1413
+
1414
+ **Content keys** (for sites that use the bundled `Header` via JSON `components: ["Header"]`):
1415
+
1416
+ - `content.header.logoText` — text shown in place of the site title in the logo position.
1417
+ - `content.header.logoSrc` — full URL to use as the logo image (overrides the `site/assets/img/logo.png` asset resolution).
1418
+ - `content.header.navItems[]` — array of `{ text, href, target? }` rendered as a top-level nav menu (responsive: hidden < 720px).
1419
+
1420
+ **Behaviour change**: when neither `logoSrc` nor a `site/assets/img/logo.png` resolves, the Header now renders a text fallback (`logoText` or `siteName`) instead of an empty `<img src="">`. Sites that were carrying a critical-CSS workaround to hide the empty `<img>` (e.g. `.site-header__logo img { display: none } / ::after { content: '...' }`) can drop it.
1421
+
1422
+ ## 1.0.0-beta.12
1423
+
1424
+ ### Drop the implicit `base` theme auto-apply (BREAKING for sites that relied on it)
1425
+
1426
+ Previously, when a site didn't configure a theme (`theme:` key absent from `site.json`), `applySiteTheme` silently fell back to `"base"`, applying the bundled blue/orange palette. That hid configuration mistakes — a site could register a theme in its `vite.config.js` `themes:` array, forget to add `"theme": "X"` to `site.json`, and silently render with `base` instead of the theme it intended.
1427
+
1428
+ `applySiteTheme` now returns early when no theme is configured. The `data-site-theme` attribute is not set on `<html>`, none of the `:root[data-site-theme="X"]` rules in `virtual:cms-theme-vars.css` apply, and components fall back to their hardcoded CSS defaults (which look mixed and a bit broken — that's intentional, it makes the misconfiguration visible).
1429
+
1430
+ **Migration**: sites that *want* the bundled `base` palette as their design must add `"theme": "base"` to `site.json`. Sites that intend a different theme must register it in `vite.config.js` `themes:` AND set `"theme": "<slug>"` in `site.json`.
1431
+
1432
+ `setActiveThemeKey` (used by JS color utilities like `resolveThemeColor`) is unchanged and still falls back to `base` for color resolution, so utility palette lookups continue to work even with no `data-site-theme` on `<html>`.
1433
+
1434
+ ## 1.0.0-beta.11
1435
+
1436
+ ### Allow setup-only / styles-only extensions
1437
+
1438
+ Extension manifests can now declare `components: []`. Previously the schema enforced `components.minItems: 1`, which forced extensions whose only contribution is a `setup()` function or a side-effect CSS import to ship a no-op stub Vue file just to satisfy the validator (e.g. the coastalcollective site's `ext-shell` and `cms-ext-realestate` both carried `Placeholder.vue` / `SiteShell.vue` for this reason).
1439
+
1440
+ The runtime loader already iterated `manifest.components` with `.some()` and `for...of`, so empty arrays work without further code changes. `registerExtension` accepts `{ manifest: { components: [] } }` and skips component registration entirely; `setup()` and `contentDefaults` continue to work as before.
1441
+
1442
+ ## 1.0.0-beta.10
1443
+
1444
+ ### First-class site-local components
1445
+
1446
+ Sites can now drop Vue files into `site/components/**/*.vue` and reference them from `pages/{pageId}.json` `components[]` by basename — no extension wrapper, no `package.json`, no `vite.config.js` change. The framework auto-globs them at build time via a new `virtual:cms-site-components` virtual module that the generated entry hands to `setSiteComponents()`.
1447
+
1448
+ - **Resolution priority**: site → extension → bundled. Most-specific wins.
1449
+ - **Source-qualified `site:Name`** always resolves to a site-local component, mirroring the existing `slug:Name` extension qualifier. Use it to disambiguate when a site component intentionally shadows a bundled or extension one.
1450
+ - **Override warnings**: when a site component shadows a bundled CMS component, a one-line dev-time console warning fires at startup naming the shadow. Duplicate basenames across subdirectories also warn (first registration wins).
1451
+ - **Component contract**: site components follow the same conventions as bundled CMS components — read content via `inject('pageContent')`, use `<style scoped>`, consume theme tokens via `var(--brand-*)`. They receive no props from the resolver (extensions still receive `content` and `configKey`, since they have a manifest-declared `configKey`).
1452
+
1453
+ This is the recommended home for any one-off custom UI that doesn't need to be shared across sites. Reach for an extension only when components are actually destined for a published `@koehler8/cms-ext-*` package.
1454
+
1455
+ ## 1.0.0-beta.9
1456
+
1457
+ ### `Home.vue`: suppress the loading placeholder during initial hydration
1458
+
1459
+ For SSG-rendered pages, `componentKeys` briefly resets to `[]` on the client during the first `syncPage` call before the cached config resolves. That made `showLoadingIndicator` flicker true for one tick, causing a jarring "Loading…" flash on top of already-rendered content. The placeholder now waits for the first `onMounted` + `nextTick` before it can render — initial hydration is silent, but genuine in-session navigation that's actually slow can still surface the placeholder.
1460
+
1461
+ ### Pin Node 20.19 / npm 10.8 via `engines` + `.nvmrc`
1462
+
1463
+ Adds `engines.node: ">=20.19.0"` and `engines.npm: "^10.8.0"` to `package.json`, plus a top-level `.nvmrc`. Matches AWS Amplify's default Node 20 environment, eliminating the lockfile drift that bit the coastalcollective site deploy when a regen on npm 11 stripped the `@rolldown/binding-linux-x64-gnu` optional-dep entries Amplify needed. Sites consuming the framework should add a matching `.nvmrc` and switch back to `npm ci` for reproducible installs.
1464
+
1465
+ ## 1.0.0-beta.8
1466
+
1467
+ ### Theme system: SSR-rendered theme attribute + sync-bundled theme CSS
1468
+
1469
+ Eliminates the flash-of-unstyled-content that affected every dark-themed site.
1470
+
1471
+ - **`applySiteTheme` now pushes `htmlAttrs.data-site-theme` via @unhead** in addition to setting it on `document.documentElement`. The attribute lands in the SSR-rendered HTML, so theme CSS selectors (`:root[data-site-theme="X"]`) apply during the first paint instead of after Vue hydrates.
1472
+ - **External themes can now ship CSS via side-effect import** (`import './theme.css';` in `index.js`) instead of `?inline` + JS injection. Vite bundles the CSS as a regular asset and emits a `<link rel="stylesheet">`, so it's parsed before Vue runs. The legacy `?inline` pattern still works — existing `@koehler8/cms-theme-*` packages don't need to be updated.
1473
+
1474
+ Sites previously using critical-path CSS overrides (`html { background: ... !important }`) to mask the FOUC can now rely on the active theme to drive body background, header chrome, etc. from the very first paint.
1475
+
1476
+ ## 1.0.0-beta.1
1477
+
1478
+ Initial public beta release.
1479
+
1480
+ ### Features
1481
+
1482
+ - Vertex CMS — lightweight, config-driven Vue 3 framework with Vite plugin
1483
+ - Theming system with design token manifests and CSS custom properties
1484
+ - Extension system with AJV-validated manifests and component loader
1485
+ - Built-in components: Header, Hero, Footer, Contact, and more
1486
+ - Dynamic routing from page config with locale support
1487
+ - SSG support via ViteSSG
1488
+ - Analytics and cookie consent utilities
1489
+ - CLI tools for theme/extension validation and asset generation
1490
+ - Vitest test suite with coverage
1491
+ - GitHub Actions publish workflow