@koehler8/cms 1.3.0 → 1.3.1

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 CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.1
4
+
5
+ ### Fixed
6
+
7
+ - **`<html lang>` now follows the language actually on screen (WCAG 2.2
8
+ SC 3.1.1, Level A).** On a multi-locale site, a returning visitor's saved
9
+ locale is restored onto the unprefixed base URL: visit `/de`, then `/`, and
10
+ the content swaps to German while the URL stays `/`. `usePageMeta` derived
11
+ `lang` from the *route* — which has no locale at `/` — so the page declared
12
+ `lang="en"` over German text, and a screen reader read it with English
13
+ pronunciation rules. `og:locale`, the canonical and the breadcrumb JSON-LD
14
+ described the English page too, while `<title>` was already German.
15
+
16
+ `usePageConfig` now exposes `contentLocale` — the locale whose content is
17
+ currently applied — and `Home.vue` keys `usePageMeta` off that instead of the
18
+ route param, so the whole head describes the rendered page (`lang="de"`,
19
+ `og:locale=de`, canonical `/de`). The new `resolveContentLocale()` in
20
+ `loadConfig.js` shares its "explicit, else saved" rule with `loadConfigData`
21
+ so the two can't drift; a saved locale with no content on disk loads the base
22
+ tree and therefore resolves to the base locale.
23
+
24
+ No SSG or hydration change: the server has no storage, and the first client
25
+ render still reproduces the route's locale from the primed cache —
26
+ `contentLocale` only moves once different-locale content has been applied,
27
+ after mount. `diff-ssg-dist` on a 15-locale site (62 pages): identical.
28
+ Pre-existing since the saved-locale restore shipped; reproduced on 1.1.0.
29
+
30
+ - **Engagement events report the rendered locale.** `Home.vue` passed the
31
+ route's locale param to `useEngagementTracking`, and an event-level `locale`
32
+ overrides the tracking context's own (read from `<html lang>`). So scroll-
33
+ depth and section-view events carried `locale: ''` for every base-locale page
34
+ — and for saved-locale content restored onto an unprefixed URL — while every
35
+ other event on the same page said `en` / `de`. They now use `contentLocale`
36
+ too. **Reporting note:** in GA, the `locale` dimension on these two event
37
+ families changes from empty to the real code (`en`, …) from the release a
38
+ site picks this up; earlier rows stay empty.
39
+
3
40
  ## 1.3.0
4
41
 
5
42
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koehler8/cms",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
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",
@@ -89,13 +90,13 @@
89
90
  },
90
91
  "devDependencies": {
91
92
  "@vitejs/plugin-vue": "^6.0.8",
92
- "@vitest/coverage-v8": "^4.1.10",
93
+ "@vitest/coverage-v8": "^4.1.11",
93
94
  "@vue/test-utils": "^2.4.10",
94
95
  "happy-dom": "^20.9.0",
95
- "vite": "^8.2.1",
96
- "vitest": "^4.1.6",
97
- "vue": "^3.5.39",
98
- "vue-router": "^5.0.7"
96
+ "vite": "^8.3.0",
97
+ "vitest": "^4.1.11",
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
+ }
@@ -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
- usePageMeta({ siteData, currentPage, locale: () => props.locale });
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: props.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 { loadConfigData, mergeConfigTrees, peekConfigSync } from '../utils/loadConfig.js';
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,
@@ -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
- let locale = explicitLocale;
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