@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.
Files changed (47) hide show
  1. package/index.d.ts +7 -2
  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 +173 -26
  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/router.js +11 -1
  30. package/src/ssr/client-router-flag.js +42 -0
  31. package/src/ssr/document.js +361 -0
  32. package/src/ssr/env-shim.js +51 -0
  33. package/src/ssr/escape.js +29 -0
  34. package/src/ssr/head.js +857 -0
  35. package/src/ssr/preloads.js +195 -0
  36. package/src/ssr/render.js +1481 -0
  37. package/src/ssr/responses.js +201 -0
  38. package/src/ssr.js +28 -2445
  39. package/src/vendor/audit.js +400 -0
  40. package/src/vendor/integrity.js +245 -0
  41. package/src/vendor/jspm.js +348 -0
  42. package/src/vendor/manifest.js +96 -0
  43. package/src/vendor/pins.js +892 -0
  44. package/src/vendor/providers.js +35 -0
  45. package/src/vendor/resolver.js +225 -0
  46. package/src/vendor/scanner.js +132 -0
  47. 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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
24
+ }
25
+
26
+ /** @param {string} s */
27
+ export function escapeHtml(s) {
28
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;');
29
+ }