@koehler8/cms 1.3.0 → 1.4.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 +78 -0
- package/package.json +10 -9
- package/scripts/check-lockfile-followup-done.mjs +218 -0
- package/scripts/check-lockfile-version.mjs +61 -0
- package/scripts/check-site-lockfile.mjs +128 -0
- package/scripts/diff-ssg-dist.mjs +194 -0
- package/src/components/Home.vue +10 -2
- package/src/composables/usePageConfig.js +31 -1
- package/src/composables/usePageMeta.js +4 -1
- package/src/utils/loadConfig.js +29 -5
- package/templates/scaffolds/site/.nvmrc +1 -1
- package/templates/scaffolds/site/CLAUDE.md +2 -2
- package/templates/scaffolds/site/README.md +1 -1
- package/templates/scaffolds/site/amplify.yml +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,83 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.0
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- **The bundled Pinia is now 4.x (was 3.x).** `createPinia()` runs Pinia 4.0.3
|
|
8
|
+
and that is the instance handed to every extension through
|
|
9
|
+
`runExtensionSetups({ app, router, pinia, ... })`.
|
|
10
|
+
|
|
11
|
+
**If you maintain an extension that declares `peerDependencies.pinia`, widen
|
|
12
|
+
it to admit `^4`** — for example `"pinia": "^3.0.0 || ^4.0.0"`. An extension
|
|
13
|
+
pinned to `^3` will fail to install alongside this release.
|
|
14
|
+
(`@koehler8/cms-ext-crypto` 1.0.0-beta.4 is in exactly that position; it is
|
|
15
|
+
not installed on any site in the fleet.)
|
|
16
|
+
|
|
17
|
+
Nothing else about extensions changes: the instance is still a single Pinia
|
|
18
|
+
per site — this package deliberately excludes `vue`, `vue-router` and `pinia`
|
|
19
|
+
from Vite's dependency pre-bundling so a linked extension cannot end up with
|
|
20
|
+
a second copy — and `vue-router` 5.3.1 already accepts `pinia@4`
|
|
21
|
+
(`^3.0.4 || ^4.0.2`).
|
|
22
|
+
|
|
23
|
+
Two consequences worth knowing, neither of which needs action in a site:
|
|
24
|
+
|
|
25
|
+
- Pinia 4 makes `@vue/devtools-api` a **required peer** at `^8.1.5`, where
|
|
26
|
+
Pinia 3 carried it as a dependency. npm installs it automatically; on a
|
|
27
|
+
consumer site the resolved version moves 7.x → 8.x, which also lets
|
|
28
|
+
`vue-router`'s own nested copies collapse into the hoisted one. In this
|
|
29
|
+
repo that made the lockfile *smaller* (318 → 307 entries).
|
|
30
|
+
- Pinia 4 is ESM-only and depends on `nostics`. That package was already
|
|
31
|
+
present in this tree, so no new package enters a site's dependency graph.
|
|
32
|
+
|
|
33
|
+
No framework source change was needed: this package defines no store of its
|
|
34
|
+
own and uses none of the APIs Pinia changed.
|
|
35
|
+
|
|
36
|
+
### Internal (not shipped)
|
|
37
|
+
|
|
38
|
+
- Dev dependency `vitest` 4.1.11 → 5.0.1 (with `@vitest/coverage-v8`). This
|
|
39
|
+
restores the repo's ability to regenerate its own `package-lock.json` under
|
|
40
|
+
npm 10: on `vitest` 4.1.x a fresh `npm install` died in arborist with
|
|
41
|
+
`Cannot read properties of null (reading 'edgesOut')`, and only the existing
|
|
42
|
+
lockfile kept the repo installable. Neither package ships.
|
|
43
|
+
|
|
44
|
+
## 1.3.1
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- **`<html lang>` now follows the language actually on screen (WCAG 2.2
|
|
49
|
+
SC 3.1.1, Level A).** On a multi-locale site, a returning visitor's saved
|
|
50
|
+
locale is restored onto the unprefixed base URL: visit `/de`, then `/`, and
|
|
51
|
+
the content swaps to German while the URL stays `/`. `usePageMeta` derived
|
|
52
|
+
`lang` from the *route* — which has no locale at `/` — so the page declared
|
|
53
|
+
`lang="en"` over German text, and a screen reader read it with English
|
|
54
|
+
pronunciation rules. `og:locale`, the canonical and the breadcrumb JSON-LD
|
|
55
|
+
described the English page too, while `<title>` was already German.
|
|
56
|
+
|
|
57
|
+
`usePageConfig` now exposes `contentLocale` — the locale whose content is
|
|
58
|
+
currently applied — and `Home.vue` keys `usePageMeta` off that instead of the
|
|
59
|
+
route param, so the whole head describes the rendered page (`lang="de"`,
|
|
60
|
+
`og:locale=de`, canonical `/de`). The new `resolveContentLocale()` in
|
|
61
|
+
`loadConfig.js` shares its "explicit, else saved" rule with `loadConfigData`
|
|
62
|
+
so the two can't drift; a saved locale with no content on disk loads the base
|
|
63
|
+
tree and therefore resolves to the base locale.
|
|
64
|
+
|
|
65
|
+
No SSG or hydration change: the server has no storage, and the first client
|
|
66
|
+
render still reproduces the route's locale from the primed cache —
|
|
67
|
+
`contentLocale` only moves once different-locale content has been applied,
|
|
68
|
+
after mount. `diff-ssg-dist` on a 15-locale site (62 pages): identical.
|
|
69
|
+
Pre-existing since the saved-locale restore shipped; reproduced on 1.1.0.
|
|
70
|
+
|
|
71
|
+
- **Engagement events report the rendered locale.** `Home.vue` passed the
|
|
72
|
+
route's locale param to `useEngagementTracking`, and an event-level `locale`
|
|
73
|
+
overrides the tracking context's own (read from `<html lang>`). So scroll-
|
|
74
|
+
depth and section-view events carried `locale: ''` for every base-locale page
|
|
75
|
+
— and for saved-locale content restored onto an unprefixed URL — while every
|
|
76
|
+
other event on the same page said `en` / `de`. They now use `contentLocale`
|
|
77
|
+
too. **Reporting note:** in GA, the `locale` dimension on these two event
|
|
78
|
+
families changes from empty to the real code (`en`, …) from the release a
|
|
79
|
+
site picks this up; earlier rows stay empty.
|
|
80
|
+
|
|
3
81
|
## 1.3.0
|
|
4
82
|
|
|
5
83
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@koehler8/cms",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Vertex CMS — lightweight, config-driven Vue 3 framework with theming, extensions, and SSG support",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"scripts": {
|
|
26
26
|
"test": "vitest run",
|
|
27
27
|
"test:watch": "vitest",
|
|
28
|
-
"test:coverage": "vitest run --coverage"
|
|
28
|
+
"test:coverage": "vitest run --coverage",
|
|
29
|
+
"check:lockfile-version": "node scripts/check-lockfile-version.mjs"
|
|
29
30
|
},
|
|
30
31
|
"exports": {
|
|
31
32
|
"./vite": "./vite-plugin.js",
|
|
@@ -79,7 +80,7 @@
|
|
|
79
80
|
"@unhead/vue": "^2.1.15",
|
|
80
81
|
"ajv": "^8.20.0",
|
|
81
82
|
"ajv-formats": "^3.0.1",
|
|
82
|
-
"pinia": "^
|
|
83
|
+
"pinia": "^4.0.3",
|
|
83
84
|
"sharp": "^0.35.3"
|
|
84
85
|
},
|
|
85
86
|
"optionalDependencies": {
|
|
@@ -89,13 +90,13 @@
|
|
|
89
90
|
},
|
|
90
91
|
"devDependencies": {
|
|
91
92
|
"@vitejs/plugin-vue": "^6.0.8",
|
|
92
|
-
"@vitest/coverage-v8": "^
|
|
93
|
-
"@vue/test-utils": "^2.
|
|
93
|
+
"@vitest/coverage-v8": "^5.0.1",
|
|
94
|
+
"@vue/test-utils": "^2.5.1",
|
|
94
95
|
"happy-dom": "^20.9.0",
|
|
95
|
-
"vite": "^8.
|
|
96
|
-
"vitest": "^
|
|
97
|
-
"vue": "^3.5.
|
|
98
|
-
"vue-router": "^5.
|
|
96
|
+
"vite": "^8.3.0",
|
|
97
|
+
"vitest": "^5.0.1",
|
|
98
|
+
"vue": "^3.5.43",
|
|
99
|
+
"vue-router": "^5.3.1"
|
|
99
100
|
},
|
|
100
101
|
"engines": {
|
|
101
102
|
"node": ">=20.19.0",
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-lockfile-followup-done — acceptance check for the "lockfile-version
|
|
3
|
+
* follow-up isn't tracked" defect.
|
|
4
|
+
*
|
|
5
|
+
* PR #6 shipped scripts/check-lockfile-version.mjs (the drift detector) but
|
|
6
|
+
* never touched package-lock.json itself, so the drift it detects
|
|
7
|
+
* (root ".version" and .packages[""].version" still "1.1.0" against
|
|
8
|
+
* package.json's "1.2.0") was left unfixed with nothing tracking it as open
|
|
9
|
+
* work. package-lock.json is floored (never-touch) for every automated
|
|
10
|
+
* pipeline stage in this repo, so the buildable fix here is a GitHub issue
|
|
11
|
+
* that captures the drift and the documented hand-fix procedure — not a
|
|
12
|
+
* code change.
|
|
13
|
+
*
|
|
14
|
+
* This is intentionally NOT a Vitest spec under tests/ and is not wired into
|
|
15
|
+
* `npm test` or builder/verify.json, for the same reason
|
|
16
|
+
* check-lockfile-version.mjs itself is kept standalone: it depends on state
|
|
17
|
+
* (the git history of this branch, and live GitHub issue content) that a
|
|
18
|
+
* hermetic, offline `npm ci && npm test` run should not depend on, and that
|
|
19
|
+
* only a human/operator action (opening the issue) can satisfy — wiring it
|
|
20
|
+
* into the gate would hold every future PR hostage to that action.
|
|
21
|
+
*
|
|
22
|
+
* Checks two things:
|
|
23
|
+
* 1. Scope: this work item's commits (relative to `main`) AND its working
|
|
24
|
+
* tree (staged or unstaged) must not modify package-lock.json,
|
|
25
|
+
* package.json, CLAUDE.md, or scripts/check-lockfile-version.mjs.
|
|
26
|
+
* 2. Tracking: an open GitHub issue on koehler8/cms exists whose body
|
|
27
|
+
* documents the drift (1.1.0 -> 1.2.0), references PR #6, states PR #6
|
|
28
|
+
* shipped the detector but not the fix, documents the hand-fix
|
|
29
|
+
* procedure (nvm use first, edit only the two "version" string
|
|
30
|
+
* literals, no npm install/npm version/regen), and explains that
|
|
31
|
+
* package-lock.json is floored/never-touch for pipeline stages.
|
|
32
|
+
*
|
|
33
|
+
* Run manually: node scripts/check-lockfile-followup-done.mjs
|
|
34
|
+
* Requires `gh` authenticated against github.com for the tracking check.
|
|
35
|
+
* Exits 1 and prints exactly what's missing if either check fails; exits 0
|
|
36
|
+
* once both are satisfied.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { execFileSync } from 'node:child_process';
|
|
40
|
+
|
|
41
|
+
const REPO = 'koehler8/cms';
|
|
42
|
+
const SCOPED_PATHS = [
|
|
43
|
+
'package-lock.json',
|
|
44
|
+
'package.json',
|
|
45
|
+
'CLAUDE.md',
|
|
46
|
+
'scripts/check-lockfile-version.mjs',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
function run(cmd, args) {
|
|
50
|
+
return execFileSync(cmd, args, { encoding: 'utf-8' }).trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// `git status --porcelain` is fixed-width (2-char status code + space before
|
|
54
|
+
// the filename); a blanket .trim() would eat that leading space on the first
|
|
55
|
+
// line and misalign the slice(3) below, so this skips run()'s trim.
|
|
56
|
+
function runPorcelainLines(cmd, args) {
|
|
57
|
+
return execFileSync(cmd, args, { encoding: 'utf-8' })
|
|
58
|
+
.split('\n')
|
|
59
|
+
.filter((line) => line.length > 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function checkScope() {
|
|
63
|
+
let baseRef;
|
|
64
|
+
for (const candidate of ['origin/main', 'main']) {
|
|
65
|
+
try {
|
|
66
|
+
run('git', ['rev-parse', '--verify', candidate]);
|
|
67
|
+
baseRef = candidate;
|
|
68
|
+
break;
|
|
69
|
+
} catch {
|
|
70
|
+
// try next candidate
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!baseRef) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
messages: [' could not resolve a `main` ref (tried origin/main, main) to diff against'],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let mergeBase;
|
|
82
|
+
try {
|
|
83
|
+
mergeBase = run('git', ['merge-base', 'HEAD', baseRef]);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return { ok: false, messages: [` git merge-base HEAD ${baseRef} failed: ${err.message}`] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const committed = run('git', ['diff', '--name-only', `${mergeBase}...HEAD`])
|
|
89
|
+
.split('\n')
|
|
90
|
+
.filter(Boolean);
|
|
91
|
+
|
|
92
|
+
const workingTree = runPorcelainLines('git', ['status', '--porcelain', '--', ...SCOPED_PATHS]).map(
|
|
93
|
+
(line) => line.slice(3)
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const touchedCommitted = SCOPED_PATHS.filter((p) => committed.includes(p));
|
|
97
|
+
const touchedWorkingTree = SCOPED_PATHS.filter((p) => workingTree.includes(p));
|
|
98
|
+
const touched = [...new Set([...touchedCommitted, ...touchedWorkingTree])];
|
|
99
|
+
|
|
100
|
+
if (touched.length > 0) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
messages: touched.map((p) => ` ${p} was modified by this work item but must stay untouched`),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { ok: true, messages: [] };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function findTrackingIssue() {
|
|
111
|
+
const raw = run('gh', [
|
|
112
|
+
'issue',
|
|
113
|
+
'list',
|
|
114
|
+
'-R',
|
|
115
|
+
REPO,
|
|
116
|
+
'--state',
|
|
117
|
+
'open',
|
|
118
|
+
'--limit',
|
|
119
|
+
'100',
|
|
120
|
+
'--json',
|
|
121
|
+
'number,title,body,url',
|
|
122
|
+
]);
|
|
123
|
+
const issues = JSON.parse(raw);
|
|
124
|
+
|
|
125
|
+
const requirements = [
|
|
126
|
+
{ label: 'mentions the current stale value "1.1.0"', test: (b) => b.includes('1.1.0') },
|
|
127
|
+
{ label: 'mentions the expected value "1.2.0"', test: (b) => b.includes('1.2.0') },
|
|
128
|
+
{ label: 'references PR #6', test: (b) => /#6\b/.test(b) },
|
|
129
|
+
{
|
|
130
|
+
label: 'states PR #6 shipped the detector but not the fix',
|
|
131
|
+
test: (b) => /check[-:]lockfile-version/.test(b) && /(did not|didn't|never|not).{0,40}(touch|fix|update)/i.test(b),
|
|
132
|
+
},
|
|
133
|
+
{ label: 'documents `nvm use` as a required first step', test: (b) => /nvm use/.test(b) },
|
|
134
|
+
{
|
|
135
|
+
label: 'documents that npm install/npm version/regen must not be used to fix it',
|
|
136
|
+
test: (b) => /npm (install|version)/.test(b) && /(never|not|don't|do not)/i.test(b),
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
label: 'explains package-lock.json is floored/never-touch for the pipeline',
|
|
140
|
+
test: (b) => /package-lock\.json/.test(b) && /(floor|never.touch)/i.test(b),
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
for (const issue of issues) {
|
|
145
|
+
const body = issue.body || '';
|
|
146
|
+
const misses = requirements.filter((r) => !r.test(body)).map((r) => r.label);
|
|
147
|
+
if (misses.length === 0) {
|
|
148
|
+
return { ok: true, issue };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { ok: false, issues, requirements };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function checkTracking() {
|
|
156
|
+
let result;
|
|
157
|
+
try {
|
|
158
|
+
result = findTrackingIssue();
|
|
159
|
+
} catch (err) {
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
messages: [` could not query GitHub issues via \`gh\` (${err.message})`],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (result.ok) {
|
|
167
|
+
return { ok: true, messages: [` found: ${result.issue.url}`] };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (result.issues.length === 0) {
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
messages: [` no open issues found on ${REPO} at all — none can be the tracking issue`],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const messages = [` no open issue on ${REPO} covers every required element:`];
|
|
178
|
+
for (const issue of result.issues) {
|
|
179
|
+
const body = issue.body || '';
|
|
180
|
+
const misses = result.requirements.filter((r) => !r.test(body)).map((r) => r.label);
|
|
181
|
+
if (misses.length < result.requirements.length) {
|
|
182
|
+
messages.push(` #${issue.number} "${issue.title}" is missing: ${misses.join('; ')}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (messages.length === 1) {
|
|
186
|
+
messages.push(' (no candidate issue matched any required element)');
|
|
187
|
+
}
|
|
188
|
+
return { ok: false, messages };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function main() {
|
|
192
|
+
const scope = checkScope();
|
|
193
|
+
const tracking = checkTracking();
|
|
194
|
+
|
|
195
|
+
let ok = true;
|
|
196
|
+
|
|
197
|
+
if (!scope.ok) {
|
|
198
|
+
ok = false;
|
|
199
|
+
console.error('Scope check failed — this work item must not modify these files:');
|
|
200
|
+
console.error(scope.messages.join('\n'));
|
|
201
|
+
} else {
|
|
202
|
+
console.log('Scope check passed: package-lock.json, package.json, CLAUDE.md, and ' +
|
|
203
|
+
'scripts/check-lockfile-version.mjs are untouched by this work item.');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!tracking.ok) {
|
|
207
|
+
ok = false;
|
|
208
|
+
console.error('Tracking check failed — no qualifying GitHub issue found:');
|
|
209
|
+
console.error(tracking.messages.join('\n'));
|
|
210
|
+
} else {
|
|
211
|
+
console.log('Tracking check passed:');
|
|
212
|
+
console.log(tracking.messages.join('\n'));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
process.exitCode = ok ? 0 : 1;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await main();
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-lockfile-version — detect drift between package.json's version and
|
|
3
|
+
* package-lock.json's two self-reported version fields (root `.version` and
|
|
4
|
+
* `.packages[""].version`).
|
|
5
|
+
*
|
|
6
|
+
* This is intentionally NOT a Vitest spec under tests/, and is not wired
|
|
7
|
+
* into `npm test`. package-lock.json is a floored, never-touch file for
|
|
8
|
+
* every automated pipeline stage in this repo (see CLAUDE.md "Lockfile and
|
|
9
|
+
* npm version" and the product skill's "Never touch" list) — a full lockfile
|
|
10
|
+
* regen on the wrong npm version is the documented #1 cause of consumer-site
|
|
11
|
+
* deploy failures, so no stage here is permitted to edit it, not even for a
|
|
12
|
+
* narrow two-field metadata correction. A Vitest spec asserting these fields
|
|
13
|
+
* match would therefore stay red until a human fixes the file by hand, which
|
|
14
|
+
* would permanently fail the mandatory `npm ci && npm test` verify gate for
|
|
15
|
+
* every future PR. Keeping this check standalone lets it detect and report
|
|
16
|
+
* the drift without holding CI hostage to a fix only a human can apply.
|
|
17
|
+
*
|
|
18
|
+
* Run manually: node scripts/check-lockfile-version.mjs
|
|
19
|
+
* Exits 1 and prints the stale fields if package-lock.json's version fields
|
|
20
|
+
* don't match package.json's version; exits 0 otherwise.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFile } from 'node:fs/promises';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const ROOT_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
28
|
+
|
|
29
|
+
async function main() {
|
|
30
|
+
const packageJson = JSON.parse(await readFile(join(ROOT_DIR, 'package.json'), 'utf-8'));
|
|
31
|
+
const lockJson = JSON.parse(await readFile(join(ROOT_DIR, 'package-lock.json'), 'utf-8'));
|
|
32
|
+
|
|
33
|
+
const expected = packageJson.version;
|
|
34
|
+
const stale = [];
|
|
35
|
+
if (lockJson.version !== expected) {
|
|
36
|
+
stale.push(` package-lock.json ".version" is "${lockJson.version}", expected "${expected}"`);
|
|
37
|
+
}
|
|
38
|
+
if (lockJson.packages?.['']?.version !== expected) {
|
|
39
|
+
stale.push(
|
|
40
|
+
` package-lock.json ".packages[\\"\\"].version" is "${lockJson.packages?.['']?.version}", expected "${expected}"`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (stale.length > 0) {
|
|
45
|
+
console.error(`package-lock.json version drift detected (package.json is at "${expected}"):`);
|
|
46
|
+
console.error(stale.join('\n'));
|
|
47
|
+
console.error(
|
|
48
|
+
'\npackage-lock.json is floored (never-touch) for every pipeline stage in this repo — ' +
|
|
49
|
+
'this must be corrected by hand, outside the pipeline, and pushed directly ' +
|
|
50
|
+
'(same as any dependency-version change; see CLAUDE.md "Lockfile and npm version"). ' +
|
|
51
|
+
'Edit only the two "version" string literals above; do not run `npm install`, ' +
|
|
52
|
+
'`npm version`, or any lockfile regen.'
|
|
53
|
+
);
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log(`package-lock.json version fields match package.json ("${expected}").`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await main();
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-site-lockfile — verify a consumer site's package-lock.json is safe to
|
|
3
|
+
* push to AWS Amplify after a dependency bump.
|
|
4
|
+
*
|
|
5
|
+
* The documented #1 cause of consumer-site deploy failures (CLAUDE.md
|
|
6
|
+
* "Lockfile and npm version") is a lockfile regenerated on the wrong npm
|
|
7
|
+
* major: it looks fine on a Mac and then Amplify's Linux build dies with
|
|
8
|
+
* `Cannot find module '@rolldown/binding-linux-x64-gnu'`, because the optional
|
|
9
|
+
* platform-binary entries were stripped. Nothing local fails, so the only
|
|
10
|
+
* defence is to inspect the lockfile before it ships.
|
|
11
|
+
*
|
|
12
|
+
* The invariant is NOT a count. rolldown 1.2.9 dropped its wasm32-wasi binding
|
|
13
|
+
* and added an android one, so a healthy bump moved a site from 42 native
|
|
14
|
+
* entries to 41 — a count check would have failed a good lockfile and could
|
|
15
|
+
* pass a bad one. What must hold instead: every name a locked package lists
|
|
16
|
+
* under `optionalDependencies` resolves to an entry in the same lockfile. The
|
|
17
|
+
* parent's list survives a bad regen; the children are what go missing. That
|
|
18
|
+
* needs no network and no knowledge of which packages happen to be native.
|
|
19
|
+
*
|
|
20
|
+
* Like check-lockfile-version this is a standalone detector, deliberately not
|
|
21
|
+
* wired into `npm test` or builder/verify.json: it inspects a SITE's lockfile,
|
|
22
|
+
* not this repo's.
|
|
23
|
+
*
|
|
24
|
+
* Run: node scripts/check-site-lockfile.mjs [siteDir] (default: cwd)
|
|
25
|
+
* Exits 1 and lists every problem; exits 0 when the lockfile is clean.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { readFile } from 'node:fs/promises';
|
|
29
|
+
import { join, resolve } from 'node:path';
|
|
30
|
+
import { pathToFileURL } from 'node:url';
|
|
31
|
+
|
|
32
|
+
// A second copy of any of these means two module instances at runtime — the
|
|
33
|
+
// reason the framework excludes them from Vite's dep pre-bundling.
|
|
34
|
+
const SINGLETONS = ['vue', 'vue-router', 'pinia', '@unhead/vue'];
|
|
35
|
+
|
|
36
|
+
// The two binaries whose absence has actually taken Amplify builds down.
|
|
37
|
+
// Checked by name on top of the general rule so the failure message is direct.
|
|
38
|
+
const AMPLIFY_BINARIES = [
|
|
39
|
+
{ parent: 'rolldown', binary: '@rolldown/binding-linux-x64-gnu' },
|
|
40
|
+
{ parent: 'sharp', binary: '@img/sharp-linux-x64' },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const REGISTRY = 'https://registry.npmjs.org/';
|
|
44
|
+
|
|
45
|
+
/** npm's resolution walk: nearest node_modules first, then each ancestor's. */
|
|
46
|
+
function resolveFrom(packages, parentKey, name) {
|
|
47
|
+
let base = parentKey;
|
|
48
|
+
for (;;) {
|
|
49
|
+
const candidate = base ? `${base}/node_modules/${name}` : `node_modules/${name}`;
|
|
50
|
+
if (packages[candidate]) return candidate;
|
|
51
|
+
if (!base) return null;
|
|
52
|
+
const cut = base.lastIndexOf('/node_modules/');
|
|
53
|
+
base = cut === -1 ? '' : base.slice(0, cut);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function checkLockfile(lock) {
|
|
58
|
+
const problems = [];
|
|
59
|
+
const packages = lock?.packages ?? {};
|
|
60
|
+
|
|
61
|
+
if (lock?.lockfileVersion !== 3) {
|
|
62
|
+
problems.push(`lockfileVersion is ${lock?.lockfileVersion}, expected 3`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const [key, entry] of Object.entries(packages)) {
|
|
66
|
+
for (const name of Object.keys(entry.optionalDependencies ?? {})) {
|
|
67
|
+
if (!resolveFrom(packages, key, name)) {
|
|
68
|
+
problems.push(
|
|
69
|
+
`${key || '(root)'} lists optional dependency "${name}" but the lockfile has no entry for it`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const { parent, binary } of AMPLIFY_BINARIES) {
|
|
76
|
+
if (!packages[`node_modules/${parent}`]) continue;
|
|
77
|
+
const entry = packages[`node_modules/${binary}`];
|
|
78
|
+
if (!entry) {
|
|
79
|
+
problems.push(`${parent} is locked but ${binary} is missing — Amplify's Linux build will fail`);
|
|
80
|
+
} else if (!entry.resolved || !entry.integrity) {
|
|
81
|
+
problems.push(`${binary} has no resolved/integrity — npm cannot install it on Amplify`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const name of SINGLETONS) {
|
|
86
|
+
const copies = Object.keys(packages).filter(
|
|
87
|
+
(key) => key === `node_modules/${name}` || key.endsWith(`/node_modules/${name}`)
|
|
88
|
+
);
|
|
89
|
+
if (copies.length > 1) {
|
|
90
|
+
problems.push(`${copies.length} copies of ${name} (${copies.join(', ')}) — must be exactly one`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const [key, entry] of Object.entries(packages)) {
|
|
95
|
+
if (!entry.resolved || entry.link) continue;
|
|
96
|
+
const isLocal = !/^[a-z][a-z0-9+.-]*:/i.test(entry.resolved);
|
|
97
|
+
if (!isLocal && !entry.resolved.startsWith(REGISTRY)) {
|
|
98
|
+
problems.push(`${key} resolves outside the npm registry: ${entry.resolved}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return problems;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function main() {
|
|
106
|
+
const siteDir = resolve(process.argv[2] ?? process.cwd());
|
|
107
|
+
const lock = JSON.parse(await readFile(join(siteDir, 'package-lock.json'), 'utf-8'));
|
|
108
|
+
const problems = checkLockfile(lock);
|
|
109
|
+
|
|
110
|
+
if (problems.length > 0) {
|
|
111
|
+
console.error(`package-lock.json in ${siteDir} is NOT safe to push:`);
|
|
112
|
+
console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
|
|
113
|
+
console.error(
|
|
114
|
+
'\nIf optional-dependency entries are missing, the lockfile was almost certainly written by ' +
|
|
115
|
+
'the wrong npm major. Revert it (`git checkout HEAD -- package-lock.json`), run `nvm use`, ' +
|
|
116
|
+
'and redo the bump with a targeted `npm install <pkg>@<ver>`.'
|
|
117
|
+
);
|
|
118
|
+
process.exitCode = 1;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const count = Object.keys(lock.packages ?? {}).length;
|
|
123
|
+
console.log(`package-lock.json in ${siteDir} is clean (${count} entries, all optional dependencies present).`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
127
|
+
await main();
|
|
128
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* diff-ssg-dist — compare two SSG `dist/` trees page by page on everything a
|
|
3
|
+
* dependency bump must NOT change: the route list, each page's SEO head, and
|
|
4
|
+
* the rendered structure and text.
|
|
5
|
+
*
|
|
6
|
+
* Consumer sites have no test suite; a green `build:ssg` is their only gate,
|
|
7
|
+
* and it cannot see the failures that matter. A component whose content block
|
|
8
|
+
* stopped resolving renders nothing, silently. A head-management regression
|
|
9
|
+
* drops canonicals or hreflang without a warning. Both build clean. So build
|
|
10
|
+
* once before the bump, once after, and diff what the crawler and the visitor
|
|
11
|
+
* would actually receive.
|
|
12
|
+
*
|
|
13
|
+
* It also compares the theme's fingerprint — the set of CSS custom-property
|
|
14
|
+
* declarations across dist/assets/*.css — because the page diff never opens a
|
|
15
|
+
* stylesheet and would not see a palette or type regression.
|
|
16
|
+
*
|
|
17
|
+
* Hashed asset filenames and bundled JS are expected to differ and are ignored.
|
|
18
|
+
* HTML comments and inline <style> are stripped first: a comment can mention a
|
|
19
|
+
* tag ("<title> is emitted by usePageMeta…") and inline CSS is
|
|
20
|
+
* minifier-dependent — neither is content, and both produced false diffs.
|
|
21
|
+
*
|
|
22
|
+
* Run: node scripts/diff-ssg-dist.mjs <dist-before> <dist-after>
|
|
23
|
+
* Exits 1 on any difference; exits 0 when every page matches.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
27
|
+
import { join, relative } from 'node:path';
|
|
28
|
+
import { pathToFileURL } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const all = (html, re) => [...html.matchAll(re)].map((match) => match[1] ?? match[0]);
|
|
31
|
+
const attr = (tag, name) => (tag.match(new RegExp(`\\b${name}="([^"]*)"`)) || [])[1] ?? null;
|
|
32
|
+
const count = (html, re) => (html.match(re) || []).length;
|
|
33
|
+
|
|
34
|
+
export function extractSignals(rawHtml) {
|
|
35
|
+
const html = rawHtml.replace(/<!--[\s\S]*?-->/g, '').replace(/<style\b[\s\S]*?<\/style>/g, '');
|
|
36
|
+
const head = (html.match(/<head[^>]*>([\s\S]*?)<\/head>/) || [])[1] ?? '';
|
|
37
|
+
const links = all(head, /<link\b[^>]*>/g);
|
|
38
|
+
const metas = all(head, /<meta\b[^>]*>/g);
|
|
39
|
+
const meta = (key, value) =>
|
|
40
|
+
metas.filter((tag) => attr(tag, key) === value).map((tag) => attr(tag, 'content')).sort();
|
|
41
|
+
const body = ((html.match(/<body[^>]*>([\s\S]*?)<\/body>/) || [])[1] ?? '').replace(
|
|
42
|
+
/<script[\s\S]*?<\/script>/g,
|
|
43
|
+
''
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
lang: attr((html.match(/<html\b[^>]*>/) || [''])[0], 'lang'),
|
|
48
|
+
title: (head.match(/<title>([\s\S]*?)<\/title>/) || [])[1] ?? null,
|
|
49
|
+
description: meta('name', 'description'),
|
|
50
|
+
robots: meta('name', 'robots'),
|
|
51
|
+
canonical: links.filter((tag) => attr(tag, 'rel') === 'canonical').map((tag) => attr(tag, 'href')),
|
|
52
|
+
hreflang: links
|
|
53
|
+
.filter((tag) => attr(tag, 'rel') === 'alternate' && attr(tag, 'hreflang'))
|
|
54
|
+
.map((tag) => `${attr(tag, 'hreflang')}=${attr(tag, 'href')}`)
|
|
55
|
+
.sort(),
|
|
56
|
+
ogUrl: meta('property', 'og:url'),
|
|
57
|
+
ogTitle: meta('property', 'og:title'),
|
|
58
|
+
ogImage: meta('property', 'og:image'),
|
|
59
|
+
jsonLd: all(html, /<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g).sort(),
|
|
60
|
+
// Structure counts are what catch a silently-vanished component.
|
|
61
|
+
sections: count(body, /<section\b/g),
|
|
62
|
+
headings: count(body, /<h[1-6]\b/g),
|
|
63
|
+
links: count(body, /<a\b/g),
|
|
64
|
+
images: count(body, /<img\b/g),
|
|
65
|
+
text: body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function initialStateBytes(rawHtml) {
|
|
70
|
+
return ((rawHtml.match(/__INITIAL_STATE__\s*=\s*([\s\S]*?)<\/script>/) || [])[1] ?? '').length;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function htmlFiles(root, dir = root, out = new Map()) {
|
|
74
|
+
for (const name of readdirSync(dir)) {
|
|
75
|
+
const path = join(dir, name);
|
|
76
|
+
if (statSync(path).isDirectory()) htmlFiles(root, path, out);
|
|
77
|
+
else if (name.endsWith('.html')) out.set(relative(root, path), path);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The theme's fingerprint: every CSS custom-property declaration in the built
|
|
83
|
+
// stylesheets, whitespace-normalised and de-duplicated. The page diff above
|
|
84
|
+
// strips inline <style> and never opens a CSS asset, so a palette or type
|
|
85
|
+
// regression — the kind a published theme package or a CSS-minifier bump could
|
|
86
|
+
// introduce — is invisible to it. Hashed filenames and rule order are
|
|
87
|
+
// minifier-dependent; the set of `--name:value` pairs is not (it held identical
|
|
88
|
+
// on three sites through lightningcss 1.32 -> 1.33).
|
|
89
|
+
export function themeFingerprint(distDir) {
|
|
90
|
+
const assets = join(distDir, 'assets');
|
|
91
|
+
let files;
|
|
92
|
+
try {
|
|
93
|
+
files = readdirSync(assets).filter((name) => name.endsWith('.css'));
|
|
94
|
+
} catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
const declarations = new Set();
|
|
98
|
+
for (const name of files) {
|
|
99
|
+
const css = readFileSync(join(assets, name), 'utf-8');
|
|
100
|
+
for (const match of css.matchAll(/(--[A-Za-z0-9_-]+)\s*:\s*([^;}]+)/g)) {
|
|
101
|
+
declarations.add(`${match[1]}:${match[2].replace(/\s+/g, '')}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return [...declarations].sort();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function diffDist(beforeDir, afterDir) {
|
|
108
|
+
const before = htmlFiles(beforeDir);
|
|
109
|
+
const after = htmlFiles(afterDir);
|
|
110
|
+
const differences = [];
|
|
111
|
+
|
|
112
|
+
const removed = [...before.keys()].filter((page) => !after.has(page));
|
|
113
|
+
const added = [...after.keys()].filter((page) => !before.has(page));
|
|
114
|
+
for (const page of removed) differences.push({ page, signal: 'route', before: 'present', after: 'MISSING' });
|
|
115
|
+
for (const page of added) differences.push({ page, signal: 'route', before: 'absent', after: 'NEW' });
|
|
116
|
+
|
|
117
|
+
let stateBefore = 0;
|
|
118
|
+
let stateAfter = 0;
|
|
119
|
+
for (const [page, beforePath] of before) {
|
|
120
|
+
if (!after.has(page)) continue;
|
|
121
|
+
const beforeHtml = readFileSync(beforePath, 'utf-8');
|
|
122
|
+
const afterHtml = readFileSync(after.get(page), 'utf-8');
|
|
123
|
+
stateBefore += initialStateBytes(beforeHtml);
|
|
124
|
+
stateAfter += initialStateBytes(afterHtml);
|
|
125
|
+
|
|
126
|
+
const a = extractSignals(beforeHtml);
|
|
127
|
+
const b = extractSignals(afterHtml);
|
|
128
|
+
for (const signal of Object.keys(a)) {
|
|
129
|
+
const left = JSON.stringify(a[signal]);
|
|
130
|
+
const right = JSON.stringify(b[signal]);
|
|
131
|
+
if (left !== right) differences.push({ page, signal, before: left, after: right });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const file of ['sitemap.xml', 'robots.txt']) {
|
|
136
|
+
const read = (dir) => {
|
|
137
|
+
try {
|
|
138
|
+
return readFileSync(join(dir, file), 'utf-8');
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
if (read(beforeDir) !== read(afterDir)) {
|
|
144
|
+
differences.push({ page: file, signal: 'bytes', before: 'baseline', after: 'CHANGED' });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const themeBefore = themeFingerprint(beforeDir);
|
|
149
|
+
const themeAfter = themeFingerprint(afterDir);
|
|
150
|
+
const afterSet = new Set(themeAfter);
|
|
151
|
+
const beforeSet = new Set(themeBefore);
|
|
152
|
+
const lost = themeBefore.filter((declaration) => !afterSet.has(declaration));
|
|
153
|
+
const gained = themeAfter.filter((declaration) => !beforeSet.has(declaration));
|
|
154
|
+
if (lost.length || gained.length) {
|
|
155
|
+
differences.push({
|
|
156
|
+
page: 'assets/*.css',
|
|
157
|
+
signal: 'theme',
|
|
158
|
+
before: `${lost.length} declaration(s) gone: ${lost.slice(0, 6).join(' | ')}`,
|
|
159
|
+
after: `${gained.length} declaration(s) new: ${gained.slice(0, 6).join(' | ')}`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { pages: before.size, stateBefore, stateAfter, themeDeclarations: themeAfter.length, differences };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function main() {
|
|
167
|
+
const [beforeDir, afterDir] = process.argv.slice(2);
|
|
168
|
+
if (!beforeDir || !afterDir) {
|
|
169
|
+
console.error('Usage: node scripts/diff-ssg-dist.mjs <dist-before> <dist-after>');
|
|
170
|
+
process.exitCode = 2;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const { pages, stateBefore, stateAfter, themeDeclarations, differences } = diffDist(beforeDir, afterDir);
|
|
175
|
+
const clip = (value) => (value.length > 160 ? `${value.slice(0, 160)}… (${value.length} chars)` : value);
|
|
176
|
+
for (const { page, signal, before, after } of differences) {
|
|
177
|
+
console.log(`DIFF ${page} [${signal}]\n before: ${clip(before)}\n after: ${clip(after)}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const drift = stateBefore ? (((stateAfter - stateBefore) / stateBefore) * 100).toFixed(2) : '0.00';
|
|
181
|
+
console.log(`pages compared: ${pages}`);
|
|
182
|
+
console.log(`__INITIAL_STATE__ total: ${stateBefore} -> ${stateAfter} bytes (${drift}%)`);
|
|
183
|
+
console.log(`theme fingerprint: ${themeDeclarations} CSS custom-property declarations`);
|
|
184
|
+
if (differences.length > 0) {
|
|
185
|
+
console.log(`FAIL: ${differences.length} difference(s)`);
|
|
186
|
+
process.exitCode = 1;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
console.log('PASS: every page identical on route list, SEO head, structure and text; theme unchanged');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
193
|
+
main();
|
|
194
|
+
}
|
package/src/components/Home.vue
CHANGED
|
@@ -65,6 +65,7 @@ const {
|
|
|
65
65
|
pageContent,
|
|
66
66
|
currentPage,
|
|
67
67
|
componentKeys,
|
|
68
|
+
contentLocale,
|
|
68
69
|
isLoading,
|
|
69
70
|
loadError,
|
|
70
71
|
} = usePageConfig({
|
|
@@ -90,7 +91,10 @@ const {
|
|
|
90
91
|
attemptUnlock,
|
|
91
92
|
} = useDraftGate({ siteData, currentPage });
|
|
92
93
|
|
|
93
|
-
|
|
94
|
+
// Keyed off the locale of the content actually rendered, not the route param:
|
|
95
|
+
// on an unprefixed route a returning visitor's saved locale is restored, and
|
|
96
|
+
// <html lang> / og:locale / canonical must describe that content (WCAG 3.1.1).
|
|
97
|
+
usePageMeta({ siteData, currentPage, locale: () => contentLocale.value });
|
|
94
98
|
|
|
95
99
|
const {
|
|
96
100
|
isComingSoonVisible,
|
|
@@ -102,7 +106,11 @@ const {
|
|
|
102
106
|
const { refreshVisibilityTargets, resetEngagementTracking } = useEngagementTracking({
|
|
103
107
|
getContext: () => ({
|
|
104
108
|
page_id: currentPage.value.id || '',
|
|
105
|
-
locale
|
|
109
|
+
// The rendered locale, for the same reason as usePageMeta above. An event-
|
|
110
|
+
// level `locale` overrides the tracking context's own (read from <html
|
|
111
|
+
// lang>), so the route param here reported '' for every base-locale page
|
|
112
|
+
// and for saved-locale content restored onto an unprefixed URL.
|
|
113
|
+
locale: contentLocale.value,
|
|
106
114
|
}),
|
|
107
115
|
});
|
|
108
116
|
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { onServerPrefetch, ref, watch } from 'vue';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
baseLocale as configBaseLocale,
|
|
4
|
+
loadConfigData,
|
|
5
|
+
mergeConfigTrees,
|
|
6
|
+
peekConfigSync,
|
|
7
|
+
resolveContentLocale,
|
|
8
|
+
} from '../utils/loadConfig.js';
|
|
3
9
|
import { readStoredLocale } from '../utils/webStorage.js';
|
|
4
10
|
import { isNotFoundPage, NOT_FOUND_PAGE_ID } from '../utils/notFound.js';
|
|
5
11
|
|
|
@@ -46,8 +52,22 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
|
|
|
46
52
|
const componentKeys = ref([]);
|
|
47
53
|
const isLoading = ref(false);
|
|
48
54
|
const loadError = ref(null);
|
|
55
|
+
// The locale of the content currently applied — NOT necessarily the route's.
|
|
56
|
+
// An unprefixed route (`/`) carries no locale, yet a returning visitor's
|
|
57
|
+
// saved locale is restored onto it, so German can be on screen at a URL that
|
|
58
|
+
// implies English. usePageMeta keys <html lang> off this so the declared
|
|
59
|
+
// language always matches the rendered one (WCAG 3.1.1). It starts as the
|
|
60
|
+
// route locale — which is what the server rendered — and only moves when
|
|
61
|
+
// different-locale content is actually applied, after mount, so hydration
|
|
62
|
+
// still matches the on-disk HTML.
|
|
63
|
+
const contentLocale = ref(
|
|
64
|
+
((typeof locale === 'function' ? locale() : locale) || '').toString().toLowerCase()
|
|
65
|
+
|| configBaseLocale
|
|
66
|
+
|| '',
|
|
67
|
+
);
|
|
49
68
|
|
|
50
69
|
let cachedConfig = null;
|
|
70
|
+
let cachedContentLocale = contentLocale.value;
|
|
51
71
|
let lastLocaleKey = null;
|
|
52
72
|
let activeRequest = 0;
|
|
53
73
|
// Cancels a pending idle-scheduled background reload (see hydrateFromSyncCache).
|
|
@@ -189,13 +209,18 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
|
|
|
189
209
|
|
|
190
210
|
try {
|
|
191
211
|
if (shouldReload) {
|
|
212
|
+
// Resolved before the await so it names the same stored preference the
|
|
213
|
+
// loader is about to read.
|
|
214
|
+
const resolvedLocale = resolveContentLocale(localeForConfig);
|
|
192
215
|
cachedConfig = await loadConfigData({ locale: localeForConfig });
|
|
216
|
+
cachedContentLocale = resolvedLocale;
|
|
193
217
|
lastLocaleKey = localeKey;
|
|
194
218
|
}
|
|
195
219
|
|
|
196
220
|
if (requestId !== activeRequest) return;
|
|
197
221
|
|
|
198
222
|
applyConfig(cachedConfig);
|
|
223
|
+
contentLocale.value = cachedContentLocale;
|
|
199
224
|
} catch (error) {
|
|
200
225
|
if (requestId !== activeRequest) return;
|
|
201
226
|
if (import.meta.env.SSR) {
|
|
@@ -242,6 +267,10 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
|
|
|
242
267
|
return false;
|
|
243
268
|
}
|
|
244
269
|
cachedConfig = primed;
|
|
270
|
+
// The primed config is what the server rendered for this route, and the
|
|
271
|
+
// server never sees a saved locale — so it is the route's locale, or base.
|
|
272
|
+
cachedContentLocale = localeForConfig || configBaseLocale || '';
|
|
273
|
+
contentLocale.value = cachedContentLocale;
|
|
245
274
|
lastLocaleKey = localeKey;
|
|
246
275
|
|
|
247
276
|
// Decide whether a soft (no blank-out) background reload of the FULL config
|
|
@@ -326,6 +355,7 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
|
|
|
326
355
|
pageContent,
|
|
327
356
|
currentPage,
|
|
328
357
|
componentKeys,
|
|
358
|
+
contentLocale,
|
|
329
359
|
isLoading,
|
|
330
360
|
loadError,
|
|
331
361
|
syncPage,
|
|
@@ -236,7 +236,10 @@ export function usePageMeta({ siteData, currentPage, locale }) {
|
|
|
236
236
|
// Reflect the page's locale on <html lang> during SSR/prerender so the
|
|
237
237
|
// on-disk HTML for /{locale}/... ships the right language (the router
|
|
238
238
|
// guard only sets this client-side, after hydration). Base-locale pages
|
|
239
|
-
// fall back to the configured base locale.
|
|
239
|
+
// fall back to the configured base locale. `locale` here is the locale of
|
|
240
|
+
// the RENDERED content (usePageConfig's contentLocale), which after
|
|
241
|
+
// hydration can differ from the route's when a saved locale is restored
|
|
242
|
+
// onto an unprefixed URL — lang must follow the text (WCAG 3.1.1).
|
|
240
243
|
htmlAttrs: { lang: localeValue.value || configBaseLocale || 'en' },
|
|
241
244
|
title: pageMetaTitle.value,
|
|
242
245
|
meta,
|
package/src/utils/loadConfig.js
CHANGED
|
@@ -22,6 +22,17 @@ function normalizeLocaleInput(value) {
|
|
|
22
22
|
return trimmed.length ? trimmed : undefined;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// Which locale a load asks for: the explicit (route) locale when there is one,
|
|
26
|
+
// otherwise the visitor's saved preference. Shared by loadConfigData and
|
|
27
|
+
// resolveContentLocale so "what gets loaded" and "what we say got loaded"
|
|
28
|
+
// can't drift apart. The server has no storage, so SSG always gets `undefined`
|
|
29
|
+
// (base locale) for an unprefixed route.
|
|
30
|
+
function pickRequestedLocale(explicitLocale) {
|
|
31
|
+
return normalizeLocaleInput(explicitLocale)
|
|
32
|
+
|| normalizeLocaleInput(readStoredLocale())
|
|
33
|
+
|| undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
export function cloneConfig(value) {
|
|
26
37
|
if (Array.isArray(value)) {
|
|
27
38
|
return value.map(cloneConfig);
|
|
@@ -282,11 +293,7 @@ export function createConfigLoader(allModules) {
|
|
|
282
293
|
explicitLocale = normalizeLocaleInput(options.locale);
|
|
283
294
|
}
|
|
284
295
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if (!locale) {
|
|
288
|
-
locale = normalizeLocaleInput(readStoredLocale()) || undefined;
|
|
289
|
-
}
|
|
296
|
+
const locale = pickRequestedLocale(explicitLocale);
|
|
290
297
|
|
|
291
298
|
const normalizedLocale = typeof locale === 'string' ? locale.toLowerCase() : undefined;
|
|
292
299
|
|
|
@@ -406,6 +413,23 @@ export function peekConfigSync(locale) {
|
|
|
406
413
|
return _syncConfigCache.get(normalizeConfigCacheKey(locale)) || null;
|
|
407
414
|
}
|
|
408
415
|
|
|
416
|
+
// The locale whose content `loadConfigData({ locale: explicitLocale })` will
|
|
417
|
+
// actually return. An unprefixed route passes no locale, and loadConfigData
|
|
418
|
+
// then restores the visitor's saved locale — so the language on screen can
|
|
419
|
+
// differ from the one the URL implies. Anything that describes the rendered
|
|
420
|
+
// page (<html lang>, og:locale) must follow THIS, not the route param
|
|
421
|
+
// (WCAG 3.1.1). A saved locale with no content on disk loads the base tree,
|
|
422
|
+
// so it resolves to the base locale here too.
|
|
423
|
+
export function resolveContentLocale(explicitLocale) {
|
|
424
|
+
const explicit = normalizeLocaleInput(explicitLocale);
|
|
425
|
+
if (explicit) return explicit.toLowerCase();
|
|
426
|
+
const requested = (pickRequestedLocale() || '').toLowerCase();
|
|
427
|
+
if (requested && requested !== _baseLocale && _availableLocales.includes(requested)) {
|
|
428
|
+
return requested;
|
|
429
|
+
}
|
|
430
|
+
return _baseLocale || '';
|
|
431
|
+
}
|
|
432
|
+
|
|
409
433
|
export { _availableLocales as availableLocales };
|
|
410
434
|
export { _baseLocale as baseLocale };
|
|
411
435
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
22.23.2
|
|
@@ -89,14 +89,14 @@ Edit `themes/<slug>/theme.config.js`. The CMS regenerates `virtual:cms-theme-var
|
|
|
89
89
|
|
|
90
90
|
## Lockfile and npm version — read before bumping any dep
|
|
91
91
|
|
|
92
|
-
This project pins **Node
|
|
92
|
+
This project pins **Node 22.23.2 / npm 10.9** to match AWS Amplify exactly (never 24 — it ships npm 11). Local installs with a different npm version produce subtly different lockfiles that look fine locally but break CI.
|
|
93
93
|
|
|
94
94
|
Two failure modes seen on sister sites:
|
|
95
95
|
1. `Cannot find module '@rolldown/binding-linux-x64-gnu'` — npm 11 regen stripped optional-dep entries Amplify needs.
|
|
96
96
|
2. `npm ci ... Missing: @types/react@..., @noble/hashes@... from lock file` — npm 11 omitted entries npm 10 expects.
|
|
97
97
|
|
|
98
98
|
Rules:
|
|
99
|
-
- **Always run `nvm use` before `npm install`**. `.nvmrc` pins
|
|
99
|
+
- **Always run `nvm use` before `npm install`**. `.nvmrc` pins 22.23.2 (which ships npm 10.9.x).
|
|
100
100
|
- **Prefer targeted bumps**: `npm install <pkg>@<ver> --no-audit --no-fund`. **Never** `rm package-lock.json && npm install` unless you're on the pinned npm version.
|
|
101
101
|
- **The build uses `npm install`**, not `npm ci` (see `amplify.yml`) for drift tolerance.
|
|
102
102
|
|
|
@@ -3,7 +3,7 @@ frontend:
|
|
|
3
3
|
phases:
|
|
4
4
|
preBuild:
|
|
5
5
|
commands:
|
|
6
|
-
- nvm install
|
|
6
|
+
- nvm install 22.23.2 && nvm use 22.23.2
|
|
7
7
|
# Use `npm install` (not `npm ci`) so the build tolerates
|
|
8
8
|
# lockfile/npm-version drift. See CLAUDE.md → "Lockfile and
|
|
9
9
|
# npm version" for the rationale.
|