slicejs-web-framework 4.0.1 → 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 (29) hide show
  1. package/Slice/Components/Structural/Router/Router.js +27 -0
  2. package/Slice/Components/Structural/Router/applyRouteMetadata.js +39 -0
  3. package/Slice/Components/Structural/StylesManager/ThemeManager/ThemeManager.js +15 -2
  4. package/Slice/Slice.js +31 -7
  5. package/api/framework/precompressedStatic.js +10 -1
  6. package/api/framework/seo.js +387 -0
  7. package/api/framework/server.js +432 -65
  8. package/api/index.js +17 -7
  9. package/package.json +53 -49
  10. package/.opencode/opencode.json +0 -15
  11. package/Slice/tests/build-bundled-component-without-category.test.js +0 -103
  12. package/Slice/tests/build-js-only-visual-components.test.js +0 -144
  13. package/Slice/tests/build-singleton.test.js +0 -244
  14. package/Slice/tests/bundle-v2-runtime-contract.test.js +0 -738
  15. package/Slice/tests/context-patch-use.test.js +0 -95
  16. package/Slice/tests/destroy-cascade.test.js +0 -215
  17. package/Slice/tests/event-emitters.test.js +0 -79
  18. package/Slice/tests/event-registry.test.js +0 -141
  19. package/Slice/tests/fixtures/real-runtime-loader.mjs +0 -21
  20. package/Slice/tests/leak-orphans.test.js +0 -131
  21. package/Slice/tests/props-allowed-values-validation.test.js +0 -119
  22. package/Slice/tests/public-env-runtime-accessors.test.js +0 -44
  23. package/Slice/tests/public-env-typed-accessors.test.js +0 -66
  24. package/Slice/tests/router-loading-finally.test.js +0 -68
  25. package/api/tests/dev-deps-optimizer.test.js +0 -137
  26. package/api/tests/dev-server-external.test.js +0 -56
  27. package/api/tests/precompressed-static.test.js +0 -119
  28. package/api/tests/public-env-resolver.test.js +0 -193
  29. package/tests/router-emit-ordering.test.js +0 -63
@@ -1,3 +1,5 @@
1
+ import { applyRouteMetadata } from './applyRouteMetadata.js';
2
+
1
3
  /**
2
4
  * @typedef {Object} RouteConfig
3
5
  * @property {string} path
@@ -461,11 +463,24 @@ export default class Router {
461
463
  * @returns {Promise<void>}
462
464
  */
