@koehler8/cms 1.0.0-beta.8 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1491 -0
- package/README.md +108 -28
- package/bin/cms-create-extension.js +7 -0
- package/bin/cms-create-site.js +7 -0
- package/bin/cms-create-theme.js +7 -0
- package/bin/cms-ssg-build.js +13 -0
- package/bin/cms-validate-extensions.js +4 -0
- package/bin/cms-validate-themes.js +11 -0
- package/extensions/manifest.schema.json +1 -1
- package/package.json +36 -16
- package/scripts/create-extension.mjs +11 -0
- package/scripts/create-site.mjs +11 -0
- package/scripts/create-theme.mjs +11 -0
- package/scripts/generate-public-assets.js +70 -36
- package/scripts/image-variants/cache.js +40 -0
- package/scripts/image-variants/classifier.js +53 -0
- package/scripts/image-variants/config.js +54 -0
- package/scripts/image-variants/index.js +18 -0
- package/scripts/image-variants/manifest.js +76 -0
- package/scripts/image-variants/planner.js +95 -0
- package/scripts/image-variants/reconcile.js +194 -0
- package/scripts/image-variants/renderer.js +51 -0
- package/scripts/lib/extension-manifests.mjs +70 -0
- package/scripts/scaffold.mjs +242 -0
- package/scripts/ssg-build/findEmptyRenders.mjs +42 -0
- package/scripts/ssg-build/index.mjs +253 -0
- package/scripts/validate-extensions.mjs +50 -382
- package/scripts/validate-themes.mjs +37 -5
- package/src/components/About.vue +4 -2
- package/src/components/AboutValue.vue +5 -4
- package/src/components/ComingSoon.vue +12 -1
- package/src/components/ComingSoonModal.vue +33 -61
- package/src/components/Contact.vue +216 -120
- package/src/components/DraftGate.vue +211 -0
- package/src/components/Footer.vue +15 -2
- package/src/components/FooterMinimal.vue +13 -12
- package/src/components/Header.vue +184 -10
- package/src/components/Hero.vue +17 -8
- package/src/components/Home.vue +81 -48
- package/src/components/Intro.vue +4 -2
- package/src/components/IntroGate.vue +30 -73
- package/src/components/NotFound.vue +100 -0
- package/src/components/Plan.vue +10 -4
- package/src/components/Portfolio.vue +29 -3
- package/src/components/Principles.vue +3 -1
- package/src/components/Spacer.vue +23 -0
- package/src/components/Spacer15.vue +3 -4
- package/src/components/Spacer30.vue +3 -4
- package/src/components/Spacer40.vue +3 -4
- package/src/components/Spacer60.vue +3 -4
- package/src/components/StickyCTA.vue +23 -1
- package/src/components/Team.vue +3 -1
- package/src/composables/useComponentResolver.js +49 -1
- package/src/composables/useDraftGate.js +93 -0
- package/src/composables/useFocusTrap.js +85 -0
- package/src/composables/usePageConfig.js +126 -28
- package/src/composables/usePageMeta.js +178 -5
- package/src/composables/usePromoBackgroundStyles.js +30 -14
- package/src/extensions/extensionLoader.js +34 -24
- package/src/main.js +69 -6
- package/src/router/index.js +98 -37
- package/src/styles/base.css +11 -2
- package/src/themes/buildCssVarMap.js +34 -0
- package/src/utils/appInfo.js +8 -5
- package/src/utils/breadcrumbs.js +121 -0
- package/src/utils/canonicalUrl.js +53 -0
- package/src/utils/componentRegistry.js +98 -16
- package/src/utils/cookieConsent.js +38 -24
- package/src/utils/draftMode.js +65 -0
- package/src/utils/imageSources.js +9 -1
- package/src/utils/jsonLd.js +66 -0
- package/src/utils/loadConfig.js +106 -6
- package/src/utils/robotsGenerator.js +95 -0
- package/src/utils/sha256.js +20 -0
- package/src/utils/sitemapGenerator.js +112 -0
- package/src/utils/socialMeta.js +100 -0
- package/src/utils/ssgRoutes.js +40 -0
- package/src/utils/trackingContext.js +16 -22
- package/src/utils/webAppManifest.js +85 -0
- package/src/utils/webStorage.js +82 -0
- package/templates/index.html +17 -24
- package/templates/scaffolds/extension/README.md +53 -0
- package/templates/scaffolds/extension/extension.config.json +10 -0
- package/templates/scaffolds/extension/index.js +37 -0
- package/templates/scaffolds/extension/package.json +22 -0
- package/templates/scaffolds/site/.env.example +7 -0
- package/templates/scaffolds/site/.nvmrc +1 -0
- package/templates/scaffolds/site/CLAUDE.md +121 -0
- package/templates/scaffolds/site/README.md +25 -0
- package/templates/scaffolds/site/amplify.yml +22 -0
- package/templates/scaffolds/site/package.json +26 -0
- package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
- package/templates/scaffolds/site/site/components/.gitkeep +0 -0
- package/templates/scaffolds/site/site/content/content.config.json +3 -0
- package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
- package/templates/scaffolds/site/site/content/en/shared.json +5 -0
- package/templates/scaffolds/site/site/content/en/site.json +10 -0
- package/templates/scaffolds/site/site/style.css +4 -0
- package/templates/scaffolds/site/vite.config.js +15 -0
- package/templates/scaffolds/theme/README.md +50 -0
- package/templates/scaffolds/theme/index.js +8 -0
- package/templates/scaffolds/theme/package.json +21 -0
- package/templates/scaffolds/theme/theme.config.js +228 -0
- package/templates/scaffolds/theme/theme.css +14 -0
- package/themes/base/theme.config.js +100 -57
- package/vite-plugin.js +545 -32
- package/public/robots.txt +0 -5
- package/scripts/check-overflow.mjs +0 -33
- package/scripts/migrate-to-content-dirs.mjs +0 -223
- 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 `"`);
|
|
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
|