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.
Files changed (73) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +97 -97
  3. package/Slice/Components/Structural/ContextManager/ContextManager.js +422 -422
  4. package/Slice/Components/Structural/ContextManager/ContextManagerDebugger.js +470 -470
  5. package/Slice/Components/Structural/Controller/Controller.js +1406 -1406
  6. package/Slice/Components/Structural/Controller/allowedValuesValidation.js +52 -52
  7. package/Slice/Components/Structural/Debugger/Debugger.html +72 -72
  8. package/Slice/Components/Structural/Debugger/Debugger.js +1505 -1505
  9. package/Slice/Components/Structural/EventManager/EventManager.js +651 -651
  10. package/Slice/Components/Structural/EventManager/EventManagerDebugger.js +985 -985
  11. package/Slice/Components/Structural/LeakInspector/LeakInspector.js +296 -296
  12. package/Slice/Components/Structural/Logger/Log.js +10 -10
  13. package/Slice/Components/Structural/Logger/LogViewer/LogViewer.js +662 -662
  14. package/Slice/Components/Structural/Logger/Logger.js +225 -225
  15. package/Slice/Components/Structural/Router/Router.js +820 -810
  16. package/Slice/Components/Structural/Router/applyRouteMetadata.js +39 -0
  17. package/Slice/Components/Structural/StylesManager/StylesManager.js +82 -82
  18. package/Slice/Components/Structural/StylesManager/ThemeManager/ThemeManager.js +175 -175
  19. package/Slice/Slice.js +711 -710
  20. package/api/framework/devDepsOptimizer.js +335 -335
  21. package/api/framework/express.js +3 -3
  22. package/api/framework/precompressedStatic.js +102 -93
  23. package/api/framework/publicEnvResolver.js +123 -123
  24. package/api/framework/securityMiddleware.js +197 -197
  25. package/api/framework/seo.js +387 -0
  26. package/api/framework/server.js +777 -410
  27. package/api/index.js +14 -4
  28. package/package.json +6 -2
  29. package/sliceConfig.schema.json +122 -122
  30. package/src/App/index.html +69 -69
  31. package/src/App/index.js +20 -20
  32. package/src/App/style.css +11 -11
  33. package/src/Components/AppComponents/AboutSection/AboutSection.css +9 -9
  34. package/src/Components/AppComponents/AboutSection/AboutSection.html +8 -8
  35. package/src/Components/AppComponents/AboutSection/AboutSection.js +12 -12
  36. package/src/Components/AppComponents/AppShell/AppShell.css +10 -10
  37. package/src/Components/AppComponents/AppShell/AppShell.html +4 -4
  38. package/src/Components/AppComponents/AppShell/AppShell.js +36 -36
  39. package/src/Components/AppComponents/HomeSection/HomeSection.css +20 -20
  40. package/src/Components/AppComponents/HomeSection/HomeSection.html +10 -10
  41. package/src/Components/AppComponents/HomeSection/HomeSection.js +19 -19
  42. package/src/Components/components.js +8 -8
  43. package/src/public/Styles/sliceStyles.css +34 -34
  44. package/src/public/Themes/Dark.css +42 -42
  45. package/src/public/Themes/Light.css +31 -31
  46. package/src/public/Themes/Slice.css +47 -47
  47. package/src/public/manifest.json +15 -15
  48. package/src/public/robots.txt +4 -4
  49. package/src/public/sitemap.xml +8 -8
  50. package/src/routes.js +9 -9
  51. package/src/sliceConfig.json +72 -72
  52. package/types/index.d.ts +219 -219
  53. package/.opencode/opencode.json +0 -15
  54. package/Slice/tests/build-bundled-component-without-category.test.js +0 -103
  55. package/Slice/tests/build-js-only-visual-components.test.js +0 -144
  56. package/Slice/tests/build-singleton.test.js +0 -244
  57. package/Slice/tests/bundle-v2-runtime-contract.test.js +0 -747
  58. package/Slice/tests/context-patch-use.test.js +0 -95
  59. package/Slice/tests/destroy-cascade.test.js +0 -215
  60. package/Slice/tests/event-emitters.test.js +0 -79
  61. package/Slice/tests/event-registry.test.js +0 -141
  62. package/Slice/tests/fixtures/real-runtime-loader.mjs +0 -21
  63. package/Slice/tests/leak-orphans.test.js +0 -131
  64. package/Slice/tests/props-allowed-values-validation.test.js +0 -119
  65. package/Slice/tests/public-env-runtime-accessors.test.js +0 -44
  66. package/Slice/tests/public-env-typed-accessors.test.js +0 -66
  67. package/Slice/tests/router-loading-finally.test.js +0 -68
  68. package/api/tests/dev-deps-optimizer.test.js +0 -137
  69. package/api/tests/dev-server-external.test.js +0 -56
  70. package/api/tests/precompressed-static.test.js +0 -119
  71. package/api/tests/public-env-resolver.test.js +0 -193
  72. package/tests/router-build-failure.test.js +0 -125
  73. 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, '&lt;')
21
+ .replace(/>/g, '&gt;')
22
+ .replace(/"/g, '&quot;')
23
+ .replace(/'/g, '&#39;');
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, '&amp;')
217
+ .replace(/</g, '&lt;')
218
+ .replace(/>/g, '&gt;')
219
+ .replace(/"/g, '&quot;')
220
+ .replace(/'/g, '&apos;');
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
+ }