@webjsdev/server 0.8.62 → 0.8.64

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 (48) hide show
  1. package/index.d.ts +32 -3
  2. package/package.json +2 -2
  3. package/src/browser-entries.js +64 -0
  4. package/src/check/helpers.js +365 -0
  5. package/src/check/rules-actions.js +174 -0
  6. package/src/check/rules-components.js +275 -0
  7. package/src/check/rules-imports.js +166 -0
  8. package/src/check/rules-registry.js +128 -0
  9. package/src/check/rules-routing.js +224 -0
  10. package/src/check/rules-typescript.js +177 -0
  11. package/src/check/rules.js +127 -0
  12. package/src/check/runner-support.js +405 -0
  13. package/src/check/runner.js +113 -0
  14. package/src/check.js +4 -1774
  15. package/src/component-elision.js +1 -1
  16. package/src/dev/config.js +338 -0
  17. package/src/dev/handler.js +1372 -0
  18. package/src/dev/helpers.js +542 -0
  19. package/src/dev/serve.js +840 -0
  20. package/src/dev/server.js +322 -0
  21. package/src/dev-classify.js +174 -0
  22. package/src/dev-reload-worker.js +133 -7
  23. package/src/dev-styles.js +97 -0
  24. package/src/dev.js +22 -3077
  25. package/src/form-dispatch.js +12 -3
  26. package/src/importmap.js +6 -3
  27. package/src/listener-core.js +16 -2
  28. package/src/module-graph.js +112 -7
  29. package/src/rate-limit.js +50 -5
  30. package/src/router.js +11 -1
  31. package/src/ssr/client-router-flag.js +42 -0
  32. package/src/ssr/document.js +361 -0
  33. package/src/ssr/env-shim.js +51 -0
  34. package/src/ssr/escape.js +29 -0
  35. package/src/ssr/head.js +857 -0
  36. package/src/ssr/preloads.js +195 -0
  37. package/src/ssr/render.js +1481 -0
  38. package/src/ssr/responses.js +201 -0
  39. package/src/ssr.js +28 -2445
  40. package/src/vendor/audit.js +400 -0
  41. package/src/vendor/integrity.js +245 -0
  42. package/src/vendor/jspm.js +348 -0
  43. package/src/vendor/manifest.js +96 -0
  44. package/src/vendor/pins.js +892 -0
  45. package/src/vendor/providers.js +35 -0
  46. package/src/vendor/resolver.js +225 -0
  47. package/src/vendor/scanner.js +132 -0
  48. package/src/vendor.js +29 -2371
@@ -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 `&lt;`
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
+ }