@c9up/aurora 0.1.3

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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/dist/AuroraManager.d.ts +44 -0
  4. package/dist/AuroraManager.js +47 -0
  5. package/dist/AuroraProvider.d.ts +52 -0
  6. package/dist/AuroraProvider.js +145 -0
  7. package/dist/Pages.d.ts +78 -0
  8. package/dist/Pages.js +116 -0
  9. package/dist/component.d.ts +55 -0
  10. package/dist/component.js +97 -0
  11. package/dist/html.d.ts +30 -0
  12. package/dist/html.js +246 -0
  13. package/dist/hydrate.d.ts +29 -0
  14. package/dist/hydrate.js +379 -0
  15. package/dist/index.d.ts +12 -0
  16. package/dist/index.js +12 -0
  17. package/dist/reactive.d.ts +83 -0
  18. package/dist/reactive.js +217 -0
  19. package/dist/relay.d.ts +43 -0
  20. package/dist/relay.js +144 -0
  21. package/dist/render.d.ts +25 -0
  22. package/dist/render.js +283 -0
  23. package/dist/route.d.ts +64 -0
  24. package/dist/route.js +49 -0
  25. package/dist/server/renderPage.d.ts +62 -0
  26. package/dist/server/renderPage.js +83 -0
  27. package/dist/server/serveAssets.d.ts +43 -0
  28. package/dist/server/serveAssets.js +89 -0
  29. package/dist/services/main.d.ts +18 -0
  30. package/dist/services/main.js +31 -0
  31. package/dist/ssr.d.ts +22 -0
  32. package/dist/ssr.js +179 -0
  33. package/dist/types.d.ts +78 -0
  34. package/dist/types.js +15 -0
  35. package/package.json +69 -0
  36. package/src/AuroraManager.ts +76 -0
  37. package/src/AuroraProvider.ts +187 -0
  38. package/src/Pages.ts +164 -0
  39. package/src/component.ts +138 -0
  40. package/src/html.ts +296 -0
  41. package/src/hydrate.ts +518 -0
  42. package/src/index.ts +43 -0
  43. package/src/reactive.ts +265 -0
  44. package/src/relay.ts +171 -0
  45. package/src/render.ts +378 -0
  46. package/src/route.ts +96 -0
  47. package/src/server/renderPage.ts +135 -0
  48. package/src/server/serveAssets.ts +135 -0
  49. package/src/services/main.ts +40 -0
  50. package/src/ssr.ts +179 -0
  51. package/src/types.ts +97 -0
