@c9up/aurora 0.1.26 → 0.1.28

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/dist/browser.js CHANGED
@@ -1,3 +1,8 @@
1
+ /// <reference lib="dom" />
2
+ // This file uses browser globals. The reference pulls the DOM lib in for
3
+ // THIS file whatever `lib` the consumer configured, so a Node app
4
+ // typechecking against our sources does not trip over `window` —
5
+ // `types: "./src/index.ts"` means every consumer reads them.
1
6
  /**
2
7
  * Browser DX helpers — navigation + a typed `localStorage` wrapper.
3
8
  *
package/dist/html.js CHANGED
@@ -1,3 +1,8 @@
1
+ /// <reference lib="dom" />
2
+ // This file uses browser globals. The reference pulls the DOM lib in for
3
+ // THIS file whatever `lib` the consumer configured, so a Node app
4
+ // typechecking against our sources does not trip over `window` —
5
+ // `types: "./src/index.ts"` means every consumer reads them.
1
6
  /**
2
7
  * Tagged-template HTML parser.
3
8
  *
package/dist/http.js CHANGED
@@ -1,3 +1,8 @@
1
+ /// <reference lib="dom" />
2
+ // This file uses browser globals. The reference pulls the DOM lib in for
3
+ // THIS file whatever `lib` the consumer configured, so a Node app
4
+ // typechecking against our sources does not trip over `window` —
5
+ // `types: "./src/index.ts"` means every consumer reads them.
1
6
  /**
2
7
  * `HttpClient` — a small typed wrapper over `fetch` so call sites read
3
8
  * `await http.get<User>("/auth/me")` instead of hand-rolling headers,
package/dist/hydrate.js CHANGED
@@ -1,3 +1,8 @@
1
+ /// <reference lib="dom" />
2
+ // This file uses browser globals. The reference pulls the DOM lib in for
3
+ // THIS file whatever `lib` the consumer configured, so a Node app
4
+ // typechecking against our sources does not trip over `window` —
5
+ // `types: "./src/index.ts"` means every consumer reads them.
1
6
  /**
2
7
  * Hydration — adopt SSR-rendered HTML in the browser without rebuilding
3
8
  * the DOM.
@@ -257,6 +262,11 @@ function hydrateTemplateResult(result, liveNodes, cleanups, mountHooks, markerCu
257
262
  const syntheticRoot = {
258
263
  childNodes: liveNodes,
259
264
  };
265
+ // An attribute interpolating several slots — `class="static ${a} ${b}"` — is
266
+ // ONE attribute value built from all of them plus the static segments in
267
+ // between. Binding each slot on its own would have the last writer win and
268
+ // wipe the statics, which is what render.ts already avoids server-side.
269
+ const multiGroups = new Map();
260
270
  for (let i = 0; i < tpl.slots.length; i++) {
261
271
  const slot = tpl.slots[i];
262
272
  const liveNode = resolvePathLive(syntheticRoot, slot.path, liveNodes);
@@ -270,8 +280,47 @@ function hydrateTemplateResult(result, liveNodes, cleanups, mountHooks, markerCu
270
280
  }
271
281
  continue;
272
282
  }
283
+ if (slot.kind === "attr" && slot.staticParts !== undefined) {
284
+ collectMultiAttr(slot, liveNode, result.values[i], multiGroups);
285
+ continue;
286
+ }
273
287
  hydrateSlot(slot, liveNode, result.values[i], cleanups, mountHooks, markerCursor);
274
288
  }
289
+ for (const group of multiGroups.values()) {
290
+ applyMultiAttrGroup(group, cleanups);
291
+ }
292
+ }
293
+ function collectMultiAttr(slot, el, value, groups) {
294
+ if (!slot.staticParts)
295
+ return;
296
+ const key = `${slot.name}::${slot.path.join(".")}`;
297
+ let group = groups.get(key);
298
+ if (!group) {
299
+ group = { el, name: slot.name, staticParts: slot.staticParts, values: [] };
300
+ groups.set(key, group);
301
+ }
302
+ group.values.push(value);
303
+ }
304
+ function applyMultiAttrGroup(group, cleanups) {
305
+ function join() {
306
+ let out = group.staticParts[0] ?? "";
307
+ for (let i = 0; i < group.values.length; i++) {
308
+ const v = group.values[i];
309
+ const resolved = isSignal(v) || typeof v === "function" ? v() : v;
310
+ out += resolved == null || resolved === false ? "" : String(resolved);
311
+ out += group.staticParts[i + 1] ?? "";
312
+ }
313
+ return out;
314
+ }
315
+ const hasReactive = group.values.some((v) => isSignal(v) || typeof v === "function");
316
+ if (hasReactive) {
317
+ // SSR already wrote the joined value; re-joining on every tick is what
318
+ // keeps the statics in place when only one part changes.
319
+ cleanups.push(effect(() => {
320
+ group.el.setAttribute(group.name, join());
321
+ }));
322
+ }
323
+ // Fully static groups need nothing: SSR wrote the final value.
275
324
  }
276
325
  /**
277
326
  * Resolve a slot's path against the LIVE DOM. The first index of the
@@ -1,3 +1,8 @@
1
+ /// <reference lib="dom" />
2
+ // This file uses browser globals. The reference pulls the DOM lib in for
3
+ // THIS file whatever `lib` the consumer configured, so a Node app
4
+ // typechecking against our sources does not trip over `window` —
5
+ // `types: "./src/index.ts"` means every consumer reads them.
1
6
  /**
2
7
  * Live client runtime (Stage 5) — the thin browser side of live components.
3
8
  *
@@ -12,7 +12,12 @@
12
12
  import type { LiveRouter } from "./liveRouter.js";
13
13
  /** The slice of the host HTTP router this needs. */