463
465
  async handleRoute(route, params) {
466
+ // Titles are on by default: without this, a SPA keeps the first page's
467
+ // title while the user navigates. `sliceConfig.router.updateTitles: false`
468
+ // opts out; a `router:change` listener still wins, because this runs
469
+ // before the event.
470
+ if (slice.routerConfig?.updateTitles !== false) {
471
+ applyRouteMetadata(route?.metadata);
472
+ }
473
+
464
474
  const targetElement = document.querySelector('#app');
465
475
 
466
476
  const componentName = route.parentRoute ? route.parentRoute.component : route.component;
467
477
  const sliceId = `route-${componentName}`;
468
478
 
479
+ if (!targetElement) {
480
+ slice.logger.logError('Router', `Cannot render route "${route?.path ?? componentName}": no #app element in the document`);
481
+ return;
482
+ }
483
+
469
484
  const existingComponent = slice.controller.getComponent(sliceId);
470
485
 
471
486
  if (slice.loading) {
@@ -487,6 +502,18 @@ export default class Router {
487
502
  sliceId: sliceId,
488
503
  });
489
504
 
505
+ // build() returns null whenever the component could not be produced
506
+ // (not registered, a resource fetch failed or was aborted, init()
507
+ // threw). Appending null raises "parameter 1 is not of type 'Node'",
508
+ // which buries the real cause. Report it and keep the current view.
509
+ if (!component) {
510
+ slice.logger.logError(
511
+ 'Router',
512
+ `Cannot render route "${route?.path ?? componentName}": slice.build('${componentName}') returned null`
513
+ );
514
+ return;
515
+ }
516
+
490
517
  targetElement.innerHTML = '';
491
518
  targetElement.appendChild(component);
492
519
 
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Apply a route's `metadata` to the document head.
3
+ *
4
+ * Only what the route declares is touched. A route without `metadata.title`
5
+ * keeps whatever the server sent: with native SEO the prerendered HTML already
6
+ * carries the right title, and writing a generic one here would undo it.
7
+ *
8
+ * `canonical` and `robots` are deliberately **never** written from here.
9
+ * Crawlers do not run this code, so rewriting them client-side could only
10
+ * desync the SPA from the HTML that was actually served.
11
+ *
12
+ * @param {Object|null} metadata route metadata (`metadata.title`, ...)
13
+ * @param {Document|null} doc injectable for tests
14
+ * @returns {void}
15
+ */
16
+ export function applyRouteMetadata(metadata, doc = typeof document !== 'undefined' ? document : null) {
17
+ if (!doc || !metadata || typeof metadata !== 'object') return;
18
+
19
+ const { title, titleTemplate, description } = metadata;
20
+
21
+ if (title) {
22
+ const next = titleTemplate && titleTemplate !== '%s'
23
+ ? titleTemplate.replace('%s', String(title))
24
+ : String(title);
25
+ if (doc.title !== next) doc.title = next;
26
+ }
27
+
28
+ if (description) {
29
+ let meta = doc.querySelector('meta[name="description"]');
30
+ if (!meta) {
31
+ meta = doc.createElement('meta');
32
+ meta.setAttribute('name', 'description');
33
+ doc.head.appendChild(meta);
34
+ }
35
+ if (meta.getAttribute('content') !== description) {
36
+ meta.setAttribute('content', description);
37
+ }
38
+ }
39
+ }
@@ -50,8 +50,21 @@ export default class ThemeManager {
50
50
  * @returns {Promise<void>}
51
51
  */
52
52
  async loadThemeCSS(themeName) {
53
- let themeContent =
54
- localStorage.getItem(`sliceTheme-${themeName}`) || (await slice.controller.fetchText(themeName, 'theme'));
53
+ let themeContent = localStorage.getItem(`sliceTheme-${themeName}`);
54
+
55
+ if (!themeContent) {
56
+ try {
57
+ themeContent = await slice.controller.fetchText(themeName, 'theme');
58
+ } catch (error) {
59
+ // A theme that fails to fetch must not abort framework init.
60
+ // applyTheme() is awaited from StylesManager.init(), which runs
61
+ // inside Slice.js init() — letting this throw leaves the runtime
62
+ // half-built (no slice.router). An unstyled app is recoverable;
63
+ // no app at all is not. fetchText already logged the cause.
64
+ slice.logger.logError('ThemeManager', `Failed to load theme: ${themeName}`, error);
65
+ return;
66
+ }
67
+ }
55
68
 
56
69
  if (!themeContent) {
57
70
  slice.logger.logError('ThemeManager', `Failed to load theme: ${themeName}`);
package/Slice/Slice.js CHANGED
@@ -20,6 +20,7 @@ export default class Slice {
20
20
  this.debuggerConfig = sliceConfig.debugger;
21
21
  this.loadingConfig = sliceConfig.loading;
22
22
  this.eventsConfig = sliceConfig.events;
23
+ this.routerConfig = sliceConfig.router;
23
24
 
24
25
  // Default to production until init() resolves the actual mode.
25
26
  // Safe to call isProduction() before init() completes.
@@ -331,7 +332,7 @@ export default class Slice {
331
332
 
332
333
  if (isVisual) {
333
334
  this.controller.registerComponentsRecursively(componentInstance);
334
- }
335
+ }
335
336
 
336
337
  this.logger.logInfo('Slice', `Instance ${componentInstance.sliceId} created`);
337
338
  return componentInstance;
@@ -523,7 +524,7 @@ async function init() {
523
524
  } else {
524
525
  setTimeout(() => safePreload(), 0);
525
526
  }
526
- }
527
+ }
527
528
 
528
529
  if (sliceConfig.debugger.enabled) {
529
530
  const DebuggerModule = window.slice.frameworkClasses?.Debugger
@@ -680,8 +681,31 @@ async function init() {
680
681
  await window.slice.router.init();
681
682
  }
682
683
 
683
- try {
684
- await init();
685
- } catch (initError) {
686
- console.error('[Slice.js] Initialization failed:', initError);
687
- }
684
+ // Booting requires a DOM. Under node — where this module is imported for the
685
+ // Slice class alone (see Slice/tests) — there is nothing to initialize, so skip
686
+ // it rather than starting an init() that can only fail.
687
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
688
+ // Exposed so callers that cannot rely on module import order — test
689
+ // harnesses, embedders, anything loaded via a plain <script> — have a real
690
+ // readiness signal. It settles exactly when init() does. Note that
691
+ // `window.slice` and `slice.build` exist well before init() finishes, so
692
+ // checking for those is NOT a readiness test; await this (or check
693
+ // `slice.router`) instead.
694
+ window.__SLICE_READY__ = init();
695
+
696
+ try {
697
+ await window.__SLICE_READY__;
698
+ if (window.slice) window.slice.initError = null;
699
+ } catch (initError) {
700
+ // Deliberately not swallowed. A half-initialized runtime (no
701
+ // slice.router, no registered components) makes every downstream call
702
+ // fail with its own unrelated-looking TypeError — "Cannot read
703
+ // properties of undefined (reading 'afterEach')", "appendChild:
704
+ // parameter 1 is not of type 'Node'" — each of which hides this, the
705
+ // actual cause. Record it, then rethrow so that importing this module
706
+ // fails loudly and on the real error.
707
+ if (window.slice) window.slice.initError = initError;
708
+ console.error('[Slice.js] Initialization failed:', initError);
709
+ throw initError;
710
+ }
711
+ }
@@ -43,10 +43,14 @@ function preferredEncodings(acceptEncoding) {
43
43
  /**
44
44
  * @param {string[]} roots Absolute directories to resolve `req.path` against,
45
45
  * in priority order (e.g. [dist/public, dist]).
46
+ * @param {{ noStorePrefixes?: string[] }} [options] Path prefixes that must not
47
+ * be cached. Bundles are unversioned, so they keep the same no-store policy
48
+ * the `/bundles/` handler applies when it serves them itself.
46
49
  * @returns {import('express').RequestHandler}
47
50
  */
48
- export function createPrecompressedStatic(roots = []) {
51
+ export function createPrecompressedStatic(roots = [], options = {}) {
49
52
  const resolvedRoots = roots.filter(Boolean).map((r) => path.resolve(r));
53
+ const noStorePrefixes = options.noStorePrefixes || [];
50
54
 
51
55
  return function precompressedStatic(req, res, next) {
52
56
  if (req.method !== 'GET' && req.method !== 'HEAD') return next();
@@ -78,6 +82,11 @@ export function createPrecompressedStatic(roots = []) {
78
82
  res.setHeader('Content-Type', contentType);
79
83
  res.setHeader('Content-Encoding', enc);
80
84
  res.setHeader('Vary', 'Accept-Encoding');
85
+ if (noStorePrefixes.some((prefix) => req.path.startsWith(prefix))) {
86
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
87
+ res.setHeader('Pragma', 'no-cache');
88
+ res.setHeader('Expires', '0');
89
+ }
81
90
  return res.send(fs.readFileSync(encodedPath));
82
91
  }
83
92
  } catch {
@@ -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, '&amp;')
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
+ }