@vmz/vmz 0.0.0 → 0.0.2
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/README.md +48 -2
- package/bin/vmz.js +4 -0
- package/dist/application-cmd.d.ts +22 -0
- package/dist/application-cmd.js +348 -0
- package/dist/bundler-adapter.d.ts +64 -0
- package/dist/bundler-adapter.js +111 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +370 -0
- package/dist/dev-session.d.ts +35 -0
- package/dist/dev-session.js +290 -0
- package/dist/document-build.d.ts +99 -0
- package/dist/document-build.js +274 -0
- package/dist/document-check.d.ts +44 -0
- package/dist/document-check.js +246 -0
- package/dist/document-cmd.d.ts +9 -0
- package/dist/document-cmd.js +147 -0
- package/dist/document-designs.d.ts +9 -0
- package/dist/document-designs.js +126 -0
- package/dist/document-enrich.d.ts +23 -0
- package/dist/document-enrich.js +234 -0
- package/dist/document-evidence.d.ts +49 -0
- package/dist/document-evidence.js +501 -0
- package/dist/document-integrate.d.ts +35 -0
- package/dist/document-integrate.js +89 -0
- package/dist/document-interactive.d.ts +69 -0
- package/dist/document-interactive.js +255 -0
- package/dist/document-locale.d.ts +31 -0
- package/dist/document-locale.js +59 -0
- package/dist/document-markdown.d.ts +13 -0
- package/dist/document-markdown.js +39 -0
- package/dist/document-scan.d.ts +21 -0
- package/dist/document-scan.js +151 -0
- package/dist/document-schema.d.ts +87 -0
- package/dist/document-schema.js +88 -0
- package/dist/explain-cmd.d.ts +5 -0
- package/dist/explain-cmd.js +123 -0
- package/dist/index.d.ts +808 -0
- package/dist/index.js +569 -0
- package/dist/locale-check.d.ts +106 -0
- package/dist/locale-check.js +737 -0
- package/dist/locale-cmd.d.ts +5 -0
- package/dist/locale-cmd.js +443 -0
- package/dist/locale-delivery.d.ts +298 -0
- package/dist/locale-delivery.js +444 -0
- package/dist/locale-router.d.ts +207 -0
- package/dist/locale-router.js +508 -0
- package/dist/locale-runtime.d.ts +406 -0
- package/dist/locale-runtime.js +542 -0
- package/dist/locale-schema.d.ts +9 -0
- package/dist/locale-schema.js +10 -0
- package/dist/locale-tooling.d.ts +118 -0
- package/dist/locale-tooling.js +358 -0
- package/dist/log.d.ts +19 -0
- package/dist/log.js +42 -0
- package/dist/packages.d.ts +27 -0
- package/dist/packages.js +147 -0
- package/dist/plugin-host.d.ts +30 -0
- package/dist/plugin-host.js +370 -0
- package/dist/refactor-cmd.d.ts +8 -0
- package/dist/refactor-cmd.js +156 -0
- package/dist/resolve.d.ts +25 -0
- package/dist/resolve.js +56 -0
- package/dist/test-cmd.d.ts +9 -0
- package/dist/test-cmd.js +343 -0
- package/dist/test-compile.d.ts +2 -0
- package/dist/test-compile.js +3 -0
- package/dist/test-discover.d.ts +2 -0
- package/dist/test-discover.js +3 -0
- package/dist/test-logic.d.ts +2 -0
- package/dist/test-logic.js +3 -0
- package/dist/test-protocol.d.ts +2 -0
- package/dist/test-protocol.js +3 -0
- package/dist/watch-diff.d.ts +17 -0
- package/dist/watch-diff.js +56 -0
- package/package.json +86 -4
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* Locale I3 router / PageMeta: route realization · canonical · hreflang ·
|
|
4
|
+
* Link locale retain · locale-aware cache key.
|
|
5
|
+
* Design: 规划设计/vmz/28 §6 · §10 · 20
|
|
6
|
+
*
|
|
7
|
+
* LocaleId is a RouteNode realization dimension — not part of stable RouteId.
|
|
8
|
+
*/
|
|
9
|
+
import { negotiateLocale } from './locale-runtime.js';
|
|
10
|
+
import { DIAG_LOCALE_CACHE_KEY_STEALS_CONTENT, DIAG_LOCALE_CANONICAL_MISSING, DIAG_LOCALE_HREFLANG_INCOMPLETE, DIAG_LOCALE_LINK_HARDCODED_PATH, DIAG_LOCALE_META_LOCALE_MISMATCH, DIAG_LOCALE_PREFIX_OMIT_WITHOUT_REDIRECT, DIAG_LOCALE_ROUTE_COLLISION, LOCALE_LINK_RESOLUTION_SCHEMA, LOCALE_PAGE_META_SCHEMA, LOCALE_ROUTE_REALIZATION_SCHEMA, LOCALE_ROUTER_CHECK_SCHEMA, } from './locale-schema.js';
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} path
|
|
13
|
+
*/
|
|
14
|
+
function normalizePath(path) {
|
|
15
|
+
let p = String(path || '/');
|
|
16
|
+
if (!p.startsWith('/'))
|
|
17
|
+
p = `/${p}`;
|
|
18
|
+
if (p.length > 1 && p.endsWith('/'))
|
|
19
|
+
p = p.slice(0, -1);
|
|
20
|
+
return p || '/';
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Join locale prefix with a stable route path pattern.
|
|
24
|
+
* @param {string} localeId
|
|
25
|
+
* @param {string} pathPattern stable path without locale (e.g. /account/profile)
|
|
26
|
+
* @param {{ strategy?: string, defaultPrefix?: string, defaultLocale?: string }} routing
|
|
27
|
+
*/
|
|
28
|
+
export function realizeRoutePath(localeId, pathPattern, routing = {}) {
|
|
29
|
+
const strategy = routing.strategy || 'prefix';
|
|
30
|
+
const defaultPrefix = routing.defaultPrefix || 'include';
|
|
31
|
+
const defaultLocale = routing.defaultLocale;
|
|
32
|
+
const base = normalizePath(pathPattern);
|
|
33
|
+
if (strategy === 'none' || strategy === 'domain') {
|
|
34
|
+
return {
|
|
35
|
+
schema: LOCALE_ROUTE_REALIZATION_SCHEMA,
|
|
36
|
+
localeId,
|
|
37
|
+
path: base,
|
|
38
|
+
prefixed: false,
|
|
39
|
+
strategy,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
// prefix
|
|
43
|
+
const omitDefault = defaultPrefix === 'omit' && localeId === defaultLocale;
|
|
44
|
+
if (omitDefault) {
|
|
45
|
+
return {
|
|
46
|
+
schema: LOCALE_ROUTE_REALIZATION_SCHEMA,
|
|
47
|
+
localeId,
|
|
48
|
+
path: base,
|
|
49
|
+
prefixed: false,
|
|
50
|
+
strategy: 'prefix',
|
|
51
|
+
defaultPrefix: 'omit',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const path = base === '/' ? `/${localeId}` : `/${localeId}${base}`;
|
|
55
|
+
return {
|
|
56
|
+
schema: LOCALE_ROUTE_REALIZATION_SCHEMA,
|
|
57
|
+
localeId,
|
|
58
|
+
path,
|
|
59
|
+
prefixed: true,
|
|
60
|
+
strategy: 'prefix',
|
|
61
|
+
defaultPrefix,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Build RouteId × LocaleId realization table.
|
|
66
|
+
* @param {{
|
|
67
|
+
* routes: Array<{ routeId: string, path: string }>,
|
|
68
|
+
* locales: string[],
|
|
69
|
+
* defaultLocale: string,
|
|
70
|
+
* routing?: { strategy?: string, defaultPrefix?: string },
|
|
71
|
+
* }} input
|
|
72
|
+
*/
|
|
73
|
+
export function buildLocaleRouteRealizationTable(input) {
|
|
74
|
+
const routing = {
|
|
75
|
+
strategy: input.routing?.strategy || 'prefix',
|
|
76
|
+
defaultPrefix: input.routing?.defaultPrefix || 'include',
|
|
77
|
+
defaultLocale: input.defaultLocale,
|
|
78
|
+
};
|
|
79
|
+
/** @type {any[]} */
|
|
80
|
+
const realizations = [];
|
|
81
|
+
/** @type {Map<string, string>} */
|
|
82
|
+
const pathOwners = new Map();
|
|
83
|
+
/** @type {any[]} */
|
|
84
|
+
const diagnostics = [];
|
|
85
|
+
for (const route of input.routes || []) {
|
|
86
|
+
for (const localeId of input.locales || []) {
|
|
87
|
+
const r = realizeRoutePath(localeId, route.path, routing);
|
|
88
|
+
const entry = {
|
|
89
|
+
...r,
|
|
90
|
+
routeId: route.routeId,
|
|
91
|
+
pathPattern: normalizePath(route.path),
|
|
92
|
+
};
|
|
93
|
+
realizations.push(entry);
|
|
94
|
+
const prev = pathOwners.get(entry.path);
|
|
95
|
+
if (prev && prev !== `${route.routeId}@${localeId}`) {
|
|
96
|
+
diagnostics.push({
|
|
97
|
+
code: DIAG_LOCALE_ROUTE_COLLISION,
|
|
98
|
+
severity: 'error',
|
|
99
|
+
message: `path ${entry.path} claimed by ${prev} and ${route.routeId}@${localeId}`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
pathOwners.set(entry.path, `${route.routeId}@${localeId}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
schema: LOCALE_ROUTE_REALIZATION_SCHEMA,
|
|
109
|
+
status: diagnostics.length ? 'failed' : 'ready',
|
|
110
|
+
routing,
|
|
111
|
+
realizations,
|
|
112
|
+
diagnostics,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Absolute URL helper.
|
|
117
|
+
* @param {string} origin
|
|
118
|
+
* @param {string} path
|
|
119
|
+
*/
|
|
120
|
+
export function absoluteUrl(origin, path) {
|
|
121
|
+
const o = String(origin || '').replace(/\/$/, '');
|
|
122
|
+
const p = normalizePath(path);
|
|
123
|
+
return `${o}${p}`;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Locale-aware PageMeta for one RouteId × LocaleId.
|
|
127
|
+
* @param {{
|
|
128
|
+
* routeId: string,
|
|
129
|
+
* localeId: string,
|
|
130
|
+
* direction?: string,
|
|
131
|
+
* title: string,
|
|
132
|
+
* description?: string,
|
|
133
|
+
* origin: string,
|
|
134
|
+
* realizations: Array<{ routeId: string, localeId: string, path: string }>,
|
|
135
|
+
* locales: string[],
|
|
136
|
+
* defaultLocale: string,
|
|
137
|
+
* }} input
|
|
138
|
+
*/
|
|
139
|
+
export function buildLocalePageMeta(input) {
|
|
140
|
+
const forRoute = (input.realizations || []).filter((r) => r.routeId === input.routeId);
|
|
141
|
+
const self = forRoute.find((r) => r.localeId === input.localeId);
|
|
142
|
+
/** @type {any[]} */
|
|
143
|
+
const diagnostics = [];
|
|
144
|
+
if (!self?.path) {
|
|
145
|
+
diagnostics.push({
|
|
146
|
+
code: DIAG_LOCALE_CANONICAL_MISSING,
|
|
147
|
+
severity: 'error',
|
|
148
|
+
message: `no realization for ${input.routeId}@${input.localeId}`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const canonicalPath = self?.path || '/';
|
|
152
|
+
const canonical = absoluteUrl(input.origin, canonicalPath);
|
|
153
|
+
/** @type {Array<{ localeId: string, hreflang: string, href: string }>} */
|
|
154
|
+
const alternates = [];
|
|
155
|
+
for (const loc of input.locales || []) {
|
|
156
|
+
const hit = forRoute.find((r) => r.localeId === loc);
|
|
157
|
+
if (!hit) {
|
|
158
|
+
diagnostics.push({
|
|
159
|
+
code: DIAG_LOCALE_HREFLANG_INCOMPLETE,
|
|
160
|
+
severity: 'error',
|
|
161
|
+
message: `missing alternate realization for ${input.routeId}@${loc}`,
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
alternates.push({
|
|
166
|
+
localeId: loc,
|
|
167
|
+
hreflang: loc,
|
|
168
|
+
href: absoluteUrl(input.origin, hit.path),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const defaultHit = forRoute.find((r) => r.localeId === input.defaultLocale);
|
|
172
|
+
if (defaultHit) {
|
|
173
|
+
alternates.push({
|
|
174
|
+
localeId: input.defaultLocale,
|
|
175
|
+
hreflang: 'x-default',
|
|
176
|
+
href: absoluteUrl(input.origin, defaultHit.path),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
schema: LOCALE_PAGE_META_SCHEMA,
|
|
181
|
+
routeId: input.routeId,
|
|
182
|
+
locale: input.localeId,
|
|
183
|
+
htmlLang: input.localeId,
|
|
184
|
+
dir: input.direction || 'ltr',
|
|
185
|
+
title: input.title,
|
|
186
|
+
description: input.description || '',
|
|
187
|
+
canonical,
|
|
188
|
+
alternates,
|
|
189
|
+
diagnostics,
|
|
190
|
+
status: diagnostics.length ? 'failed' : 'ready',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* `<Link to="routeId">` retains current locale — never hand-written localized paths.
|
|
195
|
+
* @param {{
|
|
196
|
+
* to: string,
|
|
197
|
+
* currentLocale: string,
|
|
198
|
+
* realizations: Array<{ routeId: string, localeId: string, path: string }>,
|
|
199
|
+
* }} input
|
|
200
|
+
*/
|
|
201
|
+
export function resolveLinkHref(input) {
|
|
202
|
+
const to = input.to;
|
|
203
|
+
if (typeof to === 'string' && (to.startsWith('/') || /^https?:/i.test(to))) {
|
|
204
|
+
const looksLocalized = /^\/[a-z]{2,3}(-[a-z0-9]+)?(\/|$)/.test(to);
|
|
205
|
+
return {
|
|
206
|
+
schema: LOCALE_LINK_RESOLUTION_SCHEMA,
|
|
207
|
+
status: 'failed',
|
|
208
|
+
to,
|
|
209
|
+
href: null,
|
|
210
|
+
localeId: input.currentLocale,
|
|
211
|
+
diagnostics: [
|
|
212
|
+
{
|
|
213
|
+
code: DIAG_LOCALE_LINK_HARDCODED_PATH,
|
|
214
|
+
severity: 'error',
|
|
215
|
+
message: looksLocalized
|
|
216
|
+
? `Link must use RouteId, not localized path ${JSON.stringify(to)}`
|
|
217
|
+
: `Link must use RouteId, not path ${JSON.stringify(to)}`,
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const hit = (input.realizations || []).find((r) => r.routeId === to && r.localeId === input.currentLocale);
|
|
223
|
+
if (!hit) {
|
|
224
|
+
return {
|
|
225
|
+
schema: LOCALE_LINK_RESOLUTION_SCHEMA,
|
|
226
|
+
status: 'failed',
|
|
227
|
+
to,
|
|
228
|
+
href: null,
|
|
229
|
+
localeId: input.currentLocale,
|
|
230
|
+
diagnostics: [
|
|
231
|
+
{
|
|
232
|
+
code: DIAG_LOCALE_CANONICAL_MISSING,
|
|
233
|
+
severity: 'error',
|
|
234
|
+
message: `no realization for Link to=${to} locale=${input.currentLocale}`,
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
schema: LOCALE_LINK_RESOLUTION_SCHEMA,
|
|
241
|
+
status: 'ready',
|
|
242
|
+
to,
|
|
243
|
+
routeId: to,
|
|
244
|
+
localeId: input.currentLocale,
|
|
245
|
+
href: hit.path,
|
|
246
|
+
diagnostics: [],
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Parse locale prefix from a pathname under prefix strategy.
|
|
251
|
+
* @param {string} pathname
|
|
252
|
+
* @param {string[]} supportedLocales
|
|
253
|
+
*/
|
|
254
|
+
export function parseLocaleFromPath(pathname, supportedLocales) {
|
|
255
|
+
const parts = normalizePath(pathname).split('/').filter(Boolean);
|
|
256
|
+
if (!parts.length)
|
|
257
|
+
return { localeId: null, restPath: '/' };
|
|
258
|
+
if (supportedLocales.includes(parts[0])) {
|
|
259
|
+
const rest = parts.slice(1);
|
|
260
|
+
return { localeId: parts[0], restPath: rest.length ? `/${rest.join('/')}` : '/' };
|
|
261
|
+
}
|
|
262
|
+
return { localeId: null, restPath: normalizePath(pathname) };
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Plan redirect / negotiation for an incoming URL (omit-prefix aware).
|
|
266
|
+
* @param {{
|
|
267
|
+
* pathname: string,
|
|
268
|
+
* supportedLocales: string[],
|
|
269
|
+
* defaultLocale: string,
|
|
270
|
+
* routing?: { strategy?: string, defaultPrefix?: string },
|
|
271
|
+
* hostCandidates?: string[],
|
|
272
|
+
* preference?: string|null,
|
|
273
|
+
* userChoice?: string|null,
|
|
274
|
+
* }} input
|
|
275
|
+
*/
|
|
276
|
+
export function planLocalePathNavigation(input) {
|
|
277
|
+
const routing = {
|
|
278
|
+
strategy: input.routing?.strategy || 'prefix',
|
|
279
|
+
defaultPrefix: input.routing?.defaultPrefix || 'include',
|
|
280
|
+
defaultLocale: input.defaultLocale,
|
|
281
|
+
};
|
|
282
|
+
const parsed = parseLocaleFromPath(input.pathname, input.supportedLocales);
|
|
283
|
+
const negotiated = negotiateLocale({
|
|
284
|
+
supportedLocales: input.supportedLocales,
|
|
285
|
+
defaultLocale: input.defaultLocale,
|
|
286
|
+
routeLocale: parsed.localeId,
|
|
287
|
+
userChoice: input.userChoice,
|
|
288
|
+
preference: input.preference,
|
|
289
|
+
hostCandidates: input.hostCandidates,
|
|
290
|
+
});
|
|
291
|
+
/** @type {any[]} */
|
|
292
|
+
const diagnostics = [];
|
|
293
|
+
let redirectTo = null;
|
|
294
|
+
if (routing.strategy === 'prefix' && routing.defaultPrefix === 'omit') {
|
|
295
|
+
// Prefixed default locale must redirect to unprefixed canonical.
|
|
296
|
+
if (parsed.localeId === input.defaultLocale) {
|
|
297
|
+
redirectTo = parsed.restPath;
|
|
298
|
+
}
|
|
299
|
+
// Unprefixed path is always defaultLocale content — Accept-Language must not steal.
|
|
300
|
+
if (!parsed.localeId && negotiated !== input.defaultLocale && !input.userChoice && !input.preference) {
|
|
301
|
+
// Host candidates alone cannot rewrite unprefixed default path content.
|
|
302
|
+
// negotiated falls back through hostCandidates; force default for this URL.
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (routing.strategy === 'prefix' && routing.defaultPrefix === 'omit' && parsed.localeId === input.defaultLocale) {
|
|
306
|
+
if (!redirectTo) {
|
|
307
|
+
diagnostics.push({
|
|
308
|
+
code: DIAG_LOCALE_PREFIX_OMIT_WITHOUT_REDIRECT,
|
|
309
|
+
severity: 'error',
|
|
310
|
+
message: `default locale ${input.defaultLocale} prefixed URL lacks redirect to canonical`,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const realized = realizeRoutePath(negotiated, parsed.localeId ? parsed.restPath : parsed.restPath, routing);
|
|
315
|
+
return {
|
|
316
|
+
schema: LOCALE_ROUTER_CHECK_SCHEMA,
|
|
317
|
+
kind: 'path_navigation',
|
|
318
|
+
pathname: normalizePath(input.pathname),
|
|
319
|
+
routeLocale: parsed.localeId,
|
|
320
|
+
negotiatedLocale: negotiated,
|
|
321
|
+
restPath: parsed.restPath,
|
|
322
|
+
redirectTo,
|
|
323
|
+
realizedPath: realized.path,
|
|
324
|
+
diagnostics,
|
|
325
|
+
// Unprefixed public path is bound to defaultLocale when omit — not Accept-Language.
|
|
326
|
+
contentLocale: routing.defaultPrefix === 'omit' && !parsed.localeId ? input.defaultLocale : negotiated,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Locale-aware cache key — must include LocaleId so Accept-Language cannot steal content.
|
|
331
|
+
* @param {{ routeId: string, localeId: string, path: string }} input
|
|
332
|
+
*/
|
|
333
|
+
export function localeAwareCacheKey(input) {
|
|
334
|
+
return `locale=${input.localeId}|route=${input.routeId}|path=${normalizePath(input.path)}`;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Reject Vary: Accept-Language on a cache key that does not encode LocaleId.
|
|
338
|
+
* @param {{ cacheKey: string, varyAcceptLanguage?: boolean, localeId?: string }} input
|
|
339
|
+
*/
|
|
340
|
+
export function assertLocaleCacheKey(input) {
|
|
341
|
+
/** @type {any[]} */
|
|
342
|
+
const diagnostics = [];
|
|
343
|
+
const key = String(input.cacheKey || '');
|
|
344
|
+
const hasLocale = /(?:^|[|&])locale=/.test(key) || (input.localeId && key.includes(input.localeId));
|
|
345
|
+
if (input.varyAcceptLanguage && !hasLocale) {
|
|
346
|
+
diagnostics.push({
|
|
347
|
+
code: DIAG_LOCALE_CACHE_KEY_STEALS_CONTENT,
|
|
348
|
+
severity: 'error',
|
|
349
|
+
message: 'public cache key must include LocaleId; Accept-Language alone must not swap body',
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
return { ok: diagnostics.length === 0, diagnostics };
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* LocaleTransition must commit Route realization + PageMeta together.
|
|
356
|
+
* @param {{
|
|
357
|
+
* fromLocale: string,
|
|
358
|
+
* toLocale: string,
|
|
359
|
+
* routeId: string,
|
|
360
|
+
* realizations: Array<{ routeId: string, localeId: string, path: string }>,
|
|
361
|
+
* pageMetaByLocale: Record<string, { locale: string, canonical: string }>,
|
|
362
|
+
* }} input
|
|
363
|
+
*/
|
|
364
|
+
export function commitLocaleRouteMetaTransition(input) {
|
|
365
|
+
/** @type {any[]} */
|
|
366
|
+
const diagnostics = [];
|
|
367
|
+
const fromPath = (input.realizations || []).find((r) => r.routeId === input.routeId && r.localeId === input.fromLocale)?.path;
|
|
368
|
+
const toPath = (input.realizations || []).find((r) => r.routeId === input.routeId && r.localeId === input.toLocale)?.path;
|
|
369
|
+
const meta = input.pageMetaByLocale?.[input.toLocale];
|
|
370
|
+
if (!toPath) {
|
|
371
|
+
diagnostics.push({
|
|
372
|
+
code: DIAG_LOCALE_CANONICAL_MISSING,
|
|
373
|
+
severity: 'error',
|
|
374
|
+
message: `transition missing realization ${input.routeId}@${input.toLocale}`,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
if (!meta?.canonical) {
|
|
378
|
+
diagnostics.push({
|
|
379
|
+
code: DIAG_LOCALE_CANONICAL_MISSING,
|
|
380
|
+
severity: 'error',
|
|
381
|
+
message: `transition missing PageMeta canonical for ${input.toLocale}`,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
if (meta && meta.locale !== input.toLocale) {
|
|
385
|
+
diagnostics.push({
|
|
386
|
+
code: DIAG_LOCALE_META_LOCALE_MISMATCH,
|
|
387
|
+
severity: 'error',
|
|
388
|
+
message: `PageMeta.locale ${meta.locale} != transition ${input.toLocale}`,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
schema: LOCALE_ROUTER_CHECK_SCHEMA,
|
|
393
|
+
kind: 'locale_route_meta_transition',
|
|
394
|
+
status: diagnostics.length ? 'failed' : 'committed',
|
|
395
|
+
fromLocale: input.fromLocale,
|
|
396
|
+
toLocale: input.toLocale,
|
|
397
|
+
fromPath,
|
|
398
|
+
toPath,
|
|
399
|
+
pageMeta: meta || null,
|
|
400
|
+
diagnostics,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Aggregate I3 router/meta proof.
|
|
405
|
+
* @param {{
|
|
406
|
+
* manifest: {
|
|
407
|
+
* defaultLocale: string,
|
|
408
|
+
* locales: Array<{ id: string, direction?: string }>,
|
|
409
|
+
* routing?: { strategy?: string, defaultPrefix?: string },
|
|
410
|
+
* },
|
|
411
|
+
* routes: Array<{ routeId: string, path: string }>,
|
|
412
|
+
* titles?: Record<string, Record<string, string>>,
|
|
413
|
+
* origin?: string,
|
|
414
|
+
* }} input
|
|
415
|
+
*/
|
|
416
|
+
export function checkLocaleRouter(input) {
|
|
417
|
+
/** @type {any[]} */
|
|
418
|
+
const diagnostics = [];
|
|
419
|
+
const locales = (input.manifest?.locales || []).map((l) => l.id);
|
|
420
|
+
const directions = Object.fromEntries((input.manifest?.locales || []).map((l) => [l.id, l.direction || 'ltr']));
|
|
421
|
+
const defaultLocale = input.manifest?.defaultLocale;
|
|
422
|
+
const table = buildLocaleRouteRealizationTable({
|
|
423
|
+
routes: input.routes,
|
|
424
|
+
locales,
|
|
425
|
+
defaultLocale,
|
|
426
|
+
routing: input.manifest?.routing,
|
|
427
|
+
});
|
|
428
|
+
diagnostics.push(...table.diagnostics);
|
|
429
|
+
const origin = input.origin || 'https://example.test';
|
|
430
|
+
/** @type {any[]} */
|
|
431
|
+
const pageMetas = [];
|
|
432
|
+
for (const route of input.routes || []) {
|
|
433
|
+
for (const loc of locales) {
|
|
434
|
+
const title = input.titles?.[route.routeId]?.[loc] || input.titles?.[route.routeId]?.[defaultLocale] || route.routeId;
|
|
435
|
+
const meta = buildLocalePageMeta({
|
|
436
|
+
routeId: route.routeId,
|
|
437
|
+
localeId: loc,
|
|
438
|
+
direction: directions[loc],
|
|
439
|
+
title,
|
|
440
|
+
origin,
|
|
441
|
+
realizations: table.realizations,
|
|
442
|
+
locales,
|
|
443
|
+
defaultLocale,
|
|
444
|
+
});
|
|
445
|
+
diagnostics.push(...meta.diagnostics);
|
|
446
|
+
if (meta.locale !== loc) {
|
|
447
|
+
diagnostics.push({
|
|
448
|
+
code: DIAG_LOCALE_META_LOCALE_MISMATCH,
|
|
449
|
+
severity: 'error',
|
|
450
|
+
message: `PageMeta.locale ${meta.locale} != ${loc}`,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
pageMetas.push(meta);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
// omit-prefix requires redirect plan for prefixed default locale URLs
|
|
457
|
+
if (input.manifest?.routing?.defaultPrefix === 'omit') {
|
|
458
|
+
const sample = input.routes?.[0];
|
|
459
|
+
if (sample) {
|
|
460
|
+
const prefixedDefault = `/${defaultLocale}${normalizePath(sample.path)}`;
|
|
461
|
+
const nav = planLocalePathNavigation({
|
|
462
|
+
pathname: prefixedDefault,
|
|
463
|
+
supportedLocales: locales,
|
|
464
|
+
defaultLocale,
|
|
465
|
+
routing: input.manifest.routing,
|
|
466
|
+
});
|
|
467
|
+
if (!nav.redirectTo) {
|
|
468
|
+
diagnostics.push({
|
|
469
|
+
code: DIAG_LOCALE_PREFIX_OMIT_WITHOUT_REDIRECT,
|
|
470
|
+
severity: 'error',
|
|
471
|
+
message: `omit defaultPrefix requires redirect from ${prefixedDefault}`,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
diagnostics.push(...nav.diagnostics);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// Cache key must encode locale
|
|
478
|
+
const sampleReal = table.realizations[0];
|
|
479
|
+
if (sampleReal) {
|
|
480
|
+
const goodKey = localeAwareCacheKey({
|
|
481
|
+
routeId: sampleReal.routeId,
|
|
482
|
+
localeId: sampleReal.localeId,
|
|
483
|
+
path: sampleReal.path,
|
|
484
|
+
});
|
|
485
|
+
const good = assertLocaleCacheKey({ cacheKey: goodKey, varyAcceptLanguage: true, localeId: sampleReal.localeId });
|
|
486
|
+
diagnostics.push(...good.diagnostics);
|
|
487
|
+
const bad = assertLocaleCacheKey({
|
|
488
|
+
cacheKey: `route=${sampleReal.routeId}|path=${sampleReal.path}`,
|
|
489
|
+
varyAcceptLanguage: true,
|
|
490
|
+
});
|
|
491
|
+
// Expected failure — record as proof probe, not as check failure if we only assert detection.
|
|
492
|
+
if (bad.ok) {
|
|
493
|
+
diagnostics.push({
|
|
494
|
+
code: DIAG_LOCALE_CACHE_KEY_STEALS_CONTENT,
|
|
495
|
+
severity: 'error',
|
|
496
|
+
message: 'cache key probe failed to detect Accept-Language steal',
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
const hasErrors = diagnostics.some((d) => d.severity === 'error');
|
|
501
|
+
return {
|
|
502
|
+
schema: LOCALE_ROUTER_CHECK_SCHEMA,
|
|
503
|
+
status: hasErrors ? 'failed' : 'ready',
|
|
504
|
+
realizationTable: table,
|
|
505
|
+
pageMetas,
|
|
506
|
+
diagnostics,
|
|
507
|
+
};
|
|
508
|
+
}
|