@webjsdev/server 0.8.63 → 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.
- 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 +1 -1
- 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
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { collectHoistedHeadTags, hoistHeadTags, wrapHead } from './head.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Derive a layout's segment path from its file path. The path identifies
|
|
5
|
+
* the layout's slot in the layout chain for partial-nav marker matching.
|
|
6
|
+
*
|
|
7
|
+
* app/layout.ts → '/'
|
|
8
|
+
* app/docs/layout.ts → '/docs'
|
|
9
|
+
* app/docs/components/layout.ts → '/docs/components'
|
|
10
|
+
* app/(marketing)/about/layout.ts → '/(marketing)/about'
|
|
11
|
+
*
|
|
12
|
+
* Route groups `(marketing)` are KEPT in the path. They don't appear in
|
|
13
|
+
* URLs but DO scope distinct layouts: two routes at the same URL prefix
|
|
14
|
+
* served by different `(group)` layouts must produce different markers
|
|
15
|
+
* so the client doesn't falsely identify them as a shared layout.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} layoutFile Absolute path to the layout source file.
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
export function layoutSegmentPath(layoutFile) {
|
|
21
|
+
const p = layoutFile
|
|
22
|
+
.replace(/^.*\/app\//, '')
|
|
23
|
+
.replace(/\/?layout\.[jt]sx?$/, '');
|
|
24
|
+
return p === '' ? '/' : '/' + p;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Like layoutSegmentPath but for the PAGE file. Strips the `page.ext`
|
|
29
|
+
* filename, yielding the page's own segment path (the full route pattern,
|
|
30
|
+
* dynamic tokens included):
|
|
31
|
+
*
|
|
32
|
+
* app/page.ts -> '/'
|
|
33
|
+
* app/blog/[slug]/page.ts -> '/blog/[slug]'
|
|
34
|
+
* app/files/[...rest]/page.ts -> '/files/[...rest]'
|
|
35
|
+
*
|
|
36
|
+
* This is the segment for the PAGE-level region (Pillar 1 / #1013): the page
|
|
37
|
+
* needs its own region keyed by the full resolved path so a dynamic-param
|
|
38
|
+
* change remounts the page (Next parity) while a shared parent LAYOUT (a
|
|
39
|
+
* shorter segment path whose route-key does not change) is preserved.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} pageFile Absolute path to the page source file.
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function pageSegmentPath(pageFile) {
|
|
45
|
+
const p = pageFile
|
|
46
|
+
.replace(/^.*\/app\//, '')
|
|
47
|
+
.replace(/\/?page\.[jt]sx?$/, '');
|
|
48
|
+
return p === '' ? '/' : '/' + p;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Like layoutSegmentPath but for a BOUNDARY file (error / not-found /
|
|
53
|
+
* forbidden / unauthorized). Strips the boundary filename, yielding the
|
|
54
|
+
* segment the boundary sits at:
|
|
55
|
+
*
|
|
56
|
+
* app/error.ts -> '/'
|
|
57
|
+
* app/docs/error.ts -> '/docs'
|
|
58
|
+
* app/(marketing)/about/not-found.ts -> '/(marketing)/about'
|
|
59
|
+
* app/global-not-found.ts -> '/' (root-only by definition)
|
|
60
|
+
*
|
|
61
|
+
* Route groups are KEPT, for the reason stated on layoutSegmentPath: two
|
|
62
|
+
* routes at one URL prefix served by different `(group)` layouts must not
|
|
63
|
+
* look like a shared layout.
|
|
64
|
+
*
|
|
65
|
+
* The filename match is anchored to a separator or the start of the path, and
|
|
66
|
+
* the `global-` forms are matched FIRST. Without the anchor, `not-found` also
|
|
67
|
+
* matches INSIDE `global-not-found.ts`, which yielded the nonsense segment
|
|
68
|
+
* `/global-` for a boundary that is root-only and must derive `/`.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} boundaryFile Absolute path to the boundary source file.
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function boundarySegmentPath(boundaryFile) {
|
|
74
|
+
const p = boundaryFile
|
|
75
|
+
.replace(/^.*\/app\//, '')
|
|
76
|
+
.replace(
|
|
77
|
+
/(?:^|\/)(?:global-error|global-not-found|error|not-found|forbidden|unauthorized)\.[jt]sx?$/,
|
|
78
|
+
'',
|
|
79
|
+
);
|
|
80
|
+
return p === '' ? '/' : '/' + p;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The layouts that wrap a boundary at `boundarySeg`: every layout whose own
|
|
85
|
+
* segment is that segment or an ancestor of it, in the chain's original
|
|
86
|
+
* outermost-first order (#1298).
|
|
87
|
+
*
|
|
88
|
+
* A layout DEEPER than the boundary never rendered, so it is excluded. The
|
|
89
|
+
* boundary's OWN segment layout is INCLUDED, which is Next's hierarchy
|
|
90
|
+
* (`layout.js` then `error.js` then `page.js`, so the boundary sits INSIDE its
|
|
91
|
+
* segment's layout). That is also why an error thrown by a layout is not
|
|
92
|
+
* caught by the boundary at its own segment: the boundary is nested inside the
|
|
93
|
+
* thing that threw, so the next boundary OUT handles it.
|
|
94
|
+
*
|
|
95
|
+
* The prefix test breaks on a segment, so '/doc' never matches '/docs'.
|
|
96
|
+
*
|
|
97
|
+
* @param {string[]} layouts route.layouts, outermost first.
|
|
98
|
+
* @param {string} boundarySeg
|
|
99
|
+
* @returns {string[]}
|
|
100
|
+
*/
|
|
101
|
+
export function layoutsForBoundary(layouts, boundarySeg) {
|
|
102
|
+
return (layouts || []).filter((f) => {
|
|
103
|
+
const seg = layoutSegmentPath(f);
|
|
104
|
+
return seg === boundarySeg || boundarySeg.startsWith(seg === '/' ? '/' : seg + '/');
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Derive a region's ROUTE-KEY from its segment path pattern and the render's
|
|
110
|
+
* resolved params. The route-key is the CONCRETE resolved URL path for the
|
|
111
|
+
* region: dynamic `[param]` / catch-all `[...param]` / optional-catch-all
|
|
112
|
+
* `[[...param]]` tokens are substituted with their param values, and `(group)`
|
|
113
|
+
* segments are dropped (they scope layouts but never appear in the URL).
|
|
114
|
+
* searchParams are excluded by construction (params carries route params only).
|
|
115
|
+
*
|
|
116
|
+
* The client router compares a region's OLD vs NEW route-key to pick the swap
|
|
117
|
+
* tier: route-key CHANGED -> wholesale replace (Next page-remount parity),
|
|
118
|
+
* route-key SAME -> bounded same-route morph (hydrated component state kept, the
|
|
119
|
+
* searchParams-only-nav case). A static segment (`/`, `/docs`) has a constant
|
|
120
|
+
* route-key, so that layout's region never remounts and its chrome always
|
|
121
|
+
* survives.
|
|
122
|
+
*
|
|
123
|
+
* PARAM VALUES ARE ENCODED (`encodeURIComponent`, per path piece). The
|
|
124
|
+
* route-key rides inside the boundary COMMENT, and param values are
|
|
125
|
+
* user-controlled, so an unencoded value could carry `-->` and terminate the
|
|
126
|
+
* comment mid-boundary. Encoding removes `<`, `>`, `:`, `/` from every
|
|
127
|
+
* substituted piece, which makes all three HTML-forbidden comment sequences
|
|
128
|
+
* (`<!--`, `-->`, `--!>`) impossible (each needs `<` or `>`) and keeps `:`
|
|
129
|
+
* unambiguous as the boundary-format delimiter. A catch-all's value is encoded
|
|
130
|
+
* per PIECE (split on `/`), so its literal separators stay readable. Static
|
|
131
|
+
* segments come from folder names and are emitted as-is. A bare `--` can
|
|
132
|
+
* survive encoding (`-` is unreserved) and is legal inside an HTML5 comment.
|
|
133
|
+
* The key is only ever compared for equality, so encoding does not affect the
|
|
134
|
+
* swap-tier decision.
|
|
135
|
+
*
|
|
136
|
+
* regionRouteKey('/', {}) -> '/'
|
|
137
|
+
* regionRouteKey('/docs', {}) -> '/docs'
|
|
138
|
+
* regionRouteKey('/blog/[slug]', {slug:'a'}) -> '/blog/a'
|
|
139
|
+
* regionRouteKey('/(marketing)/about', {}) -> '/about'
|
|
140
|
+
* regionRouteKey('/files/[...rest]', {rest:'a/b'}) -> '/files/a/b'
|
|
141
|
+
* regionRouteKey('/shop/[[...slug]]', {}) -> '/shop'
|
|
142
|
+
* regionRouteKey('/blog/[slug]', {slug:'a-->b'}) -> '/blog/a--%3Eb'
|
|
143
|
+
*
|
|
144
|
+
* @param {string} segmentPath Region segment pattern, e.g. '/blog/[slug]'.
|
|
145
|
+
* @param {Record<string,string>} params Resolved route params (values are
|
|
146
|
+
* strings; a catch-all value is already slash-joined, e.g. 'a/b/c').
|
|
147
|
+
* @returns {string}
|
|
148
|
+
*/
|
|
149
|
+
export function regionRouteKey(segmentPath, params) {
|
|
150
|
+
const p = params || {};
|
|
151
|
+
/** Encode one substituted value, per slash-piece (catch-alls keep their
|
|
152
|
+
* literal separators; each piece is comment-safe + delimiter-safe). */
|
|
153
|
+
const enc = (v) => String(v).split('/').map((s) => encodeURIComponent(s)).join('/');
|
|
154
|
+
/** A STATIC piece is a folder name emitted as-is EXCEPT the boundary/header
|
|
155
|
+
* delimiter characters (':' would mis-split the `segment:route-key` parses,
|
|
156
|
+
* ',' the have-entry list, '%' the decode); percent-encode just those so
|
|
157
|
+
* the no-delimiter invariant holds for every emitted route-key while
|
|
158
|
+
* normal folder names stay byte-identical. */
|
|
159
|
+
const encStatic = (v) => v.replace(/[%:,]/g, (c) => encodeURIComponent(c));
|
|
160
|
+
const out = [];
|
|
161
|
+
for (const seg of segmentPath.split('/')) {
|
|
162
|
+
if (!seg) continue;
|
|
163
|
+
// Route group `(marketing)`: scopes layouts, absent from the URL.
|
|
164
|
+
if (seg.startsWith('(') && seg.endsWith(')')) continue;
|
|
165
|
+
// Optional catch-all `[[...name]]` / catch-all `[...name]`: the value is
|
|
166
|
+
// the already-slash-joined tail (may be '' for an empty optional one).
|
|
167
|
+
if (seg.startsWith('[[...') && seg.endsWith(']]')) {
|
|
168
|
+
const v = p[seg.slice(5, -2)];
|
|
169
|
+
if (v) out.push(enc(v));
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (seg.startsWith('[...') && seg.endsWith(']')) {
|
|
173
|
+
const v = p[seg.slice(4, -1)];
|
|
174
|
+
if (v) out.push(enc(v));
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// Dynamic `[name]`.
|
|
178
|
+
if (seg.startsWith('[') && seg.endsWith(']')) {
|
|
179
|
+
out.push(enc(p[seg.slice(1, -1)] ?? ''));
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
out.push(encStatic(seg));
|
|
183
|
+
}
|
|
184
|
+
return '/' + out.join('/');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Wrap a TemplateResult-or-renderable child in a KEYED partial-nav boundary
|
|
189
|
+
* comment pair (#1015). Returns a synthetic TemplateResult: server
|
|
190
|
+
* `renderToString` walks `.strings` and `.values` exactly the same way as for
|
|
191
|
+
* the `html` tag.
|
|
192
|
+
*
|
|
193
|
+
* Format:
|
|
194
|
+
* open <!--wj:children:<segment>:<route-key>-->
|
|
195
|
+
* close <!--/wj:children:<segment>-->
|
|
196
|
+
*
|
|
197
|
+
* The close carries the SEGMENT, so client-side pairing is deterministic
|
|
198
|
+
* id-matching instead of the LIFO reconstruction that produced the #994 class
|
|
199
|
+
* of silent mispair. The open additionally carries the resolved ROUTE-KEY
|
|
200
|
+
* (param values encoded, see `regionRouteKey`), which drives the client's
|
|
201
|
+
* two-tier swap decision: key changed -> wholesale replace (Next remount
|
|
202
|
+
* parity), key same -> bounded morph (hydrated state preserved). A comment is
|
|
203
|
+
* invisible to structural CSS (`>`, `:nth-child`, flex/grid item enumeration),
|
|
204
|
+
* so unlike a wrapper element this boundary is layout-free by construction.
|
|
205
|
+
*
|
|
206
|
+
* The marker text lives in `strings` (static template parts), NOT in
|
|
207
|
+
* `values`: `values` get HTML-escaped on render, comments wouldn't survive.
|
|
208
|
+
*
|
|
209
|
+
* @param {unknown} tree A TemplateResult, string, array, or Promise.
|
|
210
|
+
* @param {string} segmentPath The boundary's segment pattern (pairing id).
|
|
211
|
+
* @param {Record<string,string>} params Resolved route params for the key.
|
|
212
|
+
* @returns {{ _$webjs: 'template', strings: string[], values: unknown[] }}
|
|
213
|
+
*/
|
|
214
|
+
export function wrapWithChildrenMarker(tree, segmentPath, params) {
|
|
215
|
+
const routeKey = regionRouteKey(segmentPath, params);
|
|
216
|
+
return {
|
|
217
|
+
_$webjs: 'template',
|
|
218
|
+
strings: [
|
|
219
|
+
`<!--wj:children:${segmentPath}:${routeKey}-->`,
|
|
220
|
+
`<!--/wj:children:${segmentPath}-->`,
|
|
221
|
+
],
|
|
222
|
+
values: [tree],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Internal helpers re-exported for unit testing. */
|
|
227
|
+
export const _layoutSegmentPath = layoutSegmentPath;
|
|
228
|
+
export const _pageSegmentPath = pageSegmentPath;
|
|
229
|
+
export const _boundarySegmentPath = boundarySegmentPath;
|
|
230
|
+
export const _layoutsForBoundary = layoutsForBoundary;
|
|
231
|
+
export const _regionRouteKey = regionRouteKey;
|
|
232
|
+
export const _wrapWithChildrenMarker = wrapWithChildrenMarker;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Detect a user-supplied <!doctype><html>…</html> shell at the top of
|
|
236
|
+
* `body`. Returns the parsed parts when present; otherwise null.
|
|
237
|
+
*
|
|
238
|
+
* The framework owns the shell by default: it auto-emits
|
|
239
|
+
* `<!doctype><html lang="en"><head>…</head><body>` around every page.
|
|
240
|
+
* But the *root layout* (only) may write its own shell to set
|
|
241
|
+
* `<html lang>`, `<html dir>`, `<html data-*>`, `<body class>`, etc.
|
|
242
|
+
* When that happens we keep the user's shell verbatim and splice the
|
|
243
|
+
* framework's required `<head>` tags (importmap, modulepreload, title,
|
|
244
|
+
* meta, og/twitter) into the user's `<head>`. Non-root layouts that
|
|
245
|
+
* try this would produce nested-shell garbage; `webjs check` flags
|
|
246
|
+
* them via the `shell-in-non-root-layout` rule.
|
|
247
|
+
*
|
|
248
|
+
* @param {string} body
|
|
249
|
+
* @returns {{
|
|
250
|
+
* htmlAttrs: string,
|
|
251
|
+
* headAttrs: string,
|
|
252
|
+
* userHead: string,
|
|
253
|
+
* bodyAttrs: string,
|
|
254
|
+
* userBody: string,
|
|
255
|
+
* } | null}
|
|
256
|
+
*/
|
|
257
|
+
export function extractUserShell(body) {
|
|
258
|
+
// Tolerant: allow optional whitespace, optional <!doctype>, then <html ...>.
|
|
259
|
+
// Capture html attributes (anything between <html and >).
|
|
260
|
+
const htmlOpen = /^\s*(?:<!doctype[^>]*>\s*)?<html\b([^>]*)>\s*([\s\S]*)<\/html>\s*$/i;
|
|
261
|
+
const m = body.match(htmlOpen);
|
|
262
|
+
if (!m) return null;
|
|
263
|
+
const htmlAttrs = m[1] || '';
|
|
264
|
+
const shellInner = m[2];
|
|
265
|
+
|
|
266
|
+
// <head> is optional inside the user's shell: if missing, the
|
|
267
|
+
// framework's head content stands alone. Same for <body>.
|
|
268
|
+
const headRe = /<head\b([^>]*)>([\s\S]*?)<\/head>/i;
|
|
269
|
+
const bodyRe = /<body\b([^>]*)>([\s\S]*?)<\/body>/i;
|
|
270
|
+
const headMatch = shellInner.match(headRe);
|
|
271
|
+
const bodyMatch = shellInner.match(bodyRe);
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
htmlAttrs,
|
|
275
|
+
headAttrs: headMatch ? (headMatch[1] || '') : '',
|
|
276
|
+
userHead: headMatch ? headMatch[2] : '',
|
|
277
|
+
bodyAttrs: bodyMatch ? (bodyMatch[1] || '') : '',
|
|
278
|
+
// If the user omitted <body>, treat everything outside <head>…</head>
|
|
279
|
+
// as their body content.
|
|
280
|
+
userBody: bodyMatch
|
|
281
|
+
? bodyMatch[2]
|
|
282
|
+
: (headMatch ? shellInner.replace(headMatch[0], '') : shellInner).trim(),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Re-export for unit testing. */
|
|
287
|
+
export const _extractUserShell = extractUserShell;
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Inner-only variant of wrapHead: returns just the meta/title/link/script
|
|
291
|
+
* tags that should live INSIDE <head>, without the surrounding
|
|
292
|
+
* <!doctype><html><head>…</head><body> shell. Used to splice into a
|
|
293
|
+
* user-provided shell from `extractUserShell()`.
|
|
294
|
+
*
|
|
295
|
+
* @param {Parameters<typeof wrapHead>[0]} opts
|
|
296
|
+
* @returns {string}
|
|
297
|
+
*/
|
|
298
|
+
function buildHeadInner(opts) {
|
|
299
|
+
// Pull the full prefix and strip the <!doctype><html><head> opening + the
|
|
300
|
+
// closing </head><body> so we're left with the inner tags only. Keeps a
|
|
301
|
+
// single source of truth for what goes in <head>.
|
|
302
|
+
const full = wrapHead({ ...opts, streaming: false });
|
|
303
|
+
const start = full.indexOf('<head>');
|
|
304
|
+
const end = full.indexOf('</head>');
|
|
305
|
+
if (start === -1 || end === -1) return '';
|
|
306
|
+
// +'<head>'.length to skip past the opening tag itself.
|
|
307
|
+
return full.slice(start + '<head>'.length, end).trim();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Build the prefix/body/closer triple for a rendered layout's body. Single
|
|
312
|
+
* source of truth used by both the buffered (`wrapInDocument`) and
|
|
313
|
+
* streaming (`streamingHtmlResponse`) paths.
|
|
314
|
+
*
|
|
315
|
+
* If `body` starts with a user-supplied <!doctype><html>…</html> shell:
|
|
316
|
+
* - `prefix` opens with the user's `<!doctype><html><head>` (with their
|
|
317
|
+
* attributes), splices the framework's required tags + the user's
|
|
318
|
+
* own head content + auto-hoisted body-positioned head-bound tags,
|
|
319
|
+
* then closes `</head>` and opens `<body>` (with user attributes).
|
|
320
|
+
* - `streamBody` is the user's body content (head-hoist already stripped).
|
|
321
|
+
* - `closer` is `</body></html>`.
|
|
322
|
+
*
|
|
323
|
+
* Otherwise (no user shell): use the framework's auto-emitted shell.
|
|
324
|
+
*
|
|
325
|
+
* @param {string} body
|
|
326
|
+
* @param {Parameters<typeof wrapHead>[0]} wrapOpts
|
|
327
|
+
* @returns {{ prefix: string, streamBody: string, closer: string }}
|
|
328
|
+
*/
|
|
329
|
+
export function buildDocumentParts(body, wrapOpts) {
|
|
330
|
+
const shell = extractUserShell(body);
|
|
331
|
+
if (shell) {
|
|
332
|
+
const headInner = buildHeadInner(wrapOpts);
|
|
333
|
+
const hoist = collectHoistedHeadTags(shell.userBody);
|
|
334
|
+
const composedHead = [headInner, shell.userHead.trim(), hoist.tags.join('\n')]
|
|
335
|
+
.filter(Boolean)
|
|
336
|
+
.join('\n');
|
|
337
|
+
const prefix =
|
|
338
|
+
`<!doctype html>\n<html${shell.htmlAttrs}>\n<head${shell.headAttrs}>\n` +
|
|
339
|
+
composedHead +
|
|
340
|
+
`\n</head>\n<body${shell.bodyAttrs}>\n`;
|
|
341
|
+
return { prefix, streamBody: hoist.body, closer: `\n</body>\n</html>` };
|
|
342
|
+
}
|
|
343
|
+
// No user shell: framework owns the wrapper.
|
|
344
|
+
const headHtml = wrapHead(wrapOpts);
|
|
345
|
+
const { head, body: bodyOut } = hoistHeadTags(headHtml, body);
|
|
346
|
+
return { prefix: head, streamBody: bodyOut, closer: `\n</body>\n</html>` };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Re-export for unit testing. */
|
|
350
|
+
export const _buildDocumentParts = buildDocumentParts;
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Buffered wrapper (error / not-found paths; no Suspense streaming).
|
|
354
|
+
*
|
|
355
|
+
* @param {string} body
|
|
356
|
+
* @param {{ metadata: Record<string,any>, moduleUrls: string[], dev: boolean }} opts
|
|
357
|
+
*/
|
|
358
|
+
export function wrapInDocument(body, opts) {
|
|
359
|
+
const { prefix, streamBody, closer } = buildDocumentParts(body, { ...opts, streaming: false });
|
|
360
|
+
return prefix + streamBody + closer;
|
|
361
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { jsonForScriptTag } from '../script-tag-json.js';
|
|
2
|
+
import { escapeAttr } from './escape.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `window.process.env` shim, kept in its own module because BOTH the head
|
|
6
|
+
* builder and the render path emit it.
|
|
7
|
+
*
|
|
8
|
+
* It lived in `document.js` before, which made `head.js` import from
|
|
9
|
+
* `document.js` while `document.js` imported from `head.js`. Nothing in
|
|
10
|
+
* `document.js` used it, so the dependency was on the file rather than on the
|
|
11
|
+
* code, and moving it here is what breaks that half of the ssr cycle.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build an inline `<script>` that exposes server-side environment
|
|
16
|
+
* variables to the browser via `window.process.env`. Two purposes:
|
|
17
|
+
*
|
|
18
|
+
* 1. App code can read `process.env.WEBJS_PUBLIC_X` directly in
|
|
19
|
+
* components (counterpart of Next.js's `NEXT_PUBLIC_` prefix,
|
|
20
|
+
* but without a build step).
|
|
21
|
+
* 2. `process.env.NODE_ENV` is defined for vendor bundles that
|
|
22
|
+
* probe it (lit, react, etc.) so they do not throw
|
|
23
|
+
* ReferenceError in the browser.
|
|
24
|
+
*
|
|
25
|
+
* Only variables whose name starts with `WEBJS_PUBLIC_` are exposed.
|
|
26
|
+
* Other server env vars stay on the server.
|
|
27
|
+
*
|
|
28
|
+
* `</...` sequences in stringified values are escaped so an env value
|
|
29
|
+
* containing `</script>` cannot terminate the inline script tag.
|
|
30
|
+
*
|
|
31
|
+
* @param {{ dev: boolean, nonce?: string, env?: Record<string, string|undefined> }} opts
|
|
32
|
+
* `env` defaults to `process.env`. Override for tests.
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function publicEnvShim(opts) {
|
|
36
|
+
const source = opts.env || process.env;
|
|
37
|
+
/** @type {Record<string, string>} */
|
|
38
|
+
const env = {};
|
|
39
|
+
for (const [k, v] of Object.entries(source)) {
|
|
40
|
+
if (k.startsWith('WEBJS_PUBLIC_') && v !== undefined) {
|
|
41
|
+
env[k] = String(v);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
env.NODE_ENV = opts.dev ? 'development' : 'production';
|
|
45
|
+
const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
|
|
46
|
+
return `<script${n}>`
|
|
47
|
+
+ `window.process=window.process||{};`
|
|
48
|
+
+ `window.process.env=Object.assign(window.process.env||{},${jsonForScriptTag(env)});`
|
|
49
|
+
+ `</script>`;
|
|
50
|
+
}
|
|
51
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTML escapers the SSR render and head paths use, in one place.
|
|
3
|
+
*
|
|
4
|
+
* Not quite the only pair in the package: `importmap.js` keeps a private,
|
|
5
|
+
* byte-identical `escapeAttr` for the one attribute it writes itself, on the
|
|
6
|
+
* stated ground that one small helper is not worth a cross-file dependency.
|
|
7
|
+
* That one is in step with these and has to stay so.
|
|
8
|
+
*
|
|
9
|
+
* `main` had a single pair serving every call site. The split produced three
|
|
10
|
+
* copies, in the render path, the head builder and the env shim, and two of
|
|
11
|
+
* them widened to also escape `>` and to coerce with `?? ''`. That is not a
|
|
12
|
+
* cosmetic difference: these decide served bytes, so a widened copy moves every
|
|
13
|
+
* ETag it touches, and the head copy serves `<title>`, every `<meta content>`,
|
|
14
|
+
* every `<link href>` and `integrity=`.
|
|
15
|
+
*
|
|
16
|
+
* Kept deliberately narrow, matching `main`: `&`, `"` and `<` for an attribute,
|
|
17
|
+
* `&` and `<` for text. A `>` in text or in a quoted attribute value is not
|
|
18
|
+
* markup, so escaping it only changes bytes.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** @param {string} s */
|
|
22
|
+
export function escapeAttr(s) {
|
|
23
|
+
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {string} s */
|
|
27
|
+
export function escapeHtml(s) {
|
|
28
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<');
|
|
29
|
+
}
|