@webjsdev/server 0.8.63 → 0.8.65
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/index.d.ts +7 -2
- package/package.json +2 -2
- package/src/browser-entries.js +64 -0
- package/src/check/helpers.js +365 -0
- package/src/check/rules-actions.js +174 -0
- package/src/check/rules-components.js +275 -0
- package/src/check/rules-imports.js +166 -0
- package/src/check/rules-registry.js +128 -0
- package/src/check/rules-routing.js +224 -0
- package/src/check/rules-typescript.js +177 -0
- package/src/check/rules.js +127 -0
- package/src/check/runner-support.js +405 -0
- package/src/check/runner.js +113 -0
- package/src/check.js +4 -1774
- package/src/component-elision.js +173 -26
- package/src/dev/config.js +338 -0
- package/src/dev/handler.js +1372 -0
- package/src/dev/helpers.js +542 -0
- package/src/dev/serve.js +840 -0
- package/src/dev/server.js +322 -0
- package/src/dev-classify.js +174 -0
- package/src/dev-reload-worker.js +133 -7
- package/src/dev-styles.js +97 -0
- package/src/dev.js +22 -3077
- package/src/form-dispatch.js +12 -3
- package/src/importmap.js +6 -3
- package/src/listener-core.js +16 -2
- package/src/module-graph.js +112 -7
- package/src/router.js +11 -1
- package/src/ssr/client-router-flag.js +42 -0
- package/src/ssr/document.js +361 -0
- package/src/ssr/env-shim.js +51 -0
- package/src/ssr/escape.js +29 -0
- package/src/ssr/head.js +857 -0
- package/src/ssr/preloads.js +195 -0
- package/src/ssr/render.js +1481 -0
- package/src/ssr/responses.js +201 -0
- package/src/ssr.js +28 -2445
- package/src/vendor/audit.js +400 -0
- package/src/vendor/integrity.js +245 -0
- package/src/vendor/jspm.js +348 -0
- package/src/vendor/manifest.js +96 -0
- package/src/vendor/pins.js +892 -0
- package/src/vendor/providers.js +35 -0
- package/src/vendor/resolver.js +225 -0
- package/src/vendor/scanner.js +132 -0
- package/src/vendor.js +29 -2371
package/src/ssr/head.js
ADDED
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
import { basePath, buildImportMap, importMapTag, vendorPreconnectOrigins } from '../importmap.js';
|
|
2
|
+
import { withBasePath } from '../base-path.js';
|
|
3
|
+
import { escapeAttr, escapeHtml } from './escape.js';
|
|
4
|
+
import { withAssetHash } from '../asset-hash.js';
|
|
5
|
+
import { jsonForScriptTag } from '../script-tag-json.js';
|
|
6
|
+
import { vendorIntegrityFor } from '../importmap.js';
|
|
7
|
+
import { publicEnvShim } from './env-shim.js';
|
|
8
|
+
import { clientRouterEnabled } from './client-router-flag.js';
|
|
9
|
+
|
|
10
|
+
// Which icon metadata ROUTES the app has (`app/icon.*`, `app/apple-icon.*`).
|
|
11
|
+
// Set at boot and on each route rebuild from the route table, the same shape
|
|
12
|
+
// as setClientRouterEnabled, so no opt has to thread through every render
|
|
13
|
+
// path. Empty by default, which keeps an app that declares its icons (or has
|
|
14
|
+
// neither route) byte-identical.
|
|
15
|
+
//
|
|
16
|
+
// This sits in head.js rather than in a module of its own the way the
|
|
17
|
+
// client-router flag does: `wrapHead` is the only reader, and module state
|
|
18
|
+
// belongs with the code that uses and writes it. The flag moved out only
|
|
19
|
+
// because it has a second reader in render.js.
|
|
20
|
+
/** @type {{ icon: boolean, apple: boolean }} */
|
|
21
|
+
let _metadataIconRoutes = { icon: false, apple: false };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Record the icon metadata routes the app defines.
|
|
25
|
+
*
|
|
26
|
+
* @param {Iterable<{ stem: string }> | null | undefined} metadataRoutes
|
|
27
|
+
* The route table's `metadataRoutes`, or nullish to clear.
|
|
28
|
+
*/
|
|
29
|
+
export function setMetadataIconRoutes(metadataRoutes) {
|
|
30
|
+
const stems = new Set();
|
|
31
|
+
for (const r of metadataRoutes || []) if (r && r.stem) stems.add(r.stem);
|
|
32
|
+
_metadataIconRoutes = { icon: stems.has('icon'), apple: stems.has('apple-icon') };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The implicit `metadata.icons` an app's icon routes stand for, or null when
|
|
37
|
+
* it has none. Base-path prefixed, because that is where the routes are
|
|
38
|
+
* SERVED: the listener strips the base path before matching, so under
|
|
39
|
+
* `webjs.basePath` the route answers at `<basePath>/icon`. A user-authored
|
|
40
|
+
* `icons` URL is deliberately left alone (it may be cross-origin, and the
|
|
41
|
+
* author writes the path they mean), so only these framework-emitted ones
|
|
42
|
+
* are prefixed.
|
|
43
|
+
*
|
|
44
|
+
* No `type` or `sizes` is emitted. A metadata route picks its own content
|
|
45
|
+
* type at request time, which is the reason to use one, so declaring a type
|
|
46
|
+
* here could contradict the bytes; and `sizes` is unknowable without reading
|
|
47
|
+
* the response. Both are optional in HTML, and a browser sniffs the served
|
|
48
|
+
* content type.
|
|
49
|
+
*
|
|
50
|
+
* @returns {{ icon?: string, apple?: string } | null}
|
|
51
|
+
*/
|
|
52
|
+
function autoMetadataRouteIcons() {
|
|
53
|
+
const { icon, apple } = _metadataIconRoutes;
|
|
54
|
+
if (!icon && !apple) return null;
|
|
55
|
+
const bp = basePath();
|
|
56
|
+
/** @type {{ icon?: string, apple?: string }} */
|
|
57
|
+
const out = {};
|
|
58
|
+
if (icon) out.icon = withBasePath('/icon', bp);
|
|
59
|
+
if (apple) out.apple = withBasePath('/apple-icon', bp);
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* HTML-safe-escape a JSON string for embedding inside a
|
|
65
|
+
* `<script type="application/ld+json">` element.
|
|
66
|
+
*
|
|
67
|
+
* This is NOT the HTML-entity escaper (escapeHtml / escapeAttr). A
|
|
68
|
+
* JSON parser reads the raw character, so turning `<` into `<`
|
|
69
|
+
* would CORRUPT the JSON. Instead we emit the Unicode escape form
|
|
70
|
+
* (`<`), which a JSON parser decodes back to the original
|
|
71
|
+
* character while making the literal byte sequence `</script>`
|
|
72
|
+
* impossible to form in the served HTML. So the embedded data parses
|
|
73
|
+
* back to the author's exact object, AND a value containing
|
|
74
|
+
* `</script><img onerror=...>` can never break out of the script tag.
|
|
75
|
+
*
|
|
76
|
+
* U+2028 / U+2029 are escaped too: they are valid inside a JSON
|
|
77
|
+
* string but are line terminators in HTML/JS contexts, and some
|
|
78
|
+
* consumers choke on them. Escaping keeps the block robust.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} json the `JSON.stringify` output
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
export function escapeJsonLd(str) {
|
|
84
|
+
return String(str)
|
|
85
|
+
.replace(/</g, '\\u003c')
|
|
86
|
+
.replace(/>/g, '\\u003e')
|
|
87
|
+
.replace(/&/g, '\\u0026')
|
|
88
|
+
.replace(/\u2028/g, '\\u2028')
|
|
89
|
+
.replace(/\u2029/g, '\\u2029');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Serialize one schema.org object into a `<script type="application/ld+json">`
|
|
94
|
+
* block, HTML-safe-escaped via escapeJsonLd. Fails SAFE: a non-object
|
|
95
|
+
* input, or a circular reference that makes JSON.stringify throw, is
|
|
96
|
+
* skipped (returns the empty string) with a one-line warn, never breaking
|
|
97
|
+
* the whole render.
|
|
98
|
+
*
|
|
99
|
+
* @param {unknown} obj
|
|
100
|
+
* @returns {string} the script tag, or '' to skip this element
|
|
101
|
+
*/
|
|
102
|
+
export function jsonLdScript(obj) {
|
|
103
|
+
if (!obj || typeof obj !== 'object') return '';
|
|
104
|
+
try {
|
|
105
|
+
const json = JSON.stringify(obj);
|
|
106
|
+
if (typeof json !== 'string') return '';
|
|
107
|
+
return `<script type="application/ld+json">${escapeJsonLd(json)}</script>`;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.warn('[webjs] metadata.jsonLd: skipped an entry that could not be serialized:', err && err.message);
|
|
110
|
+
return '';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Re-exports for unit testing. */
|
|
115
|
+
export const _escapeJsonLd = escapeJsonLd;
|
|
116
|
+
export const _jsonLdScript = jsonLdScript;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Decide whether a `<link rel="modulepreload">` href needs a
|
|
120
|
+
* `crossorigin="anonymous"` attribute. True for absolute URLs with
|
|
121
|
+
* an http(s) scheme (vendor packages from jspm.io etc.); false for
|
|
122
|
+
* same-origin paths like `/__webjs/core/index.js`. Browsers require
|
|
123
|
+
* crossorigin on cross-origin module preload, else the preload is
|
|
124
|
+
* wasted or double-fetched. Same-origin URLs must NOT have it for
|
|
125
|
+
* the same reason in reverse.
|
|
126
|
+
*
|
|
127
|
+
* Exported for tests; production callers use it via documentParts.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} url
|
|
130
|
+
* @returns {string} either ` crossorigin="anonymous"` or empty
|
|
131
|
+
*/
|
|
132
|
+
export function preloadCrossOriginAttr(url) {
|
|
133
|
+
return /^https?:\/\//i.test(url) ? ' crossorigin="anonymous"' : '';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Look up the SRI integrity hash for a vendor URL and format it as a
|
|
138
|
+
* `integrity="sha384-..."` attribute. Empty string for URLs without a
|
|
139
|
+
* known hash (framework files, user code, vendor URLs in live-API
|
|
140
|
+
* mode without a pin file).
|
|
141
|
+
*
|
|
142
|
+
* @param {string} url
|
|
143
|
+
* @returns {string}
|
|
144
|
+
*/
|
|
145
|
+
export function integrityAttr(url) {
|
|
146
|
+
const hash = vendorIntegrityFor(url);
|
|
147
|
+
// Belt and suspenders: readPinFile already validates the integrity
|
|
148
|
+
// value end-to-end against /^sha(256|384|512)-[A-Za-z0-9+/=]+$/, so
|
|
149
|
+
// a valid hash has no HTML-special chars and escapeAttr is a no-op.
|
|
150
|
+
// But emission goes through the same attribute-injection-safe path
|
|
151
|
+
// as everything else in the SSR pipeline so a future regression in
|
|
152
|
+
// the validator doesn't bypass it.
|
|
153
|
+
return hash ? ` integrity="${escapeAttr(hash)}"` : '';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Strip leading head-bound tags (<script>, <style>, <link>) from a body
|
|
158
|
+
* string. Returns the collected tags + the remaining body. Mirrors what
|
|
159
|
+
* `hoistHeadTags` does but takes/returns plain strings (no head input)
|
|
160
|
+
* so it can be used with a user-provided <head>.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} bodyHtml
|
|
163
|
+
* @returns {{ tags: string[], body: string }}
|
|
164
|
+
*/
|
|
165
|
+
export function collectHoistedHeadTags(bodyHtml) {
|
|
166
|
+
const tags = [];
|
|
167
|
+
// <script>…</script> and <style>…</style> are paired; <link …> and <meta …>
|
|
168
|
+
// are void. A plain HTML comment (<!-- … -->) is consumed but NOT hoisted, so
|
|
169
|
+
// a comment interleaved with head-bound tags (e.g. "<!-- Self-hosted fonts -->"
|
|
170
|
+
// between a favicon <link> and the stylesheet <link>) does not terminate
|
|
171
|
+
// the leading run and strand the stylesheet in <body>, which caused FOUC
|
|
172
|
+
// because a <link rel="stylesheet"> in <body> is not reliably
|
|
173
|
+
// render-blocking (#406). A <meta …> is treated the same way for the same
|
|
174
|
+
// reason: a <meta name="color-scheme"> between the theme <script> and the
|
|
175
|
+
// stylesheet <link> would otherwise terminate the run and strand the
|
|
176
|
+
// stylesheet AND a <link rel="icon"> in <body> (a favicon in <body> is
|
|
177
|
+
// ignored by browsers, so the icon silently never renders). The `(?!/?wj:)`
|
|
178
|
+
// guard exempts client-router markers (<!--wj:children:…-->,
|
|
179
|
+
// <!--/wj:children-->) so a layout that renders children directly after its
|
|
180
|
+
// head tags still terminates the run there rather than swallowing the marker.
|
|
181
|
+
// The void-tag matchers are QUOTE-AWARE ((?:[^>"']|"[^"]*"|'[^']*')*): a `>`
|
|
182
|
+
// inside a quoted attribute value (a description meta like
|
|
183
|
+
// content="Guides > API") must not terminate the tag early, which would hoist
|
|
184
|
+
// a truncated tag, leak the remainder as visible body text, and strand the
|
|
185
|
+
// stylesheet (the #406 FOUC this hoist exists to prevent).
|
|
186
|
+
const re =
|
|
187
|
+
/^\s*(<!--(?!\/?wj:)[\s\S]*?-->|<script[\s>][\s\S]*?<\/script>|<style[\s>][\s\S]*?<\/style>|<link\b(?:[^>"']|"[^"]*"|'[^']*')*>|<meta\b(?:[^>"']|"[^"]*"|'[^']*')*>)/i;
|
|
188
|
+
let remaining = bodyHtml;
|
|
189
|
+
// `body` only advances to just-past the LAST hoisted head tag. Comments
|
|
190
|
+
// are scanned through (so they don't terminate the run) but a comment that
|
|
191
|
+
// trails the final head tag stays in the body rather than being dropped.
|
|
192
|
+
let body = bodyHtml;
|
|
193
|
+
let m;
|
|
194
|
+
while ((m = re.exec(remaining)) !== null) {
|
|
195
|
+
const token = m[1];
|
|
196
|
+
remaining = remaining.slice(m[0].length);
|
|
197
|
+
if (!token.startsWith('<!--')) {
|
|
198
|
+
tags.push(token);
|
|
199
|
+
body = remaining;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { tags, body };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Produce the `<!doctype…><body>` prefix. If `streaming` is true, injects
|
|
207
|
+
* the tiny client-side resolver that swaps Suspense fallback nodes for
|
|
208
|
+
* streamed-in real content.
|
|
209
|
+
*
|
|
210
|
+
* Also emits `<link rel="modulepreload">` for every component that rendered
|
|
211
|
+
* (breaks the ES-module waterfall without a bundler) and any user-declared
|
|
212
|
+
* `metadata.preload` entries.
|
|
213
|
+
*
|
|
214
|
+
* @param {{ metadata: Record<string,any>, moduleUrls: string[], dev: boolean, streaming: boolean, preloads?: string[], lazyComponents?: Record<string, string>, nonce?: string }} opts
|
|
215
|
+
*/
|
|
216
|
+
/**
|
|
217
|
+
* Extract leading `<script>`, `<style>`, and `<link>` tags from the body
|
|
218
|
+
* HTML and hoist them into `<head>`. Ensures blocking scripts (e.g.
|
|
219
|
+
* Tailwind runtime, theme bootstrap) run before any body content renders,
|
|
220
|
+
* and that `<link rel="icon">` / `<link rel="stylesheet">` land where
|
|
221
|
+
* browsers reliably honour them.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} headHtml
|
|
224
|
+
* @param {string} bodyHtml
|
|
225
|
+
* @returns {{ head: string, body: string }}
|
|
226
|
+
*/
|
|
227
|
+
export function hoistHeadTags(headHtml, bodyHtml) {
|
|
228
|
+
// Shares the leading-run scanner (comment-skipping included) with the
|
|
229
|
+
// streaming path so both hoist identically. See collectHoistedHeadTags.
|
|
230
|
+
const { tags: hoisted, body: remaining } = collectHoistedHeadTags(bodyHtml);
|
|
231
|
+
if (!hoisted.length) return { head: headHtml, body: bodyHtml };
|
|
232
|
+
const newHead = headHtml.replace('</head>', hoisted.join('\n') + '\n</head>');
|
|
233
|
+
return { head: newHead, body: remaining };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Internal helper re-exported for unit testing. */
|
|
237
|
+
export const _hoistHeadTags = hoistHeadTags;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Serialize a Next.js-shaped viewport object into the comma-separated
|
|
241
|
+
* `content` string the meta tag expects. Recognised fields:
|
|
242
|
+
* width, height, initialScale, minimumScale, maximumScale,
|
|
243
|
+
* userScalable, viewportFit, interactiveWidget.
|
|
244
|
+
* Other fields (themeColor, colorScheme) live on their own meta tags
|
|
245
|
+
* and are handled by the caller: skipped here.
|
|
246
|
+
*
|
|
247
|
+
* @param {Record<string, unknown>} v
|
|
248
|
+
* @returns {string}
|
|
249
|
+
*/
|
|
250
|
+
export function serializeViewport(v) {
|
|
251
|
+
const parts = [];
|
|
252
|
+
/** @param {string} key @param {string} prop */
|
|
253
|
+
const push = (key, prop) => {
|
|
254
|
+
if (v[prop] !== undefined && v[prop] !== null && v[prop] !== '') {
|
|
255
|
+
parts.push(`${key}=${v[prop]}`);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
push('width', 'width');
|
|
259
|
+
push('height', 'height');
|
|
260
|
+
push('initial-scale', 'initialScale');
|
|
261
|
+
push('minimum-scale', 'minimumScale');
|
|
262
|
+
push('maximum-scale', 'maximumScale');
|
|
263
|
+
if (v.userScalable === false) parts.push('user-scalable=no');
|
|
264
|
+
else if (v.userScalable === true) parts.push('user-scalable=yes');
|
|
265
|
+
push('viewport-fit', 'viewportFit');
|
|
266
|
+
push('interactive-widget', 'interactiveWidget');
|
|
267
|
+
return parts.join(',');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function wrapHead(opts) {
|
|
271
|
+
// CSP nonce: if provided, all inline <script> tags get nonce="…" so they
|
|
272
|
+
// pass strict Content-Security-Policy headers. The nonce is extracted from
|
|
273
|
+
// the request's CSP header by the caller.
|
|
274
|
+
const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
|
|
275
|
+
// Sub-path deployment (issue #256): the boot script's per-route module
|
|
276
|
+
// specifiers and the dev reload `src` are framework-emitted same-origin
|
|
277
|
+
// absolute URLs, so prefix them with the base path (a no-op when empty).
|
|
278
|
+
// The lazy-loader import is a BARE specifier resolved through the importmap
|
|
279
|
+
// (whose target is already base-path-prefixed in importmap.js), so it is
|
|
280
|
+
// NOT prefixed here. The base path is the one set at boot via setBasePath
|
|
281
|
+
// (read from importmap.js's module state), the same value the importmap
|
|
282
|
+
// targets were prefixed with, so the boot specifiers and the map agree.
|
|
283
|
+
const bp = basePath();
|
|
284
|
+
// Content-hash asset URLs (issue #243): after base-path-prefixing, append
|
|
285
|
+
// `?v=<hash>` to a same-origin module specifier for immutable caching. A
|
|
286
|
+
// no-op in dev (so the boot script is byte-identical) and for a bare/
|
|
287
|
+
// cross-origin specifier; same compose order as the importmap targets
|
|
288
|
+
// (basePath then `?v`).
|
|
289
|
+
const fp = (u) => withAssetHash(withBasePath(u, bp), bp);
|
|
290
|
+
|
|
291
|
+
const moduleUrls = opts.moduleUrls;
|
|
292
|
+
const imports = moduleUrls
|
|
293
|
+
.map((u) => `import ${jsonForScriptTag(fp(u))};`)
|
|
294
|
+
.join('\n');
|
|
295
|
+
|
|
296
|
+
const rawLazyEntries = opts.lazyComponents && Object.keys(opts.lazyComponents).length
|
|
297
|
+
? opts.lazyComponents
|
|
298
|
+
: null;
|
|
299
|
+
// The lazy map's values are same-origin module URLs `observeLazy` will
|
|
300
|
+
// dynamically import, so prefix them with the base path too (no-op when
|
|
301
|
+
// empty).
|
|
302
|
+
// The lazy map's values are same-origin module URLs `observeLazy` will
|
|
303
|
+
// dynamically import, so base-path-prefix AND content-hash them (#243), the
|
|
304
|
+
// same as the eager boot specifiers. `fp` is a pure no-op in dev and at the
|
|
305
|
+
// root mount with fingerprinting off, so the mapped map equals the raw one
|
|
306
|
+
// byte-for-byte there; only prod fingerprinting / a sub-path mount changes it.
|
|
307
|
+
const lazyEntries = rawLazyEntries
|
|
308
|
+
? Object.fromEntries(
|
|
309
|
+
Object.entries(rawLazyEntries).map(([tag, u]) => [tag, fp(u)]),
|
|
310
|
+
)
|
|
311
|
+
: rawLazyEntries;
|
|
312
|
+
const lazyBoot = lazyEntries
|
|
313
|
+
? `\nimport { observeLazy } from '@webjsdev/core/lazy-loader';\nobserveLazy(${jsonForScriptTag(lazyEntries)});`
|
|
314
|
+
: '';
|
|
315
|
+
|
|
316
|
+
const boot = (imports || lazyBoot) ? `<script type="module"${n}>\n${imports}${lazyBoot}\n</script>` : '';
|
|
317
|
+
const reload = opts.dev
|
|
318
|
+
? `<script type="module"${n} src="${escapeAttr(withBasePath('/__webjs/reload.js', bp))}"></script>`
|
|
319
|
+
: '';
|
|
320
|
+
const suspenseBoot = opts.streaming
|
|
321
|
+
? `<script${n}>(function(){` +
|
|
322
|
+
`function r(id){var t=document.querySelector('template[data-webjs-resolve="'+id+'"]');` +
|
|
323
|
+
`var b=document.getElementById(id);if(t&&b){b.replaceWith(t.content.cloneNode(true));t.remove();}}` +
|
|
324
|
+
`window.__webjsResolve=r;` +
|
|
325
|
+
`if(typeof MutationObserver!=='undefined'){` +
|
|
326
|
+
`new MutationObserver(function(ms){ms.forEach(function(m){m.addedNodes.forEach(function(n){` +
|
|
327
|
+
`if(n.nodeType===1&&n.tagName==='TEMPLATE'&&n.dataset.webjsResolve){r(n.dataset.webjsResolve);}` +
|
|
328
|
+
`});});}).observe(document.documentElement,{childList:true,subtree:true});}` +
|
|
329
|
+
`})()</script>`
|
|
330
|
+
: '';
|
|
331
|
+
|
|
332
|
+
const m = opts.metadata || {};
|
|
333
|
+
const metaTags = [];
|
|
334
|
+
// linkTags is populated by both the metadata emission below (icons,
|
|
335
|
+
// alternates, archives, etc.) AND by the preload block further down.
|
|
336
|
+
// Hoist the declaration so the metadata block can push into it.
|
|
337
|
+
const linkTags = [];
|
|
338
|
+
// scriptTags collects JSON-LD structured-data blocks (see m.jsonLd below).
|
|
339
|
+
const scriptTags = [];
|
|
340
|
+
|
|
341
|
+
// Tiny URL resolver against metadataBase. If metadataBase is set and a
|
|
342
|
+
// value looks like a relative URL (no scheme, no `//` prefix), resolve
|
|
343
|
+
// it. Otherwise return as-is. Used by og:image, twitter:image,
|
|
344
|
+
// alternates.canonical / languages / media.
|
|
345
|
+
const base = typeof m.metadataBase === 'string' ? m.metadataBase : '';
|
|
346
|
+
/** @param {unknown} v */
|
|
347
|
+
const absUrl = (v) => {
|
|
348
|
+
const s = String(v);
|
|
349
|
+
if (!base) return s;
|
|
350
|
+
if (/^https?:\/\//i.test(s) || s.startsWith('//') || s.startsWith('data:')) return s;
|
|
351
|
+
try {
|
|
352
|
+
return new URL(s, base).toString();
|
|
353
|
+
} catch {
|
|
354
|
+
return s;
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
if (m.description) metaTags.push(`<meta name="description" content="${escapeAttr(m.description)}">`);
|
|
359
|
+
|
|
360
|
+
// viewport: support string form (legacy), `metadata.viewport` object form,
|
|
361
|
+
// and the new Next.js 14+ `export const viewport = { … }` shape captured
|
|
362
|
+
// into `_viewport` by collectMetadata.
|
|
363
|
+
let viewportStr = '';
|
|
364
|
+
if (typeof m.viewport === 'string') {
|
|
365
|
+
viewportStr = m.viewport;
|
|
366
|
+
} else if (m.viewport && typeof m.viewport === 'object') {
|
|
367
|
+
viewportStr = serializeViewport(m.viewport);
|
|
368
|
+
} else if (m._viewport && typeof m._viewport === 'object') {
|
|
369
|
+
viewportStr = serializeViewport(m._viewport);
|
|
370
|
+
}
|
|
371
|
+
metaTags.push(`<meta name="viewport" content="${escapeAttr(viewportStr || 'width=device-width,initial-scale=1')}">`);
|
|
372
|
+
|
|
373
|
+
if (m.themeColor) metaTags.push(`<meta name="theme-color" content="${escapeAttr(m.themeColor)}">`);
|
|
374
|
+
if (m.colorScheme) metaTags.push(`<meta name="color-scheme" content="${escapeAttr(m.colorScheme)}">`);
|
|
375
|
+
|
|
376
|
+
// robots: { index, follow, googleBot, etc. }
|
|
377
|
+
if (m.robots) {
|
|
378
|
+
if (typeof m.robots === 'string') {
|
|
379
|
+
metaTags.push(`<meta name="robots" content="${escapeAttr(m.robots)}">`);
|
|
380
|
+
} else if (typeof m.robots === 'object') {
|
|
381
|
+
const parts = [];
|
|
382
|
+
if (m.robots.index === false) parts.push('noindex');
|
|
383
|
+
else if (m.robots.index === true) parts.push('index');
|
|
384
|
+
if (m.robots.follow === false) parts.push('nofollow');
|
|
385
|
+
else if (m.robots.follow === true) parts.push('follow');
|
|
386
|
+
if (m.robots.noarchive) parts.push('noarchive');
|
|
387
|
+
if (m.robots.nosnippet) parts.push('nosnippet');
|
|
388
|
+
if (m.robots.noimageindex) parts.push('noimageindex');
|
|
389
|
+
if (parts.length) {
|
|
390
|
+
metaTags.push(`<meta name="robots" content="${escapeAttr(parts.join(', '))}">`);
|
|
391
|
+
}
|
|
392
|
+
if (typeof m.robots.googleBot === 'string') {
|
|
393
|
+
metaTags.push(`<meta name="googlebot" content="${escapeAttr(m.robots.googleBot)}">`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// keywords: string | string[]
|
|
399
|
+
if (m.keywords) {
|
|
400
|
+
const kws = Array.isArray(m.keywords) ? m.keywords.join(', ') : String(m.keywords);
|
|
401
|
+
if (kws) metaTags.push(`<meta name="keywords" content="${escapeAttr(kws)}">`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// authors: Array<{ name, url? }> | { name, url? } | string
|
|
405
|
+
if (m.authors) {
|
|
406
|
+
const list = Array.isArray(m.authors) ? m.authors : [m.authors];
|
|
407
|
+
for (const a of list) {
|
|
408
|
+
if (!a) continue;
|
|
409
|
+
const name = typeof a === 'string' ? a : a.name;
|
|
410
|
+
if (!name) continue;
|
|
411
|
+
metaTags.push(`<meta name="author" content="${escapeAttr(name)}">`);
|
|
412
|
+
if (typeof a === 'object' && a.url) {
|
|
413
|
+
metaTags.push(`<link rel="author" href="${escapeAttr(absUrl(a.url))}">`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Singletons that map 1:1 to <meta name="…">.
|
|
419
|
+
for (const [field, metaName] of [
|
|
420
|
+
['creator', 'creator'],
|
|
421
|
+
['publisher', 'publisher'],
|
|
422
|
+
['applicationName', 'application-name'],
|
|
423
|
+
['generator', 'generator'],
|
|
424
|
+
['referrer', 'referrer'],
|
|
425
|
+
]) {
|
|
426
|
+
if (m[field]) {
|
|
427
|
+
metaTags.push(`<meta name="${metaName}" content="${escapeAttr(String(m[field]))}">`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// ---- Long-tail metadata (the Next.js "everything else") ----
|
|
431
|
+
|
|
432
|
+
// appleWebApp: { capable, title, statusBarStyle, startupImage }
|
|
433
|
+
if (m.appleWebApp && typeof m.appleWebApp === 'object') {
|
|
434
|
+
if (m.appleWebApp.capable !== undefined) {
|
|
435
|
+
metaTags.push(
|
|
436
|
+
`<meta name="apple-mobile-web-app-capable" content="${m.appleWebApp.capable ? 'yes' : 'no'}">`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
if (m.appleWebApp.title) {
|
|
440
|
+
metaTags.push(`<meta name="apple-mobile-web-app-title" content="${escapeAttr(m.appleWebApp.title)}">`);
|
|
441
|
+
}
|
|
442
|
+
if (m.appleWebApp.statusBarStyle) {
|
|
443
|
+
metaTags.push(
|
|
444
|
+
`<meta name="apple-mobile-web-app-status-bar-style" content="${escapeAttr(m.appleWebApp.statusBarStyle)}">`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
// startupImage maps to <link rel="apple-touch-startup-image">.
|
|
448
|
+
if (m.appleWebApp.startupImage) {
|
|
449
|
+
const list = Array.isArray(m.appleWebApp.startupImage)
|
|
450
|
+
? m.appleWebApp.startupImage
|
|
451
|
+
: [m.appleWebApp.startupImage];
|
|
452
|
+
for (const it of list) {
|
|
453
|
+
if (typeof it === 'string') {
|
|
454
|
+
linkTags.push(`<link rel="apple-touch-startup-image" href="${escapeAttr(absUrl(it))}">`);
|
|
455
|
+
} else if (it && it.url) {
|
|
456
|
+
const parts = [`rel="apple-touch-startup-image"`, `href="${escapeAttr(absUrl(it.url))}"`];
|
|
457
|
+
if (it.media) parts.push(`media="${escapeAttr(it.media)}"`);
|
|
458
|
+
linkTags.push(`<link ${parts.join(' ')}>`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
} else if (m.appleWebApp === true) {
|
|
463
|
+
metaTags.push(`<meta name="apple-mobile-web-app-capable" content="yes">`);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// formatDetection: { telephone, address, email, date, … }. All booleans.
|
|
467
|
+
// Disabled detection types append "type=no" to the content string.
|
|
468
|
+
if (m.formatDetection && typeof m.formatDetection === 'object') {
|
|
469
|
+
const parts = [];
|
|
470
|
+
for (const [k, v] of Object.entries(m.formatDetection)) {
|
|
471
|
+
if (v === false) parts.push(`${k}=no`);
|
|
472
|
+
else if (v === true) parts.push(`${k}=yes`);
|
|
473
|
+
}
|
|
474
|
+
if (parts.length) {
|
|
475
|
+
metaTags.push(`<meta name="format-detection" content="${escapeAttr(parts.join(', '))}">`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// itunes: { appId, appArgument? }
|
|
480
|
+
if (m.itunes && typeof m.itunes === 'object' && m.itunes.appId) {
|
|
481
|
+
let content = `app-id=${m.itunes.appId}`;
|
|
482
|
+
if (m.itunes.appArgument) content += `, app-argument=${m.itunes.appArgument}`;
|
|
483
|
+
metaTags.push(`<meta name="apple-itunes-app" content="${escapeAttr(content)}">`);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
for (const [field, metaName] of [
|
|
487
|
+
['category', 'category'],
|
|
488
|
+
['classification', 'classification'],
|
|
489
|
+
['abstract', 'abstract'],
|
|
490
|
+
]) {
|
|
491
|
+
if (m[field]) metaTags.push(`<meta name="${metaName}" content="${escapeAttr(String(m[field]))}">`);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// archives / assets / bookmarks: each is string | string[].
|
|
495
|
+
// Standard registered link relations.
|
|
496
|
+
for (const [field, rel] of [
|
|
497
|
+
['archives', 'archives'],
|
|
498
|
+
['assets', 'assets'],
|
|
499
|
+
['bookmarks', 'bookmark'],
|
|
500
|
+
]) {
|
|
501
|
+
if (m[field]) {
|
|
502
|
+
const list = Array.isArray(m[field]) ? m[field] : [m[field]];
|
|
503
|
+
for (const href of list) {
|
|
504
|
+
linkTags.push(`<link rel="${rel}" href="${escapeAttr(absUrl(href))}">`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// `other` is the typed escape hatch for any arbitrary <meta name="…">
|
|
510
|
+
// entries Next.js (or future webjs) doesn't ship as a typed field.
|
|
511
|
+
// Values can be string, number, or string[] (emits multiple meta tags).
|
|
512
|
+
if (m.other && typeof m.other === 'object') {
|
|
513
|
+
for (const [name, v] of Object.entries(m.other)) {
|
|
514
|
+
const list = Array.isArray(v) ? v : [v];
|
|
515
|
+
for (const item of list) {
|
|
516
|
+
if (item == null) continue;
|
|
517
|
+
metaTags.push(`<meta name="${escapeAttr(name)}" content="${escapeAttr(String(item))}">`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// verification: { google, yandex, yahoo, me }. Each is string OR string[].
|
|
523
|
+
// - google → <meta name="google-site-verification">
|
|
524
|
+
// - yandex → <meta name="yandex-verification">
|
|
525
|
+
// - yahoo → <meta name="y_key"> (Yahoo's unusual canonical name)
|
|
526
|
+
// - me → <meta name="me"> (IndieAuth / personal verification)
|
|
527
|
+
if (m.verification && typeof m.verification === 'object') {
|
|
528
|
+
const verifyKeys = {
|
|
529
|
+
google: 'google-site-verification',
|
|
530
|
+
yandex: 'yandex-verification',
|
|
531
|
+
yahoo: 'y_key',
|
|
532
|
+
me: 'me',
|
|
533
|
+
};
|
|
534
|
+
for (const [field, metaName] of Object.entries(verifyKeys)) {
|
|
535
|
+
const v = m.verification[field];
|
|
536
|
+
if (!v) continue;
|
|
537
|
+
const list = Array.isArray(v) ? v : [v];
|
|
538
|
+
for (const item of list) {
|
|
539
|
+
metaTags.push(`<meta name="${metaName}" content="${escapeAttr(String(item))}">`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// `verification.other` allows arbitrary <meta name="…"> entries.
|
|
543
|
+
if (m.verification.other && typeof m.verification.other === 'object') {
|
|
544
|
+
for (const [name, v] of Object.entries(m.verification.other)) {
|
|
545
|
+
const list = Array.isArray(v) ? v : [v];
|
|
546
|
+
for (const item of list) {
|
|
547
|
+
metaTags.push(`<meta name="${escapeAttr(name)}" content="${escapeAttr(String(item))}">`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (m.openGraph && typeof m.openGraph === 'object') {
|
|
554
|
+
for (const [k, v] of Object.entries(m.openGraph)) {
|
|
555
|
+
const out = k === 'image' || k === 'url' ? absUrl(v) : String(v);
|
|
556
|
+
metaTags.push(`<meta property="og:${escapeAttr(k)}" content="${escapeAttr(out)}">`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Twitter card tags. Twitter falls back to og:* when these are absent
|
|
561
|
+
// but won't upgrade to summary_large_image without an explicit
|
|
562
|
+
// twitter:card entry.
|
|
563
|
+
if (m.twitter && typeof m.twitter === 'object') {
|
|
564
|
+
for (const [k, v] of Object.entries(m.twitter)) {
|
|
565
|
+
const out = k === 'image' ? absUrl(v) : String(v);
|
|
566
|
+
metaTags.push(`<meta name="twitter:${escapeAttr(k)}" content="${escapeAttr(out)}">`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Preload hints: page modules themselves + every discovered component
|
|
571
|
+
// module, then any custom `metadata.preload` entries (fonts, images, etc.)
|
|
572
|
+
// (linkTags array was declared earlier so the metadata block above can
|
|
573
|
+
// push icons / canonical / hreflang / archives / etc. into it.)
|
|
574
|
+
//
|
|
575
|
+
// Cross-origin URLs (vendor packages served from jspm.io etc.) MUST
|
|
576
|
+
// carry `crossorigin="anonymous"` on the preload link. Without it
|
|
577
|
+
// the browser either ignores the preload entirely or double-fetches
|
|
578
|
+
// (once for the preload as a non-CORS request, once for the actual
|
|
579
|
+
// module as a CORS request, defeating the optimization). Same-origin
|
|
580
|
+
// URLs get no attribute; adding `crossorigin=""` there would also
|
|
581
|
+
// double-fetch in some browsers because the preload becomes CORS
|
|
582
|
+
// but the import doesn't.
|
|
583
|
+
// CSP nonce on the preload link: under strict CSP (script-src
|
|
584
|
+
// 'nonce-...') the browser also gates modulepreload by the same
|
|
585
|
+
// policy. Without the attribute the preload is blocked and the
|
|
586
|
+
// import either falls back to a cold fetch or fails. Rails (via
|
|
587
|
+
// importmap-rails) applies nonce on every modulepreload tag for
|
|
588
|
+
// the same reason.
|
|
589
|
+
const noncePreload = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
|
|
590
|
+
// Core runtime modulepreload (#1118). Every module the boot imports pulls
|
|
591
|
+
// `@webjsdev/core`, but the boot script names only page and component URLs,
|
|
592
|
+
// so without this hint the browser discovers core only after one of those is
|
|
593
|
+
// fetched AND parsed: one full round trip into the load, which is exactly
|
|
594
|
+
// where the pre-boot click window lives on a cold, throttled connection.
|
|
595
|
+
//
|
|
596
|
+
// The href comes STRAIGHT from the importmap target (no `fp()` rewrite: the
|
|
597
|
+
// map's targets are already base-path-prefixed and content-hashed), so it is
|
|
598
|
+
// byte-identical to what the import resolves to. A differing href makes the
|
|
599
|
+
// browser treat the preload and the import as two resources and fetch core
|
|
600
|
+
// twice. That is why this copies the vendor loop below, not the module loop
|
|
601
|
+
// above. `vendorPreloadTargets` still excludes core deliberately; this hint
|
|
602
|
+
// is emitted from the head builder, not the vendor path.
|
|
603
|
+
//
|
|
604
|
+
// Gated on the boot actually shipping something. A fully elided page ships no
|
|
605
|
+
// boot module and must not be handed a preload for a runtime it never loads
|
|
606
|
+
// (#780), which also keeps it off `global-error.{js,ts}`, whose document is
|
|
607
|
+
// returned verbatim with no importmap and no boot script.
|
|
608
|
+
if (opts.moduleUrls.length || lazyEntries) {
|
|
609
|
+
const coreMap = buildImportMap();
|
|
610
|
+
const coreHref = coreMap.imports['@webjsdev/core'];
|
|
611
|
+
if (coreHref) {
|
|
612
|
+
const raw = coreMap.integrity ? coreMap.integrity[coreHref] : undefined;
|
|
613
|
+
const coreIntegrity = raw ? ` integrity="${escapeAttr(raw)}"` : '';
|
|
614
|
+
linkTags.push(
|
|
615
|
+
`<link rel="modulepreload" href="${escapeAttr(coreHref)}"` +
|
|
616
|
+
`${preloadCrossOriginAttr(coreHref)}${coreIntegrity}${noncePreload}>`,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Sub-path deployment (issue #256): the modulepreload href is prefixed with
|
|
622
|
+
// the base path (a no-op when empty), but `crossorigin` / `integrity` are
|
|
623
|
+
// decided on the ORIGINAL url, so the integrity lookup still keys on the
|
|
624
|
+
// unprefixed map url and a cross-origin CDN url (never prefixed) keeps its
|
|
625
|
+
// crossorigin attribute.
|
|
626
|
+
// Content-hash (#243): the href additionally gets a `?v=<hash>` after the
|
|
627
|
+
// base-path prefix (a no-op in dev / for a cross-origin url), but
|
|
628
|
+
// `crossorigin` / `integrity` are still decided on the ORIGINAL url, so the
|
|
629
|
+
// integrity lookup keys on the unprefixed/unhashed map url and a cross-origin
|
|
630
|
+
// CDN url (never prefixed, never hashed) keeps its crossorigin attribute.
|
|
631
|
+
for (const url of opts.moduleUrls) {
|
|
632
|
+
linkTags.push(
|
|
633
|
+
`<link rel="modulepreload" href="${escapeAttr(fp(url))}"` +
|
|
634
|
+
`${preloadCrossOriginAttr(url)}${integrityAttr(url)}${noncePreload}>`,
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
for (const url of opts.preloads || []) {
|
|
638
|
+
linkTags.push(
|
|
639
|
+
`<link rel="modulepreload" href="${escapeAttr(fp(url))}"` +
|
|
640
|
+
`${preloadCrossOriginAttr(url)}${integrityAttr(url)}${noncePreload}>`,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
// Vendor modulepreload (#754): the npm CDN dependencies the page reaches,
|
|
644
|
+
// hinted up front to flatten the cross-origin waterfall. The href comes
|
|
645
|
+
// straight from the importmap target (byte-identical, no `fp()` rewrite, so
|
|
646
|
+
// the browser does not double-fetch) and carries the importmap's `integrity`.
|
|
647
|
+
// `preloadCrossOriginAttr` adds `crossorigin` for a cross-origin CDN url (a
|
|
648
|
+
// same-origin pinned `/__webjs/vendor/*` url gets none). Deduped against the
|
|
649
|
+
// app module/component preloads already emitted above.
|
|
650
|
+
const emittedPreloadHrefs = new Set([
|
|
651
|
+
...opts.moduleUrls.map((u) => fp(u)),
|
|
652
|
+
...(opts.preloads || []).map((u) => fp(u)),
|
|
653
|
+
]);
|
|
654
|
+
for (const v of opts.vendorPreloads || []) {
|
|
655
|
+
if (emittedPreloadHrefs.has(v.href)) continue;
|
|
656
|
+
emittedPreloadHrefs.add(v.href);
|
|
657
|
+
const integrity = v.integrity ? ` integrity="${escapeAttr(v.integrity)}"` : '';
|
|
658
|
+
linkTags.push(
|
|
659
|
+
`<link rel="modulepreload" href="${escapeAttr(v.href)}"` +
|
|
660
|
+
`${preloadCrossOriginAttr(v.href)}${integrity}${noncePreload}>`,
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (Array.isArray(m.preload)) {
|
|
665
|
+
for (const p of m.preload) {
|
|
666
|
+
if (!p || !p.href) continue;
|
|
667
|
+
const attrs = Object.entries(p)
|
|
668
|
+
.map(([k, v]) => `${k}="${escapeAttr(String(v))}"`)
|
|
669
|
+
.join(' ');
|
|
670
|
+
linkTags.push(`<link rel="preload" ${attrs}>`);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// preconnect / dns-prefetch hints (issue #243). `metadata.preconnect` and
|
|
675
|
+
// `metadata.dnsPrefetch` each take a URL string, `{ url, crossorigin? }`, or
|
|
676
|
+
// an array of those. A preconnect warms DNS + TLS + TCP; dns-prefetch only
|
|
677
|
+
// resolves DNS (no crossorigin). The framework ALSO auto-emits ONE preconnect
|
|
678
|
+
// to the resolved vendor CDN origin for an unpinned cross-origin app, so the
|
|
679
|
+
// browser warms that connection before the importmap resolves. The author's
|
|
680
|
+
// declared origins are tracked so the auto one is not a duplicate.
|
|
681
|
+
/** @type {Set<string>} the origins the author already declared a preconnect to */
|
|
682
|
+
const declaredPreconnectOrigins = new Set();
|
|
683
|
+
/** @param {unknown} h @returns {{ url: string, crossorigin?: string|boolean } | null} */
|
|
684
|
+
const normalizeHint = (h) => {
|
|
685
|
+
if (typeof h === 'string') return h ? { url: h } : null;
|
|
686
|
+
if (h && typeof h === 'object' && typeof (/** @type {any} */ (h).url) === 'string') {
|
|
687
|
+
return /** @type {any} */ (h);
|
|
688
|
+
}
|
|
689
|
+
return null;
|
|
690
|
+
};
|
|
691
|
+
/** @param {unknown} value @returns {Array<{ url: string, crossorigin?: string|boolean }>} */
|
|
692
|
+
const toHints = (value) => {
|
|
693
|
+
if (value == null) return [];
|
|
694
|
+
const list = Array.isArray(value) ? value : [value];
|
|
695
|
+
const out = [];
|
|
696
|
+
for (const h of list) {
|
|
697
|
+
const n = normalizeHint(h);
|
|
698
|
+
if (n) out.push(n);
|
|
699
|
+
}
|
|
700
|
+
return out;
|
|
701
|
+
};
|
|
702
|
+
/** @param {string|boolean|undefined} co @returns {string} */
|
|
703
|
+
const crossoriginAttr = (co) => {
|
|
704
|
+
if (co === undefined || co === false) return '';
|
|
705
|
+
if (co === true || co === '') return ' crossorigin';
|
|
706
|
+
return ` crossorigin="${escapeAttr(String(co))}"`;
|
|
707
|
+
};
|
|
708
|
+
for (const h of toHints(m.preconnect)) {
|
|
709
|
+
try { declaredPreconnectOrigins.add(new URL(h.url).origin); } catch { declaredPreconnectOrigins.add(h.url); }
|
|
710
|
+
linkTags.push(`<link rel="preconnect" href="${escapeAttr(h.url)}"${crossoriginAttr(h.crossorigin)}>`);
|
|
711
|
+
}
|
|
712
|
+
for (const h of toHints(m.dnsPrefetch)) {
|
|
713
|
+
// dns-prefetch never carries crossorigin (it only resolves DNS).
|
|
714
|
+
linkTags.push(`<link rel="dns-prefetch" href="${escapeAttr(h.url)}">`);
|
|
715
|
+
}
|
|
716
|
+
// Auto vendor preconnect: warm the cross-origin vendor CDN connection for an
|
|
717
|
+
// unpinned app. Deduped against an author-declared preconnect to the same
|
|
718
|
+
// origin; emits none for a same-origin pinned app or one with no cross-origin
|
|
719
|
+
// vendors (vendorPreconnectOrigins returns []). crossorigin is required (the
|
|
720
|
+
// importmap fetches the module as a CORS request).
|
|
721
|
+
for (const origin of vendorPreconnectOrigins()) {
|
|
722
|
+
if (declaredPreconnectOrigins.has(origin)) continue;
|
|
723
|
+
linkTags.push(`<link rel="preconnect" href="${escapeAttr(origin)}" crossorigin>`);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// icons: { icon, apple, shortcut, other }. Each entry can be a string
|
|
727
|
+
// (URL), an object { url, sizes?, type? }, or an array of those.
|
|
728
|
+
// - icon → <link rel="icon">
|
|
729
|
+
// - apple → <link rel="apple-touch-icon">
|
|
730
|
+
// - shortcut→ <link rel="shortcut icon">
|
|
731
|
+
// - other → <link rel="…" href="…"> using the entry's `rel` field
|
|
732
|
+
//
|
|
733
|
+
// With no `icons` declared, an `app/icon.*` / `app/apple-icon.*` metadata
|
|
734
|
+
// ROUTE is linked automatically (Next parity). Those routes served their
|
|
735
|
+
// bytes and nothing referenced them before, so writing the file that every
|
|
736
|
+
// other framework treats as "this is my favicon" produced a blank tab and no
|
|
737
|
+
// diagnostic. A declared `icons` SUPPRESSES the routes rather than merging
|
|
738
|
+
// with them, which is also what Next does: it merges static icon files only
|
|
739
|
+
// when the resolved metadata has no `icons` of its own. Suppressing matters
|
|
740
|
+
// here because the file is frequently a placeholder an app has outgrown, and
|
|
741
|
+
// an author who names their icons has said which ones they want.
|
|
742
|
+
const declaredOrRouteIcons = m.icons || autoMetadataRouteIcons();
|
|
743
|
+
if (declaredOrRouteIcons) {
|
|
744
|
+
const buckets = typeof declaredOrRouteIcons === 'string' || Array.isArray(declaredOrRouteIcons)
|
|
745
|
+
? { icon: declaredOrRouteIcons }
|
|
746
|
+
: declaredOrRouteIcons;
|
|
747
|
+
/** @param {string} rel @param {unknown} entry */
|
|
748
|
+
const pushIcon = (rel, entry) => {
|
|
749
|
+
if (!entry) return;
|
|
750
|
+
const items = Array.isArray(entry) ? entry : [entry];
|
|
751
|
+
for (const it of items) {
|
|
752
|
+
if (!it) continue;
|
|
753
|
+
if (typeof it === 'string') {
|
|
754
|
+
linkTags.push(`<link rel="${rel}" href="${escapeAttr(absUrl(it))}">`);
|
|
755
|
+
} else if (typeof it === 'object' && it.url) {
|
|
756
|
+
const parts = [`rel="${rel}"`, `href="${escapeAttr(absUrl(it.url))}"`];
|
|
757
|
+
if (it.sizes) parts.push(`sizes="${escapeAttr(it.sizes)}"`);
|
|
758
|
+
if (it.type) parts.push(`type="${escapeAttr(it.type)}"`);
|
|
759
|
+
linkTags.push(`<link ${parts.join(' ')}>`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
pushIcon('icon', buckets.icon);
|
|
764
|
+
pushIcon('apple-touch-icon', buckets.apple);
|
|
765
|
+
pushIcon('shortcut icon', buckets.shortcut);
|
|
766
|
+
// `other` is the catch-all: array of { rel, url, ...attrs }.
|
|
767
|
+
if (buckets.other) {
|
|
768
|
+
const others = Array.isArray(buckets.other) ? buckets.other : [buckets.other];
|
|
769
|
+
for (const o of others) {
|
|
770
|
+
if (!o || !o.rel || !o.url) continue;
|
|
771
|
+
const parts = [`rel="${escapeAttr(o.rel)}"`, `href="${escapeAttr(absUrl(o.url))}"`];
|
|
772
|
+
if (o.sizes) parts.push(`sizes="${escapeAttr(o.sizes)}"`);
|
|
773
|
+
if (o.type) parts.push(`type="${escapeAttr(o.type)}"`);
|
|
774
|
+
linkTags.push(`<link ${parts.join(' ')}>`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// manifest: a string URL → <link rel="manifest">
|
|
780
|
+
if (typeof m.manifest === 'string') {
|
|
781
|
+
linkTags.push(`<link rel="manifest" href="${escapeAttr(absUrl(m.manifest))}">`);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// alternates: { canonical, languages: { '<hreflang>': url }, media: { '<media>': url } }
|
|
785
|
+
// Mirrors Next.js's metadata.alternates surface. Relative values are resolved
|
|
786
|
+
// against metadataBase.
|
|
787
|
+
if (m.alternates && typeof m.alternates === 'object') {
|
|
788
|
+
if (m.alternates.canonical) {
|
|
789
|
+
linkTags.push(`<link rel="canonical" href="${escapeAttr(absUrl(m.alternates.canonical))}">`);
|
|
790
|
+
}
|
|
791
|
+
if (m.alternates.languages && typeof m.alternates.languages === 'object') {
|
|
792
|
+
for (const [hreflang, href] of Object.entries(m.alternates.languages)) {
|
|
793
|
+
linkTags.push(
|
|
794
|
+
`<link rel="alternate" hreflang="${escapeAttr(hreflang)}" href="${escapeAttr(absUrl(href))}">`,
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
if (m.alternates.media && typeof m.alternates.media === 'object') {
|
|
799
|
+
for (const [media, href] of Object.entries(m.alternates.media)) {
|
|
800
|
+
linkTags.push(
|
|
801
|
+
`<link rel="alternate" media="${escapeAttr(media)}" href="${escapeAttr(absUrl(href))}">`,
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
if (m.alternates.types && typeof m.alternates.types === 'object') {
|
|
806
|
+
// alternates.types: { 'application/rss+xml': '/rss.xml' }
|
|
807
|
+
for (const [type, href] of Object.entries(m.alternates.types)) {
|
|
808
|
+
linkTags.push(
|
|
809
|
+
`<link rel="alternate" type="${escapeAttr(type)}" href="${escapeAttr(absUrl(href))}">`,
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// JSON-LD structured data (schema.org). `m.jsonLd` is a single object
|
|
816
|
+
// OR an array of objects. The author owns the schema.org shape; the
|
|
817
|
+
// framework only serializes and HTML-safe-escapes each object into a
|
|
818
|
+
// `<script type="application/ld+json">` block. A single object emits
|
|
819
|
+
// ONE script; an array emits one script PER element.
|
|
820
|
+
//
|
|
821
|
+
// The block is a NON-EXECUTABLE data island (type application/ld+json),
|
|
822
|
+
// so CSP script-src does not gate it and it carries NO nonce. Adding one
|
|
823
|
+
// would wrongly imply it is executable script.
|
|
824
|
+
if (m.jsonLd != null) {
|
|
825
|
+
const list = Array.isArray(m.jsonLd) ? m.jsonLd : [m.jsonLd];
|
|
826
|
+
for (const obj of list) {
|
|
827
|
+
const tag = jsonLdScript(obj);
|
|
828
|
+
if (tag) scriptTags.push(tag);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Byte-identical to the pre-split template. Every hole is unconditional
|
|
833
|
+
// and carries its own newline, INCLUDING the csp-nonce meta, which renders
|
|
834
|
+
// as an EMPTY line when there is no nonce. Collapsing that into a
|
|
835
|
+
// conditional line makes the CSP-off document one newline shorter than the
|
|
836
|
+
// CSP-on one, and the framework guarantees the two render identically
|
|
837
|
+
// apart from the nonce itself.
|
|
838
|
+
const title = m.title || 'webjs app';
|
|
839
|
+
|
|
840
|
+
return `<!doctype html>
|
|
841
|
+
<html lang="en">
|
|
842
|
+
<head>
|
|
843
|
+
<meta charset="utf-8">
|
|
844
|
+
<style${n}>@layer webjs-host{:where([data-wj-host]){display:block}:where([data-wj-host][hidden]:not([hidden='until-found'])){display:none}}</style>
|
|
845
|
+
${opts.nonce ? `<meta name="csp-nonce" content="${escapeAttr(opts.nonce)}">` : ''}
|
|
846
|
+
${metaTags.join('\n')}
|
|
847
|
+
<title>${escapeHtml(title)}</title>
|
|
848
|
+
${publicEnvShim({ dev: opts.dev, nonce: opts.nonce })}${clientRouterEnabled() ? '' : `\n<script${n}>window.__WEBJS_CLIENT_ROUTER__=false;</script>`}
|
|
849
|
+
${importMapTag({ nonce: opts.nonce })}
|
|
850
|
+
${linkTags.join('\n')}
|
|
851
|
+
${scriptTags.length ? scriptTags.join('\n') + '\n' : ''}${boot}
|
|
852
|
+
${reload}
|
|
853
|
+
${suspenseBoot}
|
|
854
|
+
</head>
|
|
855
|
+
<body>
|
|
856
|
+
`;
|
|
857
|
+
}
|