@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 +5 -0
- package/dist/html.js +5 -0
- package/dist/http.js +5 -0
- package/dist/hydrate.js +49 -0
- package/dist/liveClient.js +5 -0
- package/dist/liveServer.d.ts +6 -1
- package/dist/render.js +14 -2
- package/dist/rpc.js +5 -0
- package/dist/server/renderPage.js +23 -7
- package/dist/ssr.js +50 -15
- package/package.json +1 -1
- package/src/browser.ts +5 -0
- package/src/html.ts +5 -0
- package/src/http.ts +5 -0
- package/src/hydrate.ts +79 -0
- package/src/liveClient.ts +5 -0
- package/src/liveServer.ts +9 -1
- package/src/render.ts +17 -2
- package/src/rpc.ts +5 -0
- package/src/server/renderPage.ts +23 -7
- package/src/ssr.ts +48 -13
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
|
package/dist/liveClient.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
|
* Live client runtime (Stage 5) — the thin browser side of live components.
|
|
3
8
|
*
|
package/dist/liveServer.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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 =
|
|
66
|
+
const inAttr = scanner.insideTag;
|
|
65
67
|
if (inAttr) {
|
|
66
|
-
|
|
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 +=
|
|
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
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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.
|
package/src/server/renderPage.ts
CHANGED
|
@@ -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
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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 =
|
|
70
|
+
const inAttr = scanner.insideTag;
|
|
69
71
|
if (inAttr) {
|
|
70
|
-
|
|
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 +=
|
|
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
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 {
|