slicejs-web-framework 4.1.0 → 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.
- package/Slice/Components/Structural/Controller/Controller.js +1427 -1406
- package/Slice/Components/Structural/EventManager/EventManager.js +652 -651
- package/Slice/Components/Structural/Router/Router.js +982 -820
- package/Slice/Components/Structural/Router/applyRouteMetadata.js +151 -8
- package/Slice/Components/Structural/Translator/Translator.js +1111 -0
- package/Slice/Components/Structural/Translator/TranslatorDebugger.js +511 -0
- package/Slice/Components/Structural/Translator/content.js +188 -0
- package/Slice/Components/Structural/Translator/dom.js +253 -0
- package/Slice/Components/Structural/Translator/localePath.js +90 -0
- package/Slice/Components/Structural/Translator/localized.js +80 -0
- package/Slice/Components/Structural/Translator/markers.js +148 -0
- package/Slice/Components/Structural/Translator/messages.js +211 -0
- package/Slice/Slice.js +826 -711
- package/api/framework/content.js +73 -0
- package/api/framework/seo.js +394 -43
- package/api/framework/server.js +205 -28
- package/package.json +2 -1
- package/sliceConfig.schema.json +150 -122
- package/types/index.d.ts +347 -219
|
@@ -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
|
+
}
|
package/api/framework/seo.js
CHANGED
|
@@ -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
|
-
|
|
270
|
+
inline
|
|
114
271
|
);
|
|
115
272
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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}${
|
|
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
|
|
162
|
-
const
|
|
163
|
-
const
|
|
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 =
|
|
364
|
+
const ogSiteName = openGraph.siteName || siteName;
|
|
170
365
|
if (ogSiteName) tags.push(`<meta property="og:site_name" content="${escapeHtml(ogSiteName)}" />`);
|
|
171
|
-
const ogLocale =
|
|
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 =
|
|
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 (
|
|
185
|
-
if (
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
.
|
|
271
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
364
|
-
|
|
365
|
-
|
|
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} ("${
|
|
720
|
+
`route title differs from seo.json: ${pathname} ("${client.title}" vs "${served.title}")`
|
|
370
721
|
);
|
|
371
722
|
}
|
|
372
723
|
}
|