slicejs-web-framework 4.1.1 → 4.2.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.
@@ -0,0 +1,73 @@
1
+ // api/framework/content.js — read and resolve the app's content documents on the server.
2
+ //
3
+ // Content lives in `<translator.path>/content/<name>.json` (the same files the browser
4
+ // fetches through `slice.t.content()`). The resolver is the runtime's pure helper, so the
5
+ // server and the browser cannot disagree about `copy` branches or fallbacks.
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { localize } from '../../Slice/Components/Structural/Translator/localized.js';
10
+ import { resolveContent } from '../../Slice/Components/Structural/Translator/content.js';
11
+
12
+ /**
13
+ * The folder that holds the content documents for a deployed app.
14
+ * @param {string} projectRoot
15
+ * @param {{ deployedFolder?: string, translator?: { path?: string } }} [options]
16
+ * @returns {string}
17
+ */
18
+ export function contentDirectory(projectRoot, options = {}) {
19
+ const deployedFolder = options.deployedFolder || 'src';
20
+ const configured = typeof options.translator?.path === 'string' && options.translator.path.trim()
21
+ ? options.translator.path
22
+ : '/locales';
23
+ const folder = configured.replace(/^\/+|\/+$/g, '') || 'locales';
24
+ return path.join(projectRoot, deployedFolder, folder, 'content');
25
+ }
26
+
27
+ /**
28
+ * Validate a document name before turning it into a path: `experience` or `teaching/pr2`,
29
+ * never an absolute path or a `..` segment.
30
+ * @param {string} name
31
+ * @returns {string}
32
+ */
33
+ export function normalizeContentName(name) {
34
+ const value = String(name ?? '').trim().replace(/^\/+|\/+$/g, '');
35
+ const valid = value && /^[a-z0-9][a-z0-9/_-]*$/i.test(value) && !value.split('/').some((segment) => !segment || segment === '.' || segment === '..');
36
+ if (!valid) throw new Error(`[slice.content] invalid content document name "${name}"`);
37
+ return value;
38
+ }
39
+
40
+ /**
41
+ * Read a content document from `<deployedFolder>/<translator.path>/content/<name>.json`.
42
+ * Throws with the full path when it cannot be read or parsed.
43
+ * @param {string} projectRoot
44
+ * @param {string} name
45
+ * @param {{ deployedFolder?: string, translator?: { path?: string } }} [options]
46
+ * @returns {Record<string, any>}
47
+ */
48
+ export function readContent(projectRoot, name, options = {}) {
49
+ const document = normalizeContentName(name);
50
+ const file = path.join(contentDirectory(projectRoot, options), `${document}.json`);
51
+ try {
52
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
53
+ } catch (error) {
54
+ throw new Error(`[slice.content] could not read "${document}" (${file}): ${error.message}`);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Resolve a content document for one locale (`copy` branches replaced, nested nodes flat).
60
+ * @param {any} document
61
+ * @param {{ defaultLocale?: string, fallbackLocale?: string|null, locales?: string[] }} [translator]
62
+ * @param {string} [locale]
63
+ * @returns {any}
64
+ */
65
+ export function resolveContentFor(document, translator = {}, locale = translator.defaultLocale || 'en') {
66
+ const defaultLocale = translator.defaultLocale || 'en';
67
+ return resolveContent(document, (value) => localize(value, {
68
+ locale: locale || defaultLocale,
69
+ fallbackLocale: translator.fallbackLocale || null,
70
+ defaultLocale,
71
+ locales: Array.isArray(translator.locales) && translator.locales.length ? translator.locales : [defaultLocale],
72
+ }));
73
+ }
@@ -9,6 +9,19 @@
9
9
 
10
10
  import fs from 'node:fs';
11
11
  import path from 'node:path';
12
+ import {
13
+ IntlCache,
14
+ directionOf,
15
+ formatMessage,
16
+ isPluralMessage,
17
+ lookup as lookupMessage
18
+ } from '../../Slice/Components/Structural/Translator/messages.js';
19
+ import {
20
+ localeOfUrl,
21
+ localizePath,
22
+ normalizeLocalePrefix,
23
+ splitLocalePath
24
+ } from '../../Slice/Components/Structural/Translator/localePath.js';
12
25
 
13
26
  export const SEO_VERSION = 1;
14
27
 
@@ -40,6 +53,136 @@ export function matchPathPattern(pattern, pathname) {
40
53
 
41
54
  const isPattern = (value) => /\$\{[^}]+\}/.test(String(value));
42
55
 
56
+ /**
57
+ * The translator as the server needs it: which locales exist, how a URL carries one, and
58
+ * `t()` for `{ t: 'key' }` fields. Same lookup order as the browser (locale → fallback →
59
+ * the key itself), using the dictionaries the client already downloads.
60
+ *
61
+ * @param {object|null} config the `translator` block from sliceConfig.json
62
+ * @param {Record<string, object>} [dictionaries] locale → messages
63
+ */
64
+ export function buildTranslatorContext(config, dictionaries = {}) {
65
+ const clean = (value) => (typeof value === 'string' && value.trim() ? value.trim() : '');
66
+ const enabled = config?.enabled === true && clean(config.defaultLocale) !== '';
67
+ const defaultLocale = enabled ? clean(config.defaultLocale) : null;
68
+ const declared = Array.isArray(config?.locales) ? config.locales.map(clean).filter(Boolean) : [];
69
+ const seen = new Map();
70
+ for (const locale of enabled ? [defaultLocale, ...declared] : []) {
71
+ const key = locale.toLowerCase();
72
+ if (!seen.has(key)) seen.set(key, locale);
73
+ }
74
+ const fallbackLocale = enabled && clean(config.fallbackLocale)
75
+ && clean(config.fallbackLocale).toLowerCase() !== defaultLocale.toLowerCase()
76
+ ? clean(config.fallbackLocale)
77
+ : null;
78
+ // The fallback is a real locale: the client lists it (setLocale accepts it), so the
79
+ // server must too or it would emit URLs it then treats as unknown prefixes.
80
+ if (fallbackLocale && !seen.has(fallbackLocale.toLowerCase())) seen.set(fallbackLocale.toLowerCase(), fallbackLocale);
81
+ const locales = enabled ? [...seen.values()] : [];
82
+ const localePrefix = enabled ? normalizeLocalePrefix(config.localePrefix) : false;
83
+ const intl = new IntlCache();
84
+
85
+ const entry = (locale, key) => {
86
+ const value = lookupMessage(dictionaries[locale], key);
87
+ if (value === undefined && fallbackLocale && locale !== fallbackLocale) {
88
+ return lookupMessage(dictionaries[fallbackLocale], key);
89
+ }
90
+ return value;
91
+ };
92
+
93
+ const t = (locale, key, params) => {
94
+ if (!enabled || typeof key !== 'string') return key;
95
+ const value = entry(locale, key);
96
+ if (value === undefined) return key;
97
+ return formatMessage(value, params, locale, intl) ?? key;
98
+ };
99
+
100
+ const localize = (pathname, locale) => (enabled
101
+ ? localizePath(pathname, locale || defaultLocale, { localePrefix, defaultLocale })
102
+ : pathname);
103
+
104
+ return {
105
+ enabled,
106
+ localePrefix,
107
+ defaultLocale,
108
+ fallbackLocale,
109
+ locales,
110
+ t,
111
+ has: (locale, key) => {
112
+ if (!enabled) return false;
113
+ const value = entry(locale, key);
114
+ return typeof value === 'string' || isPluralMessage(value);
115
+ },
116
+ localize,
117
+ split: (pathname) => (enabled && localePrefix
118
+ ? splitLocalePath(pathname, locales)
119
+ : { locale: null, path: pathname }),
120
+ localeOf: (pathname) => (enabled
121
+ ? localeOfUrl(pathname, { localePrefix, defaultLocale, locales })
122
+ : null),
123
+ direction: (locale) => directionOf(locale || defaultLocale || 'en'),
124
+ ogLocale: (locale) => String(locale || '').replace('-', '_')
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Read `{ locale: messages }` from `<folderDeployed>/<translator.path>/<locale>.json`.
130
+ * Missing files are tolerated (the key shows, as in the browser); a malformed one is an
131
+ * error the server cannot silently ignore.
132
+ */
133
+ export function loadTranslatorDictionaries(projectRoot, folderDeployed, config) {
134
+ if (config?.enabled !== true) return {};
135
+ const folder = String(config.path || '/locales').replace(/^\/+|\/+$/g, '') || 'locales';
136
+ const declared = [
137
+ config.defaultLocale,
138
+ ...(Array.isArray(config.locales) ? config.locales : []),
139
+ config.fallbackLocale
140
+ ].filter((value) => typeof value === 'string' && value);
141
+
142
+ const dictionaries = {};
143
+ for (const locale of [...new Set(declared)]) {
144
+ const base = path.join(projectRoot, folderDeployed, folder);
145
+ const file = path.join(base, `${locale}.json`);
146
+ let messages = null;
147
+ if (fs.existsSync(file)) {
148
+ try {
149
+ messages = JSON.parse(fs.readFileSync(file, 'utf8'));
150
+ } catch (error) {
151
+ throw new Error(`seo: invalid translator dictionary ${locale}.json: ${error.message}`);
152
+ }
153
+ }
154
+ // Namespaced dictionaries (`<path>/<locale>/<ns>.json`, loaded by translator.load())
155
+ // are part of the same locale; the browser merges them under the file name.
156
+ const namespaceDir = path.join(base, locale);
157
+ if (fs.existsSync(namespaceDir) && fs.statSync(namespaceDir).isDirectory()) {
158
+ messages = messages && typeof messages === 'object' ? messages : {};
159
+ for (const name of fs.readdirSync(namespaceDir)) {
160
+ if (!name.endsWith('.json')) continue;
161
+ const namespace = name.slice(0, -5);
162
+ try {
163
+ messages[namespace] = JSON.parse(fs.readFileSync(path.join(namespaceDir, name), 'utf8'));
164
+ } catch (error) {
165
+ throw new Error(`seo: invalid translator dictionary ${locale}/${name}: ${error.message}`);
166
+ }
167
+ }
168
+ }
169
+ if (messages) dictionaries[locale] = messages;
170
+ }
171
+ return dictionaries;
172
+ }
173
+
174
+ /**
175
+ * `{ t: 'key', params? }` → text. Anything else is returned untouched, so plain strings
176
+ * keep working in every locale. Exported for the server, the CLI and the tests.
177
+ */
178
+ export function resolveSeoText(value, translate) {
179
+ if (value && typeof value === 'object' && typeof value.t === 'string') {
180
+ return translate ? translate(value.t, value.params) : value.t;
181
+ }
182
+ return value;
183
+ }
184
+
185
+
43
186
  /**
44
187
  * The most specific manifest entry for a path: an exact key first, then the
45
188
  * longest matching pattern, then nothing.
@@ -105,21 +248,65 @@ const robotsDirectives = (robots) => {
105
248
 
106
249
  /**
107
250
  * Resolve one route's metadata: defaults -> manifest entry (literal or pattern)
108
- * -> inline `metadata` from routes.js (most specific, wins).
251
+ * -> inline `metadata` from routes.js (most specific, wins). With `translate`, every
252
+ * documented text field may be `{ t: 'key', params? }` (translator feature 0003).
109
253
  */
110
- export function resolveMetadata({ pathname, routeMetadata, manifest = {}, origin }) {
254
+ export function resolveMetadata({ pathname, routeMetadata, manifest = {}, origin, translate = null, url = pathname, routerConfig = {} }) {
255
+ const inline = routeMetadata && typeof routeMetadata === 'object' ? { ...routeMetadata } : null;
256
+ // `titleKey` / `descriptionKey` are the shorthand for `{ t: 'key' }`; they win
257
+ // over the literal `title` / `description`.
258
+ if (inline) {
259
+ if (typeof inline.titleKey === 'string' && inline.titleKey.trim()) {
260
+ inline.title = { t: inline.titleKey.trim() };
261
+ }
262
+ if (typeof inline.descriptionKey === 'string' && inline.descriptionKey.trim()) {
263
+ inline.description = { t: inline.descriptionKey.trim() };
264
+ }
265
+ }
266
+ const text = (value) => resolveSeoText(value, translate);
267
+ const defaultTitle = text(manifest.defaults?.title);
111
268
  const merged = mergeDeep(
112
269
  mergeDeep(manifest.defaults || {}, pickRouteEntry(pathname, manifest)?.entry),
113
- routeMetadata
270
+ inline
114
271
  );
115
272
 
116
- const title = merged.title ?? null;
117
- const template = merged.titleTemplate;
118
- const finalTitle = title && template && template !== '%s' ? template.replace('%s', title) : title;
273
+ // `titleKey` / `descriptionKey` may come from routes.js OR from a seo.json entry;
274
+ // normalise the merged object so both work (the manifest path was previously ignored).
275
+ if (typeof merged.titleKey === 'string' && merged.titleKey.trim()) merged.title = { t: merged.titleKey.trim() };
276
+ if (typeof merged.descriptionKey === 'string' && merged.descriptionKey.trim()) {
277
+ merged.description = { t: merged.descriptionKey.trim() };
278
+ }
279
+
280
+ // Always map `{ t }`: with no translate (translator off) the key is the honest output,
281
+ // never "[object Object]".
282
+ merged.title = text(merged.title);
283
+ merged.description = text(merged.description);
284
+ merged.imageAlt = text(merged.imageAlt);
285
+ if (merged.openGraph) {
286
+ merged.openGraph = {
287
+ ...merged.openGraph,
288
+ title: text(merged.openGraph.title),
289
+ description: text(merged.openGraph.description),
290
+ siteName: text(merged.openGraph.siteName)
291
+ };
292
+ }
293
+ if (merged.twitter) {
294
+ merged.twitter = {
295
+ ...merged.twitter,
296
+ title: text(merged.twitter.title),
297
+ description: text(merged.twitter.description)
298
+ };
299
+ }
300
+
301
+ const title = merged.title || defaultTitle || null;
302
+ const affix = (own, fallback) => (own === false ? '' : typeof own === 'string' ? own : typeof fallback === 'string' ? fallback : '');
303
+ const prefix = affix(text(merged.titlePrefix), routerConfig?.titlePrefix);
304
+ const suffix = affix(text(merged.titleSuffix), routerConfig?.titleSuffix);
305
+ const finalTitle = title ? `${prefix}${title}${suffix}` : null;
119
306
 
120
307
  const canonical = merged.canonical
121
308
  ? absolute(merged.canonical, origin)
122
- : `${origin}${pathname === '/' ? '/' : pathname}`;
309
+ : `${origin}${url === '/' ? '/' : url}`;
123
310
 
124
311
  const image = absolute(merged.image ?? merged.openGraph?.image ?? merged.openGraph?.images?.[0]?.url, origin);
125
312
 
@@ -151,24 +338,32 @@ export function serializeJsonLd(list) {
151
338
  }
152
339
 
153
340
  /** The `<head>` block for one route. Pure: no shell, no injection. */
154
- export function renderHead(meta, { siteName = null, locale = null } = {}) {
341
+ export function renderHead(meta, { siteName = null, locale = null, alternates = null } = {}) {
155
342
  const tags = [];
156
343
  if (meta.title) tags.push(`<title>${escapeHtml(meta.title)}</title>`);
157
344
  if (meta.description) tags.push(`<meta name="description" content="${escapeHtml(meta.description)}" />`);
158
345
  if (meta.canonical) tags.push(`<link rel="canonical" href="${escapeHtml(meta.canonical)}" />`);
346
+ if (Array.isArray(alternates)) {
347
+ for (const alternate of alternates) {
348
+ if (!alternate?.href || !alternate?.hreflang) continue;
349
+ tags.push(`<link rel="alternate" hreflang="${escapeHtml(alternate.hreflang)}" href="${escapeHtml(alternate.href)}" />`);
350
+ }
351
+ }
159
352
  tags.push(`<meta name="robots" content="${escapeHtml(meta.robots)}" />`);
160
353
 
161
- const ogType = meta.openGraph.type || 'website';
162
- const ogTitle = meta.openGraph.title || meta.title;
163
- const ogDescription = meta.openGraph.description || meta.description;
354
+ const openGraph = meta.openGraph || {};
355
+ const twitter = meta.twitter || {};
356
+ const ogType = openGraph.type || 'website';
357
+ const ogTitle = openGraph.title || meta.title;
358
+ const ogDescription = openGraph.description || meta.description;
164
359
 
165
360
  tags.push(`<meta property="og:type" content="${escapeHtml(ogType)}" />`);
166
361
  if (ogTitle) tags.push(`<meta property="og:title" content="${escapeHtml(ogTitle)}" />`);
167
362
  if (ogDescription) tags.push(`<meta property="og:description" content="${escapeHtml(ogDescription)}" />`);
168
363
  if (meta.canonical) tags.push(`<meta property="og:url" content="${escapeHtml(meta.canonical)}" />`);
169
- const ogSiteName = meta.openGraph.siteName || siteName;
364
+ const ogSiteName = openGraph.siteName || siteName;
170
365
  if (ogSiteName) tags.push(`<meta property="og:site_name" content="${escapeHtml(ogSiteName)}" />`);
171
- const ogLocale = meta.openGraph.locale || locale;
366
+ const ogLocale = openGraph.locale || locale;
172
367
  if (ogLocale) tags.push(`<meta property="og:locale" content="${escapeHtml(ogLocale)}" />`);
173
368
  if (meta.image) {
174
369
  tags.push(`<meta property="og:image" content="${escapeHtml(meta.image)}" />`);
@@ -178,11 +373,11 @@ export function renderHead(meta, { siteName = null, locale = null } = {}) {
178
373
  if (meta.imageAlt) tags.push(`<meta property="og:image:alt" content="${escapeHtml(meta.imageAlt)}" />`);
179
374
  }
180
375
 
181
- const card = meta.twitter.card
376
+ const card = twitter.card
182
377
  || (meta.image ? 'summary_large_image' : 'summary');
183
378
  tags.push(`<meta name="twitter:card" content="${escapeHtml(card)}" />`);
184
- if (meta.twitter.site) tags.push(`<meta name="twitter:site" content="${escapeHtml(meta.twitter.site)}" />`);
185
- if (meta.twitter.creator) tags.push(`<meta name="twitter:creator" content="${escapeHtml(meta.twitter.creator)}" />`);
379
+ if (twitter.site) tags.push(`<meta name="twitter:site" content="${escapeHtml(twitter.site)}" />`);
380
+ if (twitter.creator) tags.push(`<meta name="twitter:creator" content="${escapeHtml(twitter.creator)}" />`);
186
381
  if (ogTitle) tags.push(`<meta name="twitter:title" content="${escapeHtml(ogTitle)}" />`);
187
382
  if (ogDescription) tags.push(`<meta name="twitter:description" content="${escapeHtml(ogDescription)}" />`);
188
383
  if (meta.image) tags.push(`<meta name="twitter:image" content="${escapeHtml(meta.image)}" />`);
@@ -203,9 +398,28 @@ const OWNED_HEAD = [
203
398
  /<script\s+type="application\/ld\+json"[^>]*>[\s\S]*?<\/script>/gi
204
399
  ];
205
400
 
401
+ /** Replace `<html lang|dir>` (or add them) in a shell. No-op when both are null. */
402
+ export function setDocumentLanguage(html, { lang = null, dir = null } = {}) {
403
+ if (!lang && !dir) return String(html);
404
+ return String(html).replace(/<html\b([^>]*)>/i, (match, attributes) => {
405
+ let next = attributes;
406
+ if (lang) {
407
+ next = /\slang="/i.test(next)
408
+ ? next.replace(/\slang="[^"]*"/i, ` lang="${lang}"`)
409
+ : `${next} lang="${lang}"`;
410
+ }
411
+ if (dir) {
412
+ next = /\sdir="/i.test(next)
413
+ ? next.replace(/\sdir="[^"]*"/i, ` dir="${dir}"`)
414
+ : `${next} dir="${dir}"`;
415
+ }
416
+ return `<html${next}>`;
417
+ });
418
+ }
419
+
206
420
  /** Replace this module's tags in a shell with the route's own block. */
207
- export function injectHead(html, headTags) {
208
- let output = String(html);
421
+ export function injectHead(html, headTags, options = {}) {
422
+ let output = setDocumentLanguage(html, options);
209
423
  for (const pattern of OWNED_HEAD) output = output.replace(pattern, '');
210
424
  const block = ` ${headTags}\n `;
211
425
  if (/<\/head>/i.test(output)) return output.replace(/<\/head>/i, `${block}</head>`);
@@ -222,40 +436,98 @@ const xmlEscape = (value) => String(value ?? '')
222
436
  /**
223
437
  * `sitemap.xml` from the manifest. Only literal routes that are indexable and
224
438
  * not explicitly excluded: a `${param}` pattern cannot be enumerated.
439
+ *
440
+ * With a translator context and `localePrefix`, every route appears once per locale and
441
+ * each `<url>` carries the `xhtml:link` alternates (plus `x-default`), so the
442
+ * sitemap and the `hreflang` in the head cannot disagree.
225
443
  */
226
- export function buildSitemap({ manifest, origin }) {
444
+ /**
445
+ * Expand a `${param}` sitemap entry into concrete paths.
446
+ * `values` are strings (single parameter) or objects (`{ slug: 'pr2' }`).
447
+ * @param {string} key
448
+ * @param {any[]} values
449
+ * @returns {string[]}
450
+ */
451
+ function expandPatternPaths(key, values) {
452
+ const names = [...key.matchAll(/\$\{([^}]+)\}/g)].map((match) => match[1]);
453
+ if (names.length === 0 || !Array.isArray(values)) return [];
454
+ const paths = [];
455
+ for (const value of values) {
456
+ const params = typeof value === 'string' ? { [names[0]]: value } : value;
457
+ if (!params || typeof params !== 'object') continue;
458
+ if (names.some((name) => params[name] === undefined || params[name] === null || params[name] === '')) continue;
459
+ paths.push(key.replace(/\$\{([^}]+)\}/g, (_, name) => encodeURIComponent(String(params[name]))));
460
+ }
461
+ return paths;
462
+ }
463
+
464
+ export function buildSitemap({ manifest, origin, translator = null }) {
227
465
  const base = normalizeOrigin(origin);
228
466
  if (!base) throw new Error('seo: buildSitemap needs an absolute origin');
229
467
 
468
+ const active = translator?.enabled ? translator : null;
469
+ // Without a locale prefix the URL does not identify a locale, so the sitemap has one
470
+ // URL per route (the default locale) and no alternates.
471
+ const locales = active
472
+ ? (active.localePrefix ? active.locales : [active.defaultLocale])
473
+ : [null];
230
474
  const urls = [];
475
+ const seen = new Set();
476
+
231
477
  for (const [key, entry] of Object.entries(manifest?.routes || {})) {
232
- if (isPattern(key)) continue;
233
478
  if (entry?.sitemap === false) continue;
234
479
  if (entry?.robots === false || entry?.robots?.index === false) continue;
235
480
  if (entry?.noindex) continue;
236
481
 
237
- const loc = `${base}${key === '/' ? '/' : key}`;
482
+ // A pattern cannot be enumerated by itself; it opts in with `sitemap.paths`.
483
+ const concretePaths = isPattern(key)
484
+ ? expandPatternPaths(key, entry?.sitemap?.paths)
485
+ : [key];
486
+ if (concretePaths.length === 0) continue;
487
+
238
488
  const sitemap = entry?.sitemap && typeof entry.sitemap === 'object' ? entry.sitemap : {};
239
489
  const lastmod = sitemap.lastmod || entry?.lastmod;
240
490
  const changefreq = sitemap.changefreq || entry?.changefreq;
241
491
  const priority = sitemap.priority || entry?.priority;
242
492
 
243
- // One field per line: a <lastmod> update is a one-line diff in review.
244
- const block = [
245
- ' <url>',
246
- ` <loc>${xmlEscape(loc)}</loc>`,
247
- lastmod ? ` <lastmod>${xmlEscape(lastmod)}</lastmod>` : '',
248
- changefreq ? ` <changefreq>${xmlEscape(changefreq)}</changefreq>` : '',
249
- priority ? ` <priority>${xmlEscape(priority)}</priority>` : '',
250
- ' </url>'
251
- ].filter(Boolean);
252
- urls.push({ loc, body: block.join('\n') });
493
+ for (const concrete of concretePaths) {
494
+ const paths = locales.map((locale) => (active ? active.localize(concrete, locale) : concrete));
495
+ const alternates = active?.localePrefix && locales.length > 1
496
+ ? locales
497
+ .map((locale, index) => ({
498
+ hreflang: locale,
499
+ href: `${base}${paths[index] === '/' ? '/' : paths[index]}`
500
+ }))
501
+ .concat([{
502
+ hreflang: 'x-default',
503
+ href: `${base}${active.localize(concrete, active.defaultLocale)}`
504
+ }])
505
+ : [];
506
+
507
+ for (const pathname of paths) {
508
+ const loc = `${base}${pathname === '/' ? '/' : pathname}`;
509
+ if (seen.has(loc)) continue;
510
+ seen.add(loc);
511
+ // One field per line: a <lastmod> update is a one-line diff in review.
512
+ const block = [
513
+ ' <url>',
514
+ ` <loc>${xmlEscape(loc)}</loc>`,
515
+ ...alternates.map((alternate) =>
516
+ ` <xhtml:link rel="alternate" hreflang="${xmlEscape(alternate.hreflang)}" href="${xmlEscape(alternate.href)}" />`),
517
+ lastmod ? ` <lastmod>${xmlEscape(lastmod)}</lastmod>` : '',
518
+ changefreq ? ` <changefreq>${xmlEscape(changefreq)}</changefreq>` : '',
519
+ priority ? ` <priority>${xmlEscape(priority)}</priority>` : '',
520
+ ' </url>'
521
+ ].filter(Boolean);
522
+ urls.push({ loc, body: block.join('\n') });
523
+ }
524
+ }
253
525
  }
254
526
 
255
527
  urls.sort((a, b) => a.loc.localeCompare(b.loc));
256
528
  return [
257
529
  '<?xml version="1.0" encoding="UTF-8"?>',
258
- '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
530
+ `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${active ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : ''}>`,
259
531
  ...urls.map((url) => url.body),
260
532
  '</urlset>',
261
533
  ''
@@ -265,10 +537,24 @@ export function buildSitemap({ manifest, origin }) {
265
537
  /** Route paths from `routes.js`, normalised (leading slash, no trailing slash). */
266
538
  export function routePaths(routes) {
267
539
  const list = Array.isArray(routes) ? routes : [];
268
- return list
269
- .map((route) => (typeof route === 'string' ? route : route?.path))
270
- .filter(Boolean)
271
- .map((value) => String(value).replace(/\/+$/, '') || '/');
540
+ const normalize = (base, value) => {
541
+ const merged = `${base || ''}${value || ''}`.replace(/\/+/g, '/');
542
+ return merged.length > 1 ? merged.replace(/\/+$/, '') || '/' : '/';
543
+ };
544
+ const paths = [];
545
+ const walk = (entries, base = '') => {
546
+ for (const route of Array.isArray(entries) ? entries : []) {
547
+ if (typeof route === 'string') {
548
+ paths.push(normalize(base, route));
549
+ continue;
550
+ }
551
+ const full = normalize(base, route?.path);
552
+ if (route?.path) paths.push(full);
553
+ if (Array.isArray(route?.children)) walk(route.children, full);
554
+ }
555
+ };
556
+ walk(list);
557
+ return paths;
272
558
  }
273
559
 
274
560
  /**
@@ -281,13 +567,51 @@ export function validateSeoManifest(manifest, options = {}) {
281
567
  fileExists = (file) => fs.existsSync(file),
282
568
  publicDir = null,
283
569
  titleLimit = DEFAULT_TITLE_LIMIT,
284
- descriptionLimit = DEFAULT_DESCRIPTION_LIMIT
570
+ descriptionLimit = DEFAULT_DESCRIPTION_LIMIT,
571
+ translator = null
285
572
  } = options;
286
573
 
287
574
  const errors = [];
288
575
  const warnings = [];
289
576
  const add = (list, message) => list.push(message);
290
577
 
578
+ /** `{ t: 'key' }` references in the fields a translator may fill. */
579
+ const keyRefsOf = (entry, where) => {
580
+ const refs = [];
581
+ const scan = (value, field) => {
582
+ if (value && typeof value === 'object' && !Array.isArray(value) && typeof value.t === 'string') {
583
+ refs.push({ key: value.t, where: `${where}.${field}` });
584
+ }
585
+ };
586
+ for (const field of ['title', 'description', 'imageAlt']) scan(entry?.[field], field);
587
+ for (const field of ['titleKey', 'descriptionKey']) {
588
+ if (typeof entry?.[field] === 'string' && entry[field].trim()) {
589
+ refs.push({ key: entry[field].trim(), where: `${where}.${field}` });
590
+ }
591
+ }
592
+ for (const group of ['openGraph', 'twitter']) {
593
+ for (const field of ['title', 'description', 'siteName']) scan(entry?.[group]?.[field], `${group}.${field}`);
594
+ }
595
+ return refs;
596
+ };
597
+
598
+ /**
599
+ * A `{ t }` key missing in one locale would ship the key as a title, so it blocks.
600
+ * The browser resolves the same way (locale → fallback → key).
601
+ */
602
+ const checkTranslations = (entry, where) => {
603
+ if (!translator?.enabled) return;
604
+ const refs = keyRefsOf(entry, where);
605
+ if (!refs.length) return;
606
+ for (const locale of translator.locales) {
607
+ for (const ref of refs) {
608
+ if (!translator.has(locale, ref.key)) {
609
+ add(errors, `missing translation "${ref.key}" for "${locale}" (${ref.where})`);
610
+ }
611
+ }
612
+ }
613
+ };
614
+
291
615
  if (!manifest || typeof manifest !== 'object') {
292
616
  return { errors: ['manifest is not an object'], warnings };
293
617
  }
@@ -306,6 +630,7 @@ export function validateSeoManifest(manifest, options = {}) {
306
630
  }
307
631
 
308
632
  const seen = new Map();
633
+ checkTranslations(manifest.defaults, 'defaults');
309
634
  for (const [key, entry] of Object.entries(manifest.routes || {})) {
310
635
  if (!key.startsWith('/')) add(errors, `route key must start with "/": ${key}`);
311
636
  if (key.length > 1 && key.endsWith('/')) add(errors, `route key must not end with "/": ${key}`);
@@ -316,10 +641,12 @@ export function validateSeoManifest(manifest, options = {}) {
316
641
  continue;
317
642
  }
318
643
 
319
- if (entry.title && String(entry.title).length > titleLimit) {
644
+ checkTranslations(entry, key);
645
+
646
+ if (typeof entry.title === 'string' && entry.title.length > titleLimit) {
320
647
  add(warnings, `title longer than ${titleLimit} chars: ${key}`);
321
648
  }
322
- if (entry.description && String(entry.description).length > descriptionLimit) {
649
+ if (typeof entry.description === 'string' && entry.description.length > descriptionLimit) {
323
650
  add(warnings, `description longer than ${descriptionLimit} chars: ${key}`);
324
651
  }
325
652
 
@@ -335,6 +662,22 @@ export function validateSeoManifest(manifest, options = {}) {
335
662
  if (indexable && entry.sitemap === false) {
336
663
  add(warnings, `indexable page excluded from the sitemap: ${key}`);
337
664
  }
665
+
666
+ if (isPattern(key) && entry.sitemap && typeof entry.sitemap === 'object' && Array.isArray(entry.sitemap.paths)) {
667
+ const names = [...key.matchAll(/\$\{([^}]+)\}/g)].map((match) => match[1]);
668
+ for (const value of entry.sitemap.paths) {
669
+ const params = typeof value === 'string' ? { [names[0]]: value } : value;
670
+ if (!params || typeof params !== 'object') {
671
+ add(errors, `sitemap.paths entry must be a string or an object: ${key}`);
672
+ continue;
673
+ }
674
+ for (const name of names) {
675
+ if (params[name] === undefined || params[name] === null || params[name] === '') {
676
+ add(errors, `sitemap.paths entry is missing "${name}": ${key}`);
677
+ }
678
+ }
679
+ }
680
+ }
338
681
  }
339
682
 
340
683
  if (routes) {
@@ -357,16 +700,24 @@ export function validateSeoManifest(manifest, options = {}) {
357
700
  // sends for the same path, the tab title changes after hydration — and a
358
701
  // crawler that runs JS sees the shorter one. Catch it here, not in production.
359
702
  const origin = normalizeOrigin(manifest.origin) || '';
703
+ const translate = translator?.enabled
704
+ ? (key, params) => translator.t(translator.defaultLocale, key, params)
705
+ : null;
360
706
  for (const route of (Array.isArray(routes) ? routes : [])) {
361
707
  const pathname = typeof route === 'string' ? route : route?.path;
362
708
  const metadata = typeof route === 'object' ? route?.metadata : null;
363
- if (!pathname || !metadata?.title) continue;
364
- const served = resolveMetadata({ pathname, manifest, origin });
365
- const client = resolveMetadata({ pathname, manifest: {}, origin, routeMetadata: metadata });
709
+ if (!pathname || !metadata) continue;
710
+ checkTranslations(metadata, `routes.js ${pathname}`);
711
+ if (!metadata.title && !metadata.titleKey) continue;
712
+ if (metadata.title && typeof metadata.title !== 'string') continue;
713
+ // A missing titleKey is already an error; comparing it to seo.json would only add noise.
714
+ if (metadata.titleKey && translator?.enabled && !translator.has(translator.defaultLocale, metadata.titleKey)) continue;
715
+ const served = resolveMetadata({ pathname, manifest, origin, translate });
716
+ const client = resolveMetadata({ pathname, manifest: {}, origin, routeMetadata: metadata, translate });
366
717
  if (served.title && client.title && served.title !== client.title) {
367
718
  add(
368
719
  warnings,
369
- `route title differs from seo.json: ${pathname} ("${metadata.title}" vs "${served.title}")`
720
+ `route title differs from seo.json: ${pathname} ("${client.title}" vs "${served.title}")`
370
721
  );
371
722
  }
372
723
  }