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.
- package/Slice/Components/Structural/Router/Router.js +27 -0
- package/Slice/Components/Structural/Router/applyRouteMetadata.js +39 -0
- package/Slice/Components/Structural/StylesManager/ThemeManager/ThemeManager.js +15 -2
- package/Slice/Slice.js +31 -7
- package/api/framework/precompressedStatic.js +10 -1
- package/api/framework/seo.js +387 -0
- package/api/framework/server.js +432 -65
- package/api/index.js +17 -7
- package/package.json +53 -49
- 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 -738
- 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-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
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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, '&')
|
|
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
|
+
}
|