slicejs-web-framework 4.0.2 → 4.1.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/LICENSE +21 -21
- package/README.md +97 -97
- package/Slice/Components/Structural/ContextManager/ContextManager.js +422 -422
- package/Slice/Components/Structural/ContextManager/ContextManagerDebugger.js +470 -470
- package/Slice/Components/Structural/Controller/Controller.js +1406 -1406
- package/Slice/Components/Structural/Controller/allowedValuesValidation.js +52 -52
- package/Slice/Components/Structural/Debugger/Debugger.html +72 -72
- package/Slice/Components/Structural/Debugger/Debugger.js +1505 -1505
- package/Slice/Components/Structural/EventManager/EventManager.js +651 -651
- package/Slice/Components/Structural/EventManager/EventManagerDebugger.js +985 -985
- package/Slice/Components/Structural/LeakInspector/LeakInspector.js +296 -296
- package/Slice/Components/Structural/Logger/Log.js +10 -10
- package/Slice/Components/Structural/Logger/LogViewer/LogViewer.js +662 -662
- package/Slice/Components/Structural/Logger/Logger.js +225 -225
- package/Slice/Components/Structural/Router/Router.js +820 -810
- package/Slice/Components/Structural/Router/applyRouteMetadata.js +39 -0
- package/Slice/Components/Structural/StylesManager/StylesManager.js +82 -82
- package/Slice/Components/Structural/StylesManager/ThemeManager/ThemeManager.js +175 -175
- package/Slice/Slice.js +711 -710
- package/api/framework/devDepsOptimizer.js +335 -335
- package/api/framework/express.js +3 -3
- package/api/framework/precompressedStatic.js +102 -93
- package/api/framework/publicEnvResolver.js +123 -123
- package/api/framework/securityMiddleware.js +197 -197
- package/api/framework/seo.js +387 -0
- package/api/framework/server.js +777 -410
- package/api/index.js +14 -4
- package/package.json +6 -2
- package/sliceConfig.schema.json +122 -122
- package/src/App/index.html +69 -69
- package/src/App/index.js +20 -20
- package/src/App/style.css +11 -11
- package/src/Components/AppComponents/AboutSection/AboutSection.css +9 -9
- package/src/Components/AppComponents/AboutSection/AboutSection.html +8 -8
- package/src/Components/AppComponents/AboutSection/AboutSection.js +12 -12
- package/src/Components/AppComponents/AppShell/AppShell.css +10 -10
- package/src/Components/AppComponents/AppShell/AppShell.html +4 -4
- package/src/Components/AppComponents/AppShell/AppShell.js +36 -36
- package/src/Components/AppComponents/HomeSection/HomeSection.css +20 -20
- package/src/Components/AppComponents/HomeSection/HomeSection.html +10 -10
- package/src/Components/AppComponents/HomeSection/HomeSection.js +19 -19
- package/src/Components/components.js +8 -8
- package/src/public/Styles/sliceStyles.css +34 -34
- package/src/public/Themes/Dark.css +42 -42
- package/src/public/Themes/Light.css +31 -31
- package/src/public/Themes/Slice.css +47 -47
- package/src/public/manifest.json +15 -15
- package/src/public/robots.txt +4 -4
- package/src/public/sitemap.xml +8 -8
- package/src/routes.js +9 -9
- package/src/sliceConfig.json +72 -72
- package/types/index.d.ts +219 -219
- package/.opencode/opencode.json +0 -15
- package/Slice/tests/build-bundled-component-without-category.test.js +0 -103
- package/Slice/tests/build-js-only-visual-components.test.js +0 -144
- package/Slice/tests/build-singleton.test.js +0 -244
- package/Slice/tests/bundle-v2-runtime-contract.test.js +0 -747
- package/Slice/tests/context-patch-use.test.js +0 -95
- package/Slice/tests/destroy-cascade.test.js +0 -215
- package/Slice/tests/event-emitters.test.js +0 -79
- package/Slice/tests/event-registry.test.js +0 -141
- package/Slice/tests/fixtures/real-runtime-loader.mjs +0 -21
- package/Slice/tests/leak-orphans.test.js +0 -131
- package/Slice/tests/props-allowed-values-validation.test.js +0 -119
- package/Slice/tests/public-env-runtime-accessors.test.js +0 -44
- package/Slice/tests/public-env-typed-accessors.test.js +0 -66
- package/Slice/tests/router-loading-finally.test.js +0 -68
- package/api/tests/dev-deps-optimizer.test.js +0 -137
- package/api/tests/dev-server-external.test.js +0 -56
- package/api/tests/precompressed-static.test.js +0 -119
- package/api/tests/public-env-resolver.test.js +0 -193
- package/tests/router-build-failure.test.js +0 -125
- package/tests/router-emit-ordering.test.js +0 -63
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// api/framework/seo.js — the mechanics of per-route metadata.
|
|
2
|
+
//
|
|
3
|
+
// Consumes two sources and never guesses:
|
|
4
|
+
// - `routes.js` (the app's route list): the only answer to "is this path a page?"
|
|
5
|
+
// - `seo.json` (the metadata manifest): titles, descriptions, images, JSON-LD.
|
|
6
|
+
//
|
|
7
|
+
// Everything here is pure or filesystem-only (no express, no DOM) so the server,
|
|
8
|
+
// the build command and the CLI validator share exactly one implementation.
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
export const SEO_VERSION = 1;
|
|
14
|
+
|
|
15
|
+
const DEFAULT_TITLE_LIMIT = 60;
|
|
16
|
+
const DEFAULT_DESCRIPTION_LIMIT = 160;
|
|
17
|
+
|
|
18
|
+
const escapeHtml = (value) => String(value ?? '')
|
|
19
|
+
.replace(/&/g, '&')
|
|
20
|
+
.replace(/</g, '<')
|
|
21
|
+
.replace(/>/g, '>')
|
|
22
|
+
.replace(/"/g, '"')
|
|
23
|
+
.replace(/'/g, ''');
|
|
24
|
+
|
|
25
|
+
/** `https://a.com/` -> `https://a.com`; anything not absolute is rejected. */
|
|
26
|
+
export function normalizeOrigin(value) {
|
|
27
|
+
const raw = String(value ?? '').trim();
|
|
28
|
+
if (!/^https?:\/\/[^/]+/i.test(raw)) return null;
|
|
29
|
+
return raw.replace(/\/+$/, '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `/producto/${id}` matched against `/producto/42` -> `{ id: '42' }`. */
|
|
33
|
+
export function matchPathPattern(pattern, pathname) {
|
|
34
|
+
const escaped = String(pattern)
|
|
35
|
+
.replace(/[.*+?^()|[\]\\]/g, '\\$&')
|
|
36
|
+
.replace(/\$\{([^}]+)\}/g, (_, name) => `(?<${name}>[^/]+)`);
|
|
37
|
+
const match = new RegExp(`^${escaped}$`).exec(pathname);
|
|
38
|
+
return match ? { ...match.groups } : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const isPattern = (value) => /\$\{[^}]+\}/.test(String(value));
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The most specific manifest entry for a path: an exact key first, then the
|
|
45
|
+
* longest matching pattern, then nothing.
|
|
46
|
+
*/
|
|
47
|
+
export function pickRouteEntry(pathname, manifest) {
|
|
48
|
+
const routes = manifest?.routes || {};
|
|
49
|
+
if (Object.prototype.hasOwnProperty.call(routes, pathname)) {
|
|
50
|
+
return { key: pathname, entry: routes[pathname], params: {} };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let best = null;
|
|
54
|
+
for (const [key, entry] of Object.entries(routes)) {
|
|
55
|
+
if (!isPattern(key)) continue;
|
|
56
|
+
const params = matchPathPattern(key, pathname);
|
|
57
|
+
if (!params) continue;
|
|
58
|
+
if (!best || key.length > best.key.length) best = { key, entry, params };
|
|
59
|
+
}
|
|
60
|
+
return best;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const mergeDeep = (base, override) => {
|
|
64
|
+
if (!override) return base;
|
|
65
|
+
if (!base) return override;
|
|
66
|
+
return {
|
|
67
|
+
...base,
|
|
68
|
+
...override,
|
|
69
|
+
openGraph: { ...(base.openGraph || {}), ...(override.openGraph || {}) },
|
|
70
|
+
twitter: { ...(base.twitter || {}), ...(override.twitter || {}) }
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const absolute = (value, origin) => {
|
|
75
|
+
const raw = String(value ?? '').trim();
|
|
76
|
+
if (!raw) return null;
|
|
77
|
+
if (/^https?:\/\//i.test(raw)) return raw;
|
|
78
|
+
return `${origin}${raw.startsWith('/') ? '' : '/'}${raw}`;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const IMAGE_TYPES = {
|
|
82
|
+
png: 'image/png',
|
|
83
|
+
jpg: 'image/jpeg',
|
|
84
|
+
jpeg: 'image/jpeg',
|
|
85
|
+
webp: 'image/webp',
|
|
86
|
+
avif: 'image/avif',
|
|
87
|
+
gif: 'image/gif'
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** `og:image:type` from the file extension, when it is a known one. */
|
|
91
|
+
const imageTypeFromUrl = (url) => {
|
|
92
|
+
if (!url) return null;
|
|
93
|
+
const extension = String(url).split('?')[0].split('#')[0].split('.').pop()?.toLowerCase();
|
|
94
|
+
return IMAGE_TYPES[extension] || null;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const robotsDirectives = (robots) => {
|
|
98
|
+
if (robots === false) return 'noindex, nofollow';
|
|
99
|
+
if (!robots || typeof robots !== 'object') return 'index, follow';
|
|
100
|
+
const parts = [robots.index === false ? 'noindex' : 'index', robots.follow === false ? 'nofollow' : 'follow'];
|
|
101
|
+
if (robots.noarchive) parts.push('noarchive');
|
|
102
|
+
if (robots.nosnippet) parts.push('nosnippet');
|
|
103
|
+
return parts.join(', ');
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve one route's metadata: defaults -> manifest entry (literal or pattern)
|
|
108
|
+
* -> inline `metadata` from routes.js (most specific, wins).
|
|
109
|
+
*/
|
|
110
|
+
export function resolveMetadata({ pathname, routeMetadata, manifest = {}, origin }) {
|
|
111
|
+
const merged = mergeDeep(
|
|
112
|
+
mergeDeep(manifest.defaults || {}, pickRouteEntry(pathname, manifest)?.entry),
|
|
113
|
+
routeMetadata
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const title = merged.title ?? null;
|
|
117
|
+
const template = merged.titleTemplate;
|
|
118
|
+
const finalTitle = title && template && template !== '%s' ? template.replace('%s', title) : title;
|
|
119
|
+
|
|
120
|
+
const canonical = merged.canonical
|
|
121
|
+
? absolute(merged.canonical, origin)
|
|
122
|
+
: `${origin}${pathname === '/' ? '/' : pathname}`;
|
|
123
|
+
|
|
124
|
+
const image = absolute(merged.image ?? merged.openGraph?.image ?? merged.openGraph?.images?.[0]?.url, origin);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
title: finalTitle,
|
|
128
|
+
description: merged.description ?? null,
|
|
129
|
+
canonical,
|
|
130
|
+
robots: robotsDirectives(merged.robots),
|
|
131
|
+
indexable: !(merged.robots === false || merged.robots?.index === false),
|
|
132
|
+
image,
|
|
133
|
+
imageAlt: merged.imageAlt ?? merged.openGraph?.images?.[0]?.alt ?? finalTitle,
|
|
134
|
+
imageWidth: merged.imageWidth ?? merged.openGraph?.images?.[0]?.width ?? null,
|
|
135
|
+
imageHeight: merged.imageHeight ?? merged.openGraph?.images?.[0]?.height ?? null,
|
|
136
|
+
imageType: merged.imageType ?? imageTypeFromUrl(image),
|
|
137
|
+
openGraph: merged.openGraph || {},
|
|
138
|
+
twitter: merged.twitter || {},
|
|
139
|
+
jsonLd: merged.jsonLd ?? null,
|
|
140
|
+
sitemap: merged.sitemap !== false
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** JSON-LD must never close its own script tag. */
|
|
145
|
+
export function serializeJsonLd(list) {
|
|
146
|
+
const entries = Array.isArray(list) ? list : list ? [list] : [];
|
|
147
|
+
return entries
|
|
148
|
+
.map((entry) => JSON.stringify({ '@context': 'https://schema.org', ...entry }).replace(/</g, '\\u003c'))
|
|
149
|
+
.map((json) => `<script type="application/ld+json">${json}</script>`)
|
|
150
|
+
.join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The `<head>` block for one route. Pure: no shell, no injection. */
|
|
154
|
+
export function renderHead(meta, { siteName = null, locale = null } = {}) {
|
|
155
|
+
const tags = [];
|
|
156
|
+
if (meta.title) tags.push(`<title>${escapeHtml(meta.title)}</title>`);
|
|
157
|
+
if (meta.description) tags.push(`<meta name="description" content="${escapeHtml(meta.description)}" />`);
|
|
158
|
+
if (meta.canonical) tags.push(`<link rel="canonical" href="${escapeHtml(meta.canonical)}" />`);
|
|
159
|
+
tags.push(`<meta name="robots" content="${escapeHtml(meta.robots)}" />`);
|
|
160
|
+
|
|
161
|
+
const ogType = meta.openGraph.type || 'website';
|
|
162
|
+
const ogTitle = meta.openGraph.title || meta.title;
|
|
163
|
+
const ogDescription = meta.openGraph.description || meta.description;
|
|
164
|
+
|
|
165
|
+
tags.push(`<meta property="og:type" content="${escapeHtml(ogType)}" />`);
|
|
166
|
+
if (ogTitle) tags.push(`<meta property="og:title" content="${escapeHtml(ogTitle)}" />`);
|
|
167
|
+
if (ogDescription) tags.push(`<meta property="og:description" content="${escapeHtml(ogDescription)}" />`);
|
|
168
|
+
if (meta.canonical) tags.push(`<meta property="og:url" content="${escapeHtml(meta.canonical)}" />`);
|
|
169
|
+
const ogSiteName = meta.openGraph.siteName || siteName;
|
|
170
|
+
if (ogSiteName) tags.push(`<meta property="og:site_name" content="${escapeHtml(ogSiteName)}" />`);
|
|
171
|
+
const ogLocale = meta.openGraph.locale || locale;
|
|
172
|
+
if (ogLocale) tags.push(`<meta property="og:locale" content="${escapeHtml(ogLocale)}" />`);
|
|
173
|
+
if (meta.image) {
|
|
174
|
+
tags.push(`<meta property="og:image" content="${escapeHtml(meta.image)}" />`);
|
|
175
|
+
if (meta.imageWidth) tags.push(`<meta property="og:image:width" content="${escapeHtml(meta.imageWidth)}" />`);
|
|
176
|
+
if (meta.imageHeight) tags.push(`<meta property="og:image:height" content="${escapeHtml(meta.imageHeight)}" />`);
|
|
177
|
+
if (meta.imageType) tags.push(`<meta property="og:image:type" content="${escapeHtml(meta.imageType)}" />`);
|
|
178
|
+
if (meta.imageAlt) tags.push(`<meta property="og:image:alt" content="${escapeHtml(meta.imageAlt)}" />`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const card = meta.twitter.card
|
|
182
|
+
|| (meta.image ? 'summary_large_image' : 'summary');
|
|
183
|
+
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)}" />`);
|
|
186
|
+
if (ogTitle) tags.push(`<meta name="twitter:title" content="${escapeHtml(ogTitle)}" />`);
|
|
187
|
+
if (ogDescription) tags.push(`<meta name="twitter:description" content="${escapeHtml(ogDescription)}" />`);
|
|
188
|
+
if (meta.image) tags.push(`<meta name="twitter:image" content="${escapeHtml(meta.image)}" />`);
|
|
189
|
+
|
|
190
|
+
const jsonLd = meta.jsonLd ? serializeJsonLd(meta.jsonLd) : '';
|
|
191
|
+
return [...tags, jsonLd].filter(Boolean).join('\n ');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Everything this module owns in the `<head>`. Removed before inserting, so the
|
|
195
|
+
// shell's generic tags can never survive next to a route's own.
|
|
196
|
+
const OWNED_HEAD = [
|
|
197
|
+
/<title>[^<]*<\/title>/i,
|
|
198
|
+
/<meta\s+name="description"[^>]*?\/?>/gi,
|
|
199
|
+
/<link\s+rel="canonical"[^>]*?\/?>/gi,
|
|
200
|
+
/<meta\s+property="og:[^"]*"[^>]*?\/?>/gi,
|
|
201
|
+
/<meta\s+name="twitter:[^"]*"[^>]*?\/?>/gi,
|
|
202
|
+
/<meta\s+name="robots"[^>]*?\/?>/gi,
|
|
203
|
+
/<script\s+type="application\/ld\+json"[^>]*>[\s\S]*?<\/script>/gi
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
/** 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);
|
|
209
|
+
for (const pattern of OWNED_HEAD) output = output.replace(pattern, '');
|
|
210
|
+
const block = ` ${headTags}\n `;
|
|
211
|
+
if (/<\/head>/i.test(output)) return output.replace(/<\/head>/i, `${block}</head>`);
|
|
212
|
+
return `${output}\n${block}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const xmlEscape = (value) => String(value ?? '')
|
|
216
|
+
.replace(/&/g, '&')
|
|
217
|
+
.replace(/</g, '<')
|
|
218
|
+
.replace(/>/g, '>')
|
|
219
|
+
.replace(/"/g, '"')
|
|
220
|
+
.replace(/'/g, ''');
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* `sitemap.xml` from the manifest. Only literal routes that are indexable and
|
|
224
|
+
* not explicitly excluded: a `${param}` pattern cannot be enumerated.
|
|
225
|
+
*/
|
|
226
|
+
export function buildSitemap({ manifest, origin }) {
|
|
227
|
+
const base = normalizeOrigin(origin);
|
|
228
|
+
if (!base) throw new Error('seo: buildSitemap needs an absolute origin');
|
|
229
|
+
|
|
230
|
+
const urls = [];
|
|
231
|
+
for (const [key, entry] of Object.entries(manifest?.routes || {})) {
|
|
232
|
+
if (isPattern(key)) continue;
|
|
233
|
+
if (entry?.sitemap === false) continue;
|
|
234
|
+
if (entry?.robots === false || entry?.robots?.index === false) continue;
|
|
235
|
+
if (entry?.noindex) continue;
|
|
236
|
+
|
|
237
|
+
const loc = `${base}${key === '/' ? '/' : key}`;
|
|
238
|
+
const sitemap = entry?.sitemap && typeof entry.sitemap === 'object' ? entry.sitemap : {};
|
|
239
|
+
const lastmod = sitemap.lastmod || entry?.lastmod;
|
|
240
|
+
const changefreq = sitemap.changefreq || entry?.changefreq;
|
|
241
|
+
const priority = sitemap.priority || entry?.priority;
|
|
242
|
+
|
|
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') });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
urls.sort((a, b) => a.loc.localeCompare(b.loc));
|
|
256
|
+
return [
|
|
257
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
258
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
259
|
+
...urls.map((url) => url.body),
|
|
260
|
+
'</urlset>',
|
|
261
|
+
''
|
|
262
|
+
].join('\n');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Route paths from `routes.js`, normalised (leading slash, no trailing slash). */
|
|
266
|
+
export function routePaths(routes) {
|
|
267
|
+
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(/\/+$/, '') || '/');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The one validator: `slice seo check` and `slice doctor` both call this, so
|
|
276
|
+
* they cannot disagree. `fileExists` is injectable to keep it unit-testable.
|
|
277
|
+
*/
|
|
278
|
+
export function validateSeoManifest(manifest, options = {}) {
|
|
279
|
+
const {
|
|
280
|
+
routes = null,
|
|
281
|
+
fileExists = (file) => fs.existsSync(file),
|
|
282
|
+
publicDir = null,
|
|
283
|
+
titleLimit = DEFAULT_TITLE_LIMIT,
|
|
284
|
+
descriptionLimit = DEFAULT_DESCRIPTION_LIMIT
|
|
285
|
+
} = options;
|
|
286
|
+
|
|
287
|
+
const errors = [];
|
|
288
|
+
const warnings = [];
|
|
289
|
+
const add = (list, message) => list.push(message);
|
|
290
|
+
|
|
291
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
292
|
+
return { errors: ['manifest is not an object'], warnings };
|
|
293
|
+
}
|
|
294
|
+
if (manifest.version !== SEO_VERSION) {
|
|
295
|
+
add(errors, `unsupported version ${JSON.stringify(manifest.version)} (expected ${SEO_VERSION})`);
|
|
296
|
+
}
|
|
297
|
+
if (!manifest.origin) {
|
|
298
|
+
add(errors, 'origin is required');
|
|
299
|
+
} else if (!normalizeOrigin(manifest.origin)) {
|
|
300
|
+
add(errors, `origin must be an absolute http(s) URL without a path: ${JSON.stringify(manifest.origin)}`);
|
|
301
|
+
} else if (manifest.origin !== normalizeOrigin(manifest.origin)) {
|
|
302
|
+
add(errors, 'origin must not end with a slash');
|
|
303
|
+
}
|
|
304
|
+
if (manifest.origin && /localhost|127\.0\.0\.1|0\.0\.0\.0|\.local\b/i.test(manifest.origin)) {
|
|
305
|
+
add(warnings, 'origin points at localhost; canonical and og:url will leak it after deploy');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const seen = new Map();
|
|
309
|
+
for (const [key, entry] of Object.entries(manifest.routes || {})) {
|
|
310
|
+
if (!key.startsWith('/')) add(errors, `route key must start with "/": ${key}`);
|
|
311
|
+
if (key.length > 1 && key.endsWith('/')) add(errors, `route key must not end with "/": ${key}`);
|
|
312
|
+
if (seen.has(key)) add(errors, `duplicate route key: ${key}`);
|
|
313
|
+
seen.set(key, true);
|
|
314
|
+
if (!entry || typeof entry !== 'object') {
|
|
315
|
+
add(errors, `route entry must be an object: ${key}`);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (entry.title && String(entry.title).length > titleLimit) {
|
|
320
|
+
add(warnings, `title longer than ${titleLimit} chars: ${key}`);
|
|
321
|
+
}
|
|
322
|
+
if (entry.description && String(entry.description).length > descriptionLimit) {
|
|
323
|
+
add(warnings, `description longer than ${descriptionLimit} chars: ${key}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const image = entry.image;
|
|
327
|
+
if (typeof image === 'string' && !/^https?:\/\//i.test(image)) {
|
|
328
|
+
if (publicDir && !fileExists(path.join(publicDir, image.replace(/^\//, '')))) {
|
|
329
|
+
add(errors, `image not found in public/: ${image} (${key})`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const robots = entry.robots;
|
|
334
|
+
const indexable = !(robots === false || robots?.index === false);
|
|
335
|
+
if (indexable && entry.sitemap === false) {
|
|
336
|
+
add(warnings, `indexable page excluded from the sitemap: ${key}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (routes) {
|
|
341
|
+
for (const route of routePaths(routes)) {
|
|
342
|
+
const covered = Object.keys(manifest.routes || {}).some((key) =>
|
|
343
|
+
key === route || (isPattern(key) && matchPathPattern(key, route.replace(/\$\{[^}]+\}/g, 'x')))
|
|
344
|
+
);
|
|
345
|
+
if (!covered) add(warnings, `route has no metadata: ${route}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
for (const key of Object.keys(manifest.routes || {})) {
|
|
349
|
+
const matches = routePaths(routes).some((route) =>
|
|
350
|
+
route === key || (isPattern(key) && matchPathPattern(key, route.replace(/\$\{[^}]+\}/g, 'x')))
|
|
351
|
+
);
|
|
352
|
+
if (!matches) add(warnings, `metadata entry has no route: ${key}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// The client applies `routes.js` metadata on every navigation (the Router's
|
|
356
|
+
// `updateTitles`, on by default). If that title differs from what the server
|
|
357
|
+
// sends for the same path, the tab title changes after hydration — and a
|
|
358
|
+
// crawler that runs JS sees the shorter one. Catch it here, not in production.
|
|
359
|
+
const origin = normalizeOrigin(manifest.origin) || '';
|
|
360
|
+
for (const route of (Array.isArray(routes) ? routes : [])) {
|
|
361
|
+
const pathname = typeof route === 'string' ? route : route?.path;
|
|
362
|
+
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 });
|
|
366
|
+
if (served.title && client.title && served.title !== client.title) {
|
|
367
|
+
add(
|
|
368
|
+
warnings,
|
|
369
|
+
`route title differs from seo.json: ${pathname} ("${metadata.title}" vs "${served.title}")`
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return { errors, warnings };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Read `seo.json` (or a JS module exporting the same object). */
|
|
379
|
+
export function loadSeoManifest(file) {
|
|
380
|
+
const resolved = path.resolve(file);
|
|
381
|
+
if (!fs.existsSync(resolved)) return null;
|
|
382
|
+
if (/\.(c|m)?js$/i.test(resolved)) {
|
|
383
|
+
// Caller must import it: this module stays synchronous for the build path.
|
|
384
|
+
throw new Error(`seo: use import() for JS manifests: ${resolved}`);
|
|
385
|
+
}
|
|
386
|
+
return JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
|
387
|
+
}
|