package/src/render.ts ADDED
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Render a `TemplateResult` to the DOM and keep it reactive.
3
+ *
4
+ * `render(template, container)` clones the parsed `<template>`, walks to
5
+ * each slot, attaches the corresponding value (with `effect()` for any
6
+ * reactive expression), and appends the result to the container. The
7
+ * returned dispose function tears down every effect and removes the
8
+ * mounted nodes.
9
+ */
10
+
11
+ import { readComponentLifecycle } from "./component.js";
12
+ import { getTemplate } from "./html.js";
13
+ import { effect, isSignal } from "./reactive.js";
14
+ import {
15
+ type AttrSlot,
16
+ type BooleanAttrSlot,
17
+ type EffectCallback,
18
+ type EventSlot,
19
+ isTemplateResult,
20
+ type NodePath,
21
+ type PropSlot,
22
+ type Slot,
23
+ type TemplateResult,
24
+ type TextSlot,
25
+ } from "./types.js";
26
+
27
+ export type Disposer = () => void;
28
+
29
+ /**
30
+ * Mount a TemplateResult into `container`. Returns a `Disposer` that
31
+ * stops every reactive effect and removes the mounted nodes. Calling it
32
+ * twice is a no-op.
33
+ */
34
+ export function render(
35
+ result: TemplateResult,
36
+ container: Element | DocumentFragment,
37
+ ): Disposer {
38
+ const cleanups: Disposer[] = [];
39
+ const mountedNodes: ChildNode[] = [];
40
+ const mountHooks: Array<EffectCallback> = [];
41
+
42
+ const fragment = mount(result, cleanups, mountedNodes, mountHooks);
43
+ container.appendChild(fragment);
44
+
45
+ // `onMount` hooks fire after the fragment is live in the document so
46
+ // callbacks that measure / focus / observe see a real DOM. A returned
47
+ // cleanup function joins the unmount queue.
48
+ for (const hook of mountHooks) {
49
+ try {
50
+ const teardown = hook();
51
+ if (typeof teardown === "function") cleanups.push(teardown);
52
+ } catch {
53
+ /* swallow — one bad onMount should not block sibling components */
54
+ }
55
+ }
56
+
57
+ let disposed = false;
58
+ return () => {
59
+ if (disposed) return;
60
+ disposed = true;
61
+ for (const c of cleanups.splice(0)) c();
62
+ for (const node of mountedNodes) node.remove();
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Pending multi-slot attribute group — collected during the per-slot
68
+ * pass so the renderer can wire a single effect per `(element, attr)`
69
+ * after every contributing value is known.
70
+ */
71
+ interface MultiAttrGroup {
72
+ el: Element;
73
+ name: string;
74
+ staticParts: readonly string[];
75
+ /** Source-ordered values from each contributing slot. */
76
+ values: unknown[];
77
+ }
78
+
79
+ /**
80
+ * Build a fragment for a TemplateResult and register cleanups.
81
+ *
82
+ * @internal Exported so `hydrate.ts` can client-render a reactive
83
+ * nested-template subtree when the signal changes after hydration
84
+ * (the swap path — see `hydrateTextSlot`).
85
+ */
86
+ export function mount(
87
+ result: TemplateResult,
88
+ cleanups: Disposer[],
89
+ mounted: ChildNode[],
90
+ mountHooks: Array<EffectCallback>,
91
+ ): DocumentFragment {
92
+ const tpl = getTemplate(result.strings);
93
+ const fragment = tpl.element.content.cloneNode(true) as DocumentFragment;
94
+
95
+ // Forward any component()-attached lifecycle from this result.
96
+ const lifecycle = readComponentLifecycle(result);
97
+ if (lifecycle) {
98
+ for (const hook of lifecycle.mountHooks) mountHooks.push(hook);
99
+ for (const c of lifecycle.cleanups) cleanups.push(c);
100
+ }
101
+
102
+ // Multi-slot attrs need every contributing value before we can join
103
+ // the final string. Collect them in a first pass, attach effects
104
+ // after.
105
+ const multiGroups = new Map<string, MultiAttrGroup>();
106
+
107
+ for (let i = 0; i < tpl.slots.length; i++) {
108
+ const slot = tpl.slots[i];
109
+ const node = resolvePath(fragment, slot.path);
110
+ if (slot.kind === "attr" && slot.staticParts !== undefined) {
111
+ collectMultiAttr(slot, node as Element, result.values[i], multiGroups);
112
+ } else {
113
+ applySlot(slot, node, result.values[i], cleanups, mounted, mountHooks);
114
+ }
115
+ }
116
+
117
+ for (const group of multiGroups.values()) {
118
+ applyMultiAttrGroup(group, cleanups);
119
+ }
120
+
121
+ for (const child of Array.from(fragment.childNodes)) {
122
+ mounted.push(child);
123
+ }
124
+ return fragment;
125
+ }
126
+
127
+ function resolvePath(root: ParentNode, path: NodePath): Node {
128
+ let node: Node = root;
129
+ for (const i of path) node = node.childNodes[i];
130
+ return node;
131
+ }
132
+
133
+ function applySlot(
134
+ slot: Slot,
135
+ node: Node,
136
+ value: unknown,
137
+ cleanups: Disposer[],
138
+ mounted: ChildNode[],
139
+ mountHooks: Array<EffectCallback>,
140
+ ): void {
141
+ switch (slot.kind) {
142
+ case "text":
143
+ applyTextSlot(
144
+ slot,
145
+ node as Comment,
146
+ value,
147
+ cleanups,
148
+ mounted,
149
+ mountHooks,
150
+ );
151
+ return;
152
+ case "attr":
153
+ applyAttrSlot(slot, node as Element, value, cleanups);
154
+ return;
155
+ case "boolean-attr":
156
+ applyBooleanAttrSlot(slot, node as Element, value, cleanups);
157
+ return;
158
+ case "prop":
159
+ applyPropSlot(slot, node as Element, value, cleanups);
160
+ return;
161
+ case "event":
162
+ applyEventSlot(slot, node as Element, value, cleanups);
163
+ return;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Text slot — replace the marker comment with whatever the value
169
+ * resolves to. The anchor comment stays in place; new content is
170
+ * inserted before it, and each re-render swaps out only the nodes it
171
+ * previously inserted.
172
+ */
173
+ function applyTextSlot(
174
+ _slot: TextSlot,
175
+ anchor: Comment,
176
+ value: unknown,
177
+ cleanups: Disposer[],
178
+ mounted: ChildNode[],
179
+ mountHooks: Array<EffectCallback>,
180
+ ): void {
181
+ let currentNodes: ChildNode[] = [];
182
+ // Per-render disposers for whatever the slot currently shows. A
183
+ // reactive slot that swaps a nested TemplateResult for another must
184
+ // dispose the OLD subtree's effects + event listeners — otherwise
185
+ // they'd live in the shared `cleanups` array until the whole root
186
+ // disposes, leaking a stale subscription/listener on every branch
187
+ // change. We hand `localCleanups` (not `cleanups`) to the per-render
188
+ // mount and tear it down at the top of each `set()`.
189
+ let localCleanups: Disposer[] = [];
190
+
191
+ function disposeLocal(): void {
192
+ for (const d of localCleanups) d();
193
+ localCleanups = [];
194
+ }
195
+
196
+ function set(newValue: unknown): void {
197
+ disposeLocal();
198
+ for (const n of currentNodes) n.remove();
199
+ currentNodes = [];
200
+ const nodes = renderValueIntoNodes(
201
+ newValue,
202
+ localCleanups,
203
+ mounted,
204
+ mountHooks,
205
+ );
206
+ const parent = anchor.parentNode;
207
+ if (!parent) return;
208
+ for (const n of nodes) {
209
+ parent.insertBefore(n, anchor);
210
+ currentNodes.push(n);
211
+ }
212
+ }
213
+
214
+ if (isSignal(value) || typeof value === "function") {
215
+ const dispose = effect(() => {
216
+ set((value as () => unknown)());
217
+ });
218
+ // Root disposal tears down the slot's own effect AND whatever
219
+ // subtree is currently mounted.
220
+ cleanups.push(() => {
221
+ dispose();
222
+ disposeLocal();
223
+ });
224
+ } else {
225
+ set(value);
226
+ // Static value never re-runs, but its subtree (e.g. a one-shot
227
+ // nested template) still needs to be disposed with the root.
228
+ cleanups.push(disposeLocal);
229
+ }
230
+ }
231
+
232
+ function renderValueIntoNodes(
233
+ value: unknown,
234
+ cleanups: Disposer[],
235
+ mounted: ChildNode[],
236
+ mountHooks: Array<EffectCallback>,
237
+ ): ChildNode[] {
238
+ if (value === null || value === undefined || value === false) return [];
239
+ if (Array.isArray(value)) {
240
+ const out: ChildNode[] = [];
241
+ for (const item of value) {
242
+ out.push(...renderValueIntoNodes(item, cleanups, mounted, mountHooks));
243
+ }
244
+ return out;
245
+ }
246
+ if (isTemplateResult(value)) {
247
+ const frag = mount(value, cleanups, mounted, mountHooks);
248
+ return Array.from(frag.childNodes);
249
+ }
250
+ if (value instanceof Node) {
251
+ return [value as ChildNode];
252
+ }
253
+ return [document.createTextNode(String(value))];
254
+ }
255
+
256
+ function applyAttrSlot(
257
+ slot: AttrSlot,
258
+ el: Element,
259
+ value: unknown,
260
+ cleanups: Disposer[],
261
+ ): void {
262
+ function apply(v: unknown): void {
263
+ if (v === null || v === undefined || v === false) {
264
+ el.removeAttribute(slot.name);
265
+ } else if (v === true) {
266
+ el.setAttribute(slot.name, "");
267
+ } else {
268
+ el.setAttribute(slot.name, String(v));
269
+ }
270
+ }
271
+ if (isSignal(value) || typeof value === "function") {
272
+ const dispose = effect(() => apply((value as () => unknown)()));
273
+ cleanups.push(dispose);
274
+ } else {
275
+ apply(value);
276
+ }
277
+ }
278
+
279
+ function collectMultiAttr(
280
+ slot: AttrSlot,
281
+ el: Element,
282
+ value: unknown,
283
+ groups: Map<string, MultiAttrGroup>,
284
+ ): void {
285
+ if (!slot.staticParts) return;
286
+ const key = `${slot.name}::${(slot.path as readonly number[]).join(".")}`;
287
+ let group = groups.get(key);
288
+ if (!group) {
289
+ group = {
290
+ el,
291
+ name: slot.name,
292
+ staticParts: slot.staticParts,
293
+ values: [],
294
+ };
295
+ groups.set(key, group);
296
+ }
297
+ group.values.push(value);
298
+ }
299
+
300
+ function applyMultiAttrGroup(
301
+ group: MultiAttrGroup,
302
+ cleanups: Disposer[],
303
+ ): void {
304
+ function join(): string {
305
+ let out = group.staticParts[0] ?? "";
306
+ for (let i = 0; i < group.values.length; i++) {
307
+ const v = resolveReactive(group.values[i]);
308
+ out += v == null || v === false ? "" : String(v);
309
+ out += group.staticParts[i + 1] ?? "";
310
+ }
311
+ return out;
312
+ }
313
+
314
+ const hasReactive = group.values.some(
315
+ (v) => isSignal(v) || typeof v === "function",
316
+ );
317
+ if (hasReactive) {
318
+ const dispose = effect(() => {
319
+ group.el.setAttribute(group.name, join());
320
+ });
321
+ cleanups.push(dispose);
322
+ } else {
323
+ group.el.setAttribute(group.name, join());
324
+ }
325
+ }
326
+
327
+ function resolveReactive(value: unknown): unknown {
328
+ if (isSignal(value)) return value();
329
+ if (typeof value === "function") return (value as () => unknown)();
330
+ return value;
331
+ }
332
+
333
+ function applyBooleanAttrSlot(
334
+ slot: BooleanAttrSlot,
335
+ el: Element,
336
+ value: unknown,
337
+ cleanups: Disposer[],
338
+ ): void {
339
+ function apply(v: unknown): void {
340
+ if (v) el.setAttribute(slot.name, "");
341
+ else el.removeAttribute(slot.name);
342
+ }
343
+ if (isSignal(value) || typeof value === "function") {
344
+ const dispose = effect(() => apply((value as () => unknown)()));
345
+ cleanups.push(dispose);
346
+ } else {
347
+ apply(value);
348
+ }
349
+ }
350
+
351
+ function applyPropSlot(
352
+ slot: PropSlot,
353
+ el: Element,
354
+ value: unknown,
355
+ cleanups: Disposer[],
356
+ ): void {
357
+ function apply(v: unknown): void {
358
+ (el as unknown as Record<string, unknown>)[slot.name] = v;
359
+ }
360
+ if (isSignal(value) || typeof value === "function") {
361
+ const dispose = effect(() => apply((value as () => unknown)()));
362
+ cleanups.push(dispose);
363
+ } else {
364
+ apply(value);
365
+ }
366
+ }
367
+
368
+ function applyEventSlot(
369
+ slot: EventSlot,
370
+ el: Element,
371
+ value: unknown,
372
+ cleanups: Disposer[],
373
+ ): void {
374
+ if (typeof value !== "function") return;
375
+ const handler = value as EventListener;
376
+ el.addEventListener(slot.event, handler);
377
+ cleanups.push(() => el.removeEventListener(slot.event, handler));
378
+ }
package/src/route.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `auroraRoute` — Ream route handler that SSR-renders an aurora
3
+ * component and ships the markup plus the bytes needed for client-side
4
+ * hydration.
5
+ *
6
+ * Usage:
7
+ *
8
+ * import { auroraRoute } from '@c9up/aurora/provider'
9
+ * router.get('/dashboard', auroraRoute({
10
+ * entry: '/app/pages/dashboard.client.js',
11
+ * render: () => Dashboard({ user: ... })
12
+ * }))
13
+ *
14
+ * The handler:
15
+ * 1. invokes `render()` and stringifies the result via @c9up/aurora's SSR
16
+ * 2. wraps the markup in the page shell from `shell` (default: a minimal
17
+ * <!doctype html> document)
18
+ * 3. embeds a `<script type="module">` that imports the user-supplied
19
+ * `entry` module — the client bundle is expected to call
20
+ * `hydrate(document.getElementById('aurora-root'), factory)` itself.
21
+ */
22
+
23
+ import { renderToString } from "./ssr.js";
24
+ import type { TemplateResult } from "./types.js";
25
+
26
+ /**
27
+ * Structural slice of Ream's HttpContext — the response surface we need.
28
+ * Declaring it locally keeps aurora's bundle free of a `@c9up/ream`
29
+ * import; any framework whose context exposes `response.header()` and
30
+ * `response.send()` satisfies this contract via structural subtyping.
31
+ */
32
+ export interface AuroraResponse {
33
+ status(code: number): AuroraResponse;
34
+ header(name: string, value: string): AuroraResponse;
35
+ send(data: string): void;
36
+ }
37
+ export interface AuroraHttpContext {
38
+ request: unknown;
39
+ response: AuroraResponse;
40
+ }
41
+
42
+ /** Configuration for a single auroraRoute call. */
43
+ export interface AuroraRouteConfig {
44
+ /**
45
+ * SSR factory. Called once per request. Returning a TemplateResult is
46
+ * the common case; returning a Promise<TemplateResult> works too
47
+ * (data-loading components).
48
+ */
49
+ render: (ctx: AuroraHttpContext) => TemplateResult | Promise<TemplateResult>;
50
+ /**
51
+ * Path to the ES module the browser should import to hydrate. The
52
+ * value is inlined into a `<script type="module" src="...">` tag —
53
+ * make sure the route exists on the Ream router (typically served
54
+ * statically). Defaults to `/aurora-client.js`.
55
+ */
56
+ entry?: string;
57
+ /**
58
+ * Page shell. Defaults to a minimal HTML5 doctype with a `<div
59
+ * id="aurora-root">` that wraps the SSR markup. Apps swap in their
60
+ * own to control `<head>` (title, meta, fonts, css).
61
+ */
62
+ shell?: (body: string, entry: string) => string;
63
+ }
64
+
65
+ const DEFAULT_SHELL = (body: string, entry: string): string =>
66
+ `<!doctype html>
67
+ <html lang="en">
68
+ <head>
69
+ <meta charset="utf-8" />
70
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
71
+ <title>Aurora</title>
72
+ </head>
73
+ <body>
74
+ <div id="aurora-root">${body}</div>
75
+ <script type="module" src="${entry}"></script>
76
+ </body>
77
+ </html>`;
78
+
79
+ /**
80
+ * Build a Ream-compatible route handler that SSR-renders the given
81
+ * factory and serves the full HTML document.
82
+ */
83
+ export function auroraRoute(
84
+ config: AuroraRouteConfig,
85
+ ): (ctx: AuroraHttpContext) => Promise<void> {
86
+ const entry = config.entry ?? "/aurora-client.js";
87
+ const shell = config.shell ?? DEFAULT_SHELL;
88
+
89
+ return async (ctx) => {
90
+ const tree = await config.render(ctx);
91
+ const body = renderToString(tree);
92
+ const html = shell(body, entry);
93
+ ctx.response.header("content-type", "text/html; charset=utf-8");
94
+ ctx.response.send(html);
95
+ };
96
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `renderPage` — server-side helper that turns a page name + props into
3
+ * a full HTML document, ready to ship from a route handler.
4
+ *
5
+ * The output carries everything the browser needs to hydrate against
6
+ * the SSR markup with zero app-side scripting:
7
+ *
8
+ * <head>
9
+ * ...
10
+ * <script type="importmap">
11
+ * { "imports": { "@c9up/aurora": "/_assets/aurora/index.js" } }
12
+ * </script>
13
+ * </head>
14
+ * <body>
15
+ * <div id="aurora-root">…SSR markup…</div>
16
+ * <script id="aurora-page-data" type="application/json">{…}</script>
17
+ * <script type="module">
18
+ * import { hydrate } from '@c9up/aurora'
19
+ * import Page from '/_assets/pages/ProjectPage.js'
20
+ * const data = JSON.parse(document.getElementById('aurora-page-data').textContent)
21
+ * hydrate(document.getElementById('aurora-root'), () => Page(data.props))
22
+ * </script>
23
+ * </body>
24
+ */
25
+
26
+ import type { Pages } from "../Pages.js";
27
+ import { renderToString } from "../ssr.js";
28
+
29
+ /**
30
+ * Structural slice of the host framework's response. Same shape
31
+ * `auroraRoute()` uses — keeps aurora free of a `@c9up/ream` import.
32
+ */
33
+ export interface RenderResponse {
34
+ status(code: number): RenderResponse;
35
+ header(name: string, value: string): RenderResponse;
36
+ send(body: string): void;
37
+ }
38
+ export interface RenderHttpContext {
39
+ request: unknown;
40
+ response: RenderResponse;
41
+ }
42
+
43
+ export interface RenderPageOptions {
44
+ /**
45
+ * Importmap entries injected into `<head>`. Defaults to mapping
46
+ * `@c9up/aurora` to `/_assets/aurora/index.js`. Override to point
47
+ * at a different mount or to add app-side aliases.
48
+ */
49
+ importmap?: Record<string, string>;
50
+ /**
51
+ * Extra markup spliced into `<head>` after the importmap. Use to
52
+ * inject `<title>`, meta tags, stylesheets.
53
+ */
54
+ headExtra?: string;
55
+ /**
56
+ * Outer language tag on the `<html>` element. Defaults to `en`.
57
+ */
58
+ lang?: string;
59
+ /**
60
+ * Mount root id for the SSR + hydrated tree. Defaults to
61
+ * `aurora-root`. Matches the id the client-side hydrate script
62
+ * targets.
63
+ */
64
+ rootId?: string;
65
+ }
66
+
67
+ export async function renderPage<P>(
68
+ ctx: RenderHttpContext,
69
+ pages: Pages,
70
+ name: string,
71
+ props: P,
72
+ options: RenderPageOptions = {},
73
+ ): Promise<void> {
74
+ const factory = await pages.resolve(name);
75
+ // The factory must be invoked the SAME way client-side for hydrate
76
+ // to find matching slots — `Page(props)` is the contract.
77
+ const tree = await factory(props as never);
78
+ const body = renderToString(tree);
79
+
80
+ const importmap = {
81
+ "@c9up/aurora": "/_assets/aurora/index.js",
82
+ ...options.importmap,
83
+ };
84
+ const rootId = options.rootId ?? "aurora-root";
85
+ const lang = options.lang ?? "en";
86
+ const pageUrl = pages.urlFor(name);
87
+
88
+ const doc = `<!doctype html>
89
+ <html lang="${escapeAttr(lang)}">
90
+ <head>
91
+ <meta charset="utf-8" />
92
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
93
+ <script type="importmap">${JSON.stringify({ imports: importmap })}</script>
94
+ ${options.headExtra ?? ""}
95
+ </head>
96
+ <body>
97
+ <div id="${escapeAttr(rootId)}">${body}</div>
98
+ <script id="aurora-page-data" type="application/json">${escapeJsonForScript({
99
+ name,
100
+ props,
101
+ url: pageUrl,
102
+ rootId,
103
+ })}</script>
104
+ <script type="module">
105
+ import { hydrate } from '@c9up/aurora'
106
+ import Page from ${JSON.stringify(pageUrl)}
107
+ const data = JSON.parse(document.getElementById('aurora-page-data').textContent)
108
+ hydrate(document.getElementById(data.rootId), () => Page(data.props))
109
+ </script>
110
+ </body>
111
+ </html>`;
112
+
113
+ ctx.response.header("content-type", "text/html; charset=utf-8");
114
+ ctx.response.send(doc);
115
+ }
116
+
117
+ function escapeAttr(value: string): string {
118
+ return value
119
+ .replace(/&/g, "&amp;")
120
+ .replace(/"/g, "&quot;")
121
+ .replace(/</g, "&lt;");
122
+ }
123
+
124
+ /**
125
+ * Escape a JSON payload for safe embedding inside a `<script>` block.
126
+ * The HTML parser closes the script on `</script>` regardless of JSON
127
+ * quoting, so we slash-escape the `/`. We also escape `<!--` and `-->`
128
+ * to dodge HTML-comment interpretation inside the script body.
129
+ */
130
+ function escapeJsonForScript(value: unknown): string {
131
+ return JSON.stringify(value)
132
+ .replace(/<\/(script)/gi, "<\\/$1")
133
+ .replace(/<!--/g, "<\\!--")
134
+ .replace(/-->/g, "--\\>");
135
+ }