14
14
  export interface LiveHttpRouter {
15
- post(path: string, handler: (ctx: LiveHttpContext) => unknown): unknown;
15
+ /**
16
+ * The handler returns `void | Promise<void>`, not `unknown`: that is what an
17
+ * HTTP handler returns in ream and in AdonisJS, and the wider shape made
18
+ * ream's own Router fail to satisfy this interface.
19
+ */
20
+ post(path: string, handler: (ctx: LiveHttpContext) => void | Promise<void>): unknown;
16
21
  }
17
22
  /** The slice of the host HTTP context this needs (Ream's HttpContext satisfies it). */
18
23
  export interface LiveHttpContext {
package/dist/render.js CHANGED
@@ -1,3 +1,8 @@
1
+ /// <reference lib="dom" />
2
+ // This file uses browser globals. The reference pulls the DOM lib in for
3
+ // THIS file whatever `lib` the consumer configured, so a Node app
4
+ // typechecking against our sources does not trip over `window` —
5
+ // `types: "./src/index.ts"` means every consumer reads them.
1
6
  /**
2
7
  * Render a `TemplateResult` to the DOM and keep it reactive.
3
8
  *
@@ -68,10 +73,17 @@ export function mount(result, cleanups, mounted, mountHooks) {
68
73
  // the final string. Collect them in a first pass, attach effects
69
74
  // after.
70
75
  const multiGroups = new Map();
76
+ // Resolve EVERY slot's node before applying any of them. Applying a text
77
+ // slot inserts nodes into the fragment, which shifts the child indices the
78
+ // remaining paths were computed against — so a slot sitting after a nested
79
+ // template (`${Icon()}${label}`) used to resolve to the wrong node, or to
80
+ // none, and silently never bound. Fragments exist precisely so a component
81
+ // needs no wrapper element; they must not cost the slots that follow them.
82
+ const resolved = tpl.slots.map((slot) => resolvePath(fragment, slot.path));
71
83
  for (let i = 0; i < tpl.slots.length; i++) {
72
84
  const slot = tpl.slots[i];
73
- const node = resolvePath(fragment, slot.path);
74
- if (node === null) {
85
+ const node = resolved[i];
86
+ if (node === null || node === undefined) {
75
87
  // Path didn't resolve — skip this binding rather than crash (see
76
88
  // resolvePath). Degrades to a dead binding; the surrounding render
77
89
  // (and any command driving it) survives.
package/dist/rpc.js CHANGED
@@ -1,3 +1,8 @@
1
+ /// <reference lib="dom" />
2
+ // This file uses browser globals. The reference pulls the DOM lib in for
3
+ // THIS file whatever `lib` the consumer configured, so a Node app
4
+ // typechecking against our sources does not trip over `window` —
5
+ // `types: "./src/index.ts"` means every consumer reads them.
1
6
  /**
2
7
  * Browser JSON-RPC 2.0 client for Ream's RPC endpoint — aurora's thin binding
3
8
  * over the agnostic {@link https://github.com/C9up/comet | @c9up/comet} client.
@@ -140,13 +140,29 @@ function escapeAttr(value) {
140
140
  }
141
141
  /**
142
142
  * Escape a JSON payload for safe embedding inside a `<script>` block.
143
- * The HTML parser closes the script on `</script>` regardless of JSON
144
- * quoting, so we slash-escape the `/`. We also escape `<!--` and `-->`
145
- * to dodge HTML-comment interpretation inside the script body.
143
+ *
144
+ * The HTML parser ends the script on `</script>` and reinterprets `<!--` /
145
+ * `-->` as comment markers, whatever the JSON quoting says — so those
146
+ * characters must not survive literally. They are escaped as \uXXXX, which is
147
+ * valid JSON: a backslash escape like `\!` or `\>` is NOT, and made
148
+ * `JSON.parse` throw on the client the moment a prop contained a comment
149
+ * marker, taking the whole page's hydration with it.
146
150
  */
147
151
  function escapeJsonForScript(value) {
148
- return JSON.stringify(value)
149
- .replace(/<\/(script)/gi, "<\\/$1")
150
- .replace(/<!--/g, "<\\!--")
151
- .replace(/-->/g, "--\\>");
152
+ return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (c) => {
153
+ switch (c) {
154
+ case "<":
155
+ return "\\u003c";
156
+ case ">":
157
+ return "\\u003e";
158
+ case "&":
159
+ return "\\u0026";
160
+ // Line separators are valid in JSON strings but terminate a JS line,
161
+ // so a script block carrying them raw is a syntax error.
162
+ case "\u2028":
163
+ return "\\u2028";
164
+ default:
165
+ return "\\u2029";
166
+ }
167
+ });
152
168
  }
package/dist/ssr.js CHANGED
@@ -46,6 +46,7 @@ function stringifyTemplateResult(result) {
46
46
  // segment. This three-step coordination is why the loop holds a
47
47
  // `pendingClosingQuote` flag.
48
48
  let pendingClosingQuote = false;
49
+ const scanner = new TagScanner();
49
50
  for (let i = 0; i < strings.length; i++) {
50
51
  let segment = strings[i];
51
52
  if (pendingClosingQuote) {
@@ -59,11 +60,14 @@ function stringifyTemplateResult(result) {
59
60
  pendingClosingQuote = true;
60
61
  }
61
62
  out += segment;
63
+ scanner.consume(segment);
62
64
  if (i < values.length && !skipValue) {
63
65
  const value = values[i];
64
- const inAttr = isInsideAttribute(out);
66
+ const inAttr = scanner.insideTag;
65
67
  if (inAttr) {
66
- out += stringifyValue(value, true);
68
+ const rendered = stringifyValue(value, true);
69
+ out += rendered;
70
+ scanner.consume(rendered);
67
71
  }
68
72
  else {
69
73
  // Text-region slot — ALWAYS wrap in boundary markers so the SSR
@@ -77,9 +81,13 @@ function stringifyTemplateResult(result) {
77
81
  // (collapseMarkerRanges) so paths align exactly; the range also
78
82
  // anchors scalar text updates and nested-template swaps. Same
79
83
  // part-marker approach as lit-html / Solid.
84
+ const rendered = stringifyValue(value, false);
80
85
  out += `<!--${SLOT_START}-->`;
81
- out += stringifyValue(value, false);
86
+ out += rendered;
82
87
  out += `<!--${SLOT_END}-->`;
88
+ // A text-region value may itself carry markup (a nested template
89
+ // or a SafeString), so it has to move the scanner too.
90
+ scanner.consume(rendered);
83
91
  }
84
92
  }
85
93
  }
@@ -89,20 +97,47 @@ function stringifyTemplateResult(result) {
89
97
  const SLOT_START = "$";
90
98
  const SLOT_END = "/$";
91
99
  /**
92
- * Returns true if the position at the end of `htmlSoFar` lives inside
93
- * the value region of an HTML tag (between `<` and `>`). The check
94
- * walks backwards from the end, which is the smallest hint we need to
95
- * decide between text-region and attribute-region escaping.
100
+ * Tracks whether the cursor sits inside a tag, scanning FORWARD as the output
101
+ * grows.
102
+ *
103
+ * The obvious version walked backwards looking for the nearest `<` or `>`, but
104
+ * a `>` inside a quoted attribute value — `title="a > b"` — reads as the end of
105
+ * the tag, so the next interpolation is treated as a text slot and gets wrapped
106
+ * in `<!--$-->` markers INSIDE an attribute. That corrupts the markup and
107
+ * desyncs every following slot path at hydration. Quotes are what disambiguate,
108
+ * and they can only be resolved by reading forward.
109
+ *
110
+ * State is carried across appends instead of re-derived, so the whole render
111
+ * stays linear.
96
112
  */
97
- function isInsideAttribute(htmlSoFar) {
98
- for (let i = htmlSoFar.length - 1; i >= 0; i--) {
99
- const c = htmlSoFar.charCodeAt(i);
100
- if (c === 60 /* '<' */)
101
- return true;
102
- if (c === 62 /* '>' */)
103
- return false;
113
+ class TagScanner {
114
+ #inTag = false;
115
+ /** The quote character currently open inside a tag, or empty. */
116
+ #quote = "";
117
+ /** Feed everything appended since the last call. */
118
+ consume(chunk) {
119
+ for (let i = 0; i < chunk.length; i++) {
120
+ const c = chunk[i];
121
+ if (this.#quote !== "") {
122
+ if (c === this.#quote)
123
+ this.#quote = "";
124
+ continue;
125
+ }
126
+ if (this.#inTag) {
127
+ if (c === '"' || c === "'")
128
+ this.#quote = c;
129
+ else if (c === ">")
130
+ this.#inTag = false;
131
+ continue;
132
+ }
133
+ if (c === "<")
134
+ this.#inTag = true;
135
+ }
136
+ }
137
+ /** True when the cursor is inside a tag — an attribute region. */
138
+ get insideTag() {
139
+ return this.#inTag;
104
140
  }
105
- return false;
106
141
  }
107
142
  function stringifyValue(value, inAttribute) {
108
143
  if (value === null || value === undefined || value === false)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/aurora",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "Aurora — reactive UI runtime for the Ream framework. Tagged-template DOM, signal-based state, isomorphic SSR + hydration, zero build step.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/browser.ts CHANGED
@@ -1,3 +1,8 @@
1
+ /// <reference lib="dom" />
2
+ // This file uses browser globals. The reference pulls the DOM lib in for
3
+ // THIS file whatever `lib` the consumer configured, so a Node app
4
+ // typechecking against our sources does not trip over `window` —
5
+ // `types: "./src/index.ts"` means every consumer reads them.
1
6
  /**
2
7
  * Browser DX helpers — navigation + a typed `localStorage` wrapper.
3
8
  *
package/src/html.ts CHANGED
@@ -1,3 +1,8 @@
1
+ /// <reference lib="dom" />
2
+ // This file uses browser globals. The reference pulls the DOM lib in for
3
+ // THIS file whatever `lib` the consumer configured, so a Node app
4
+ // typechecking against our sources does not trip over `window` —
5
+ // `types: "./src/index.ts"` means every consumer reads them.
1
6
  /**
2
7
  * Tagged-template HTML parser.
3
8
  *
package/src/http.ts CHANGED
@@ -1,3 +1,8 @@
1
+ /// <reference lib="dom" />
2
+ // This file uses browser globals. The reference pulls the DOM lib in for
3
+ // THIS file whatever `lib` the consumer configured, so a Node app
4
+ // typechecking against our sources does not trip over `window` —
5
+ // `types: "./src/index.ts"` means every consumer reads them.
1
6
  /**
2
7
  * `HttpClient` — a small typed wrapper over `fetch` so call sites read
3
8
  * `await http.get<User>("/auth/me")` instead of hand-rolling headers,
package/src/hydrate.ts CHANGED
@@ -1,3 +1,8 @@
1
+ /// <reference lib="dom" />
2
+ // This file uses browser globals. The reference pulls the DOM lib in for
3
+ // THIS file whatever `lib` the consumer configured, so a Node app
4
+ // typechecking against our sources does not trip over `window` —
5
+ // `types: "./src/index.ts"` means every consumer reads them.
1
6
  /**
2
7
  * Hydration — adopt SSR-rendered HTML in the browser without rebuilding
3
8
  * the DOM.
@@ -358,6 +363,12 @@ function hydrateTemplateResult(
358
363
  childNodes: liveNodes,
359
364
  } as unknown as ParentNode;
360
365
 
366
+ // An attribute interpolating several slots — `class="static ${a} ${b}"` — is
367
+ // ONE attribute value built from all of them plus the static segments in
368
+ // between. Binding each slot on its own would have the last writer win and
369
+ // wipe the statics, which is what render.ts already avoids server-side.
370
+ const multiGroups = new Map<string, MultiAttrGroup>();
371
+
361
372
  for (let i = 0; i < tpl.slots.length; i++) {
362
373
  const slot = tpl.slots[i];
363
374
  const liveNode = resolvePathLive(syntheticRoot, slot.path, liveNodes);
@@ -373,6 +384,15 @@ function hydrateTemplateResult(
373
384
  }
374
385
  continue;
375
386
  }
387
+ if (slot.kind === "attr" && slot.staticParts !== undefined) {
388
+ collectMultiAttr(
389
+ slot,
390
+ liveNode as Element,
391
+ result.values[i],
392
+ multiGroups,
393
+ );
394
+ continue;
395
+ }
376
396
  hydrateSlot(
377
397
  slot,
378
398
  liveNode,
@@ -382,6 +402,65 @@ function hydrateTemplateResult(
382
402
  markerCursor,
383
403
  );
384
404
  }
405
+
406
+ for (const group of multiGroups.values()) {
407
+ applyMultiAttrGroup(group, cleanups);
408
+ }
409
+ }
410
+
411
+ /** One attribute whose value is assembled from several slots. */
412
+ interface MultiAttrGroup {
413
+ el: Element;
414
+ name: string;
415
+ staticParts: readonly string[];
416
+ values: unknown[];
417
+ }
418
+
419
+ function collectMultiAttr(
420
+ slot: AttrSlot,
421
+ el: Element,
422
+ value: unknown,
423
+ groups: Map<string, MultiAttrGroup>,
424
+ ): void {
425
+ if (!slot.staticParts) return;
426
+ const key = `${slot.name}::${(slot.path as readonly number[]).join(".")}`;
427
+ let group = groups.get(key);
428
+ if (!group) {
429
+ group = { el, name: slot.name, staticParts: slot.staticParts, values: [] };
430
+ groups.set(key, group);
431
+ }
432
+ group.values.push(value);
433
+ }
434
+
435
+ function applyMultiAttrGroup(
436
+ group: MultiAttrGroup,
437
+ cleanups: Disposer[],
438
+ ): void {
439
+ function join(): string {
440
+ let out = group.staticParts[0] ?? "";
441
+ for (let i = 0; i < group.values.length; i++) {
442
+ const v = group.values[i];
443
+ const resolved =
444
+ isSignal(v) || typeof v === "function" ? (v as () => unknown)() : v;
445
+ out += resolved == null || resolved === false ? "" : String(resolved);
446
+ out += group.staticParts[i + 1] ?? "";
447
+ }
448
+ return out;
449
+ }
450
+
451
+ const hasReactive = group.values.some(
452
+ (v) => isSignal(v) || typeof v === "function",
453
+ );
454
+ if (hasReactive) {
455
+ // SSR already wrote the joined value; re-joining on every tick is what
456
+ // keeps the statics in place when only one part changes.
457
+ cleanups.push(
458
+ effect(() => {
459
+ group.el.setAttribute(group.name, join());
460
+ }),
461
+ );
462
+ }
463
+ // Fully static groups need nothing: SSR wrote the final value.
385
464
  }
386
465
 
387
466
  /**
package/src/liveClient.ts CHANGED
@@ -1,3 +1,8 @@
1
+ /// <reference lib="dom" />
2
+ // This file uses browser globals. The reference pulls the DOM lib in for
3
+ // THIS file whatever `lib` the consumer configured, so a Node app
4
+ // typechecking against our sources does not trip over `window` —
5
+ // `types: "./src/index.ts"` means every consumer reads them.
1
6
  /**
2
7
  * Live client runtime (Stage 5) — the thin browser side of live components.
3
8
  *
package/src/liveServer.ts CHANGED
@@ -14,7 +14,15 @@ import type { LiveRouter } from "./liveRouter.js";
14
14
 
15
15
  /** The slice of the host HTTP router this needs. */
16
16
  export interface LiveHttpRouter {
17
- post(path: string, handler: (ctx: LiveHttpContext) => unknown): unknown;
17
+ /**
18
+ * The handler returns `void | Promise<void>`, not `unknown`: that is what an
19
+ * HTTP handler returns in ream and in AdonisJS, and the wider shape made
20
+ * ream's own Router fail to satisfy this interface.
21
+ */
22
+ post(
23
+ path: string,
24
+ handler: (ctx: LiveHttpContext) => void | Promise<void>,
25
+ ): unknown;
18
26
  }
19
27
 
20
28
  /** The slice of the host HTTP context this needs (Ream's HttpContext satisfies it). */
package/src/render.ts CHANGED
@@ -1,3 +1,8 @@
1
+ /// <reference lib="dom" />
2
+ // This file uses browser globals. The reference pulls the DOM lib in for
3
+ // THIS file whatever `lib` the consumer configured, so a Node app
4
+ // typechecking against our sources does not trip over `window` —
5
+ // `types: "./src/index.ts"` means every consumer reads them.
1
6
  /**
2
7
  * Render a `TemplateResult` to the DOM and keep it reactive.
3
8
  *
@@ -104,10 +109,20 @@ export function mount(
104
109
  // after.
105
110
  const multiGroups = new Map<string, MultiAttrGroup>();
106
111
 
112
+ // Resolve EVERY slot's node before applying any of them. Applying a text
113
+ // slot inserts nodes into the fragment, which shifts the child indices the
114
+ // remaining paths were computed against — so a slot sitting after a nested
115
+ // template (`${Icon()}${label}`) used to resolve to the wrong node, or to
116
+ // none, and silently never bound. Fragments exist precisely so a component
117
+ // needs no wrapper element; they must not cost the slots that follow them.
118
+ const resolved: Array<Node | null> = tpl.slots.map((slot) =>
119
+ resolvePath(fragment, slot.path),
120
+ );
121
+
107
122
  for (let i = 0; i < tpl.slots.length; i++) {
108
123
  const slot = tpl.slots[i];
109
- const node = resolvePath(fragment, slot.path);
110
- if (node === null) {
124
+ const node = resolved[i];
125
+ if (node === null || node === undefined) {
111
126
  // Path didn't resolve — skip this binding rather than crash (see
112
127
  // resolvePath). Degrades to a dead binding; the surrounding render
113
128
  // (and any command driving it) survives.
package/src/rpc.ts CHANGED
@@ -1,3 +1,8 @@
1
+ /// <reference lib="dom" />
2
+ // This file uses browser globals. The reference pulls the DOM lib in for
3
+ // THIS file whatever `lib` the consumer configured, so a Node app
4
+ // typechecking against our sources does not trip over `window` —
5
+ // `types: "./src/index.ts"` means every consumer reads them.
1
6
  /**
2
7
  * Browser JSON-RPC 2.0 client for Ream's RPC endpoint — aurora's thin binding
3
8
  * over the agnostic {@link https://github.com/C9up/comet | @c9up/comet} client.
@@ -278,13 +278,29 @@ function escapeAttr(value: string): string {
278
278
 
279
279
  /**
280
280
  * Escape a JSON payload for safe embedding inside a `<script>` block.
281
- * The HTML parser closes the script on `</script>` regardless of JSON
282
- * quoting, so we slash-escape the `/`. We also escape `<!--` and `-->`
283
- * to dodge HTML-comment interpretation inside the script body.
281
+ *
282
+ * The HTML parser ends the script on `</script>` and reinterprets `<!--` /
283
+ * `-->` as comment markers, whatever the JSON quoting says — so those
284
+ * characters must not survive literally. They are escaped as \uXXXX, which is
285
+ * valid JSON: a backslash escape like `\!` or `\>` is NOT, and made
286
+ * `JSON.parse` throw on the client the moment a prop contained a comment
287
+ * marker, taking the whole page's hydration with it.
284
288
  */
285
289
  function escapeJsonForScript(value: unknown): string {
286
- return JSON.stringify(value)
287
- .replace(/<\/(script)/gi, "<\\/$1")
288
- .replace(/<!--/g, "<\\!--")
289
- .replace(/-->/g, "--\\>");
290
+ return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (c) => {
291
+ switch (c) {
292
+ case "<":
293
+ return "\\u003c";
294
+ case ">":
295
+ return "\\u003e";
296
+ case "&":
297
+ return "\\u0026";
298
+ // Line separators are valid in JSON strings but terminate a JS line,
299
+ // so a script block carrying them raw is a syntax error.
300
+ case "\u2028":
301
+ return "\\u2028";
302
+ default:
303
+ return "\\u2029";
304
+ }
305
+ });
290
306
  }
package/src/ssr.ts CHANGED
@@ -50,6 +50,7 @@ function stringifyTemplateResult(result: TemplateResult): string {
50
50
  // segment. This three-step coordination is why the loop holds a
51
51
  // `pendingClosingQuote` flag.
52
52
  let pendingClosingQuote = false;
53
+ const scanner = new TagScanner();
53
54
  for (let i = 0; i < strings.length; i++) {
54
55
  let segment = strings[i];
55
56
  if (pendingClosingQuote) {
@@ -63,11 +64,14 @@ function stringifyTemplateResult(result: TemplateResult): string {
63
64
  pendingClosingQuote = true;
64
65
  }
65
66
  out += segment;
67
+ scanner.consume(segment);
66
68
  if (i < values.length && !skipValue) {
67
69
  const value = values[i];
68
- const inAttr = isInsideAttribute(out);
70
+ const inAttr = scanner.insideTag;
69
71
  if (inAttr) {
70
- out += stringifyValue(value, true);
72
+ const rendered = stringifyValue(value, true);
73
+ out += rendered;
74
+ scanner.consume(rendered);
71
75
  } else {
72
76
  // Text-region slot — ALWAYS wrap in boundary markers so the SSR
73
77
  // node structure matches the client template, which keeps exactly
@@ -80,9 +84,13 @@ function stringifyTemplateResult(result: TemplateResult): string {
80
84
  // (collapseMarkerRanges) so paths align exactly; the range also
81
85
  // anchors scalar text updates and nested-template swaps. Same
82
86
  // part-marker approach as lit-html / Solid.
87
+ const rendered = stringifyValue(value, false);
83
88
  out += `<!--${SLOT_START}-->`;
84
- out += stringifyValue(value, false);
89
+ out += rendered;
85
90
  out += `<!--${SLOT_END}-->`;
91
+ // A text-region value may itself carry markup (a nested template
92
+ // or a SafeString), so it has to move the scanner too.
93
+ scanner.consume(rendered);
86
94
  }
87
95
  }
88
96
  }
@@ -94,18 +102,45 @@ const SLOT_START = "$";
94
102
  const SLOT_END = "/$";
95
103
 
96
104
  /**
97
- * Returns true if the position at the end of `htmlSoFar` lives inside
98
- * the value region of an HTML tag (between `<` and `>`). The check
99
- * walks backwards from the end, which is the smallest hint we need to
100
- * decide between text-region and attribute-region escaping.
105
+ * Tracks whether the cursor sits inside a tag, scanning FORWARD as the output
106
+ * grows.
107
+ *
108
+ * The obvious version walked backwards looking for the nearest `<` or `>`, but
109
+ * a `>` inside a quoted attribute value — `title="a > b"` — reads as the end of
110
+ * the tag, so the next interpolation is treated as a text slot and gets wrapped
111
+ * in `<!--$-->` markers INSIDE an attribute. That corrupts the markup and
112
+ * desyncs every following slot path at hydration. Quotes are what disambiguate,
113
+ * and they can only be resolved by reading forward.
114
+ *
115
+ * State is carried across appends instead of re-derived, so the whole render
116
+ * stays linear.
101
117
  */
102
- function isInsideAttribute(htmlSoFar: string): boolean {
103
- for (let i = htmlSoFar.length - 1; i >= 0; i--) {
104
- const c = htmlSoFar.charCodeAt(i);
105
- if (c === 60 /* '<' */) return true;
106
- if (c === 62 /* '>' */) return false;
118
+ class TagScanner {
119
+ #inTag = false;
120
+ /** The quote character currently open inside a tag, or empty. */
121
+ #quote = "";
122
+
123
+ /** Feed everything appended since the last call. */
124
+ consume(chunk: string): void {
125
+ for (let i = 0; i < chunk.length; i++) {
126
+ const c = chunk[i];
127
+ if (this.#quote !== "") {
128
+ if (c === this.#quote) this.#quote = "";
129
+ continue;
130
+ }
131
+ if (this.#inTag) {
132
+ if (c === '"' || c === "'") this.#quote = c;
133
+ else if (c === ">") this.#inTag = false;
134
+ continue;
135
+ }
136
+ if (c === "<") this.#inTag = true;
137
+ }
138
+ }
139
+
140
+ /** True when the cursor is inside a tag — an attribute region. */
141
+ get insideTag(): boolean {
142
+ return this.#inTag;
107
143
  }
108
- return false;
109
144
  }
110
145
 
111
146
  function stringifyValue(value: unknown, inAttribute: boolean): string {