@c9up/aurora 0.1.36 → 0.1.37
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/augmentations.d.ts +20 -0
- package/dist/html.js +80 -1
- package/dist/ssr.js +43 -1
- package/package.json +3 -2
- package/src/augmentations.ts +23 -1
- package/src/html.ts +84 -1
- package/src/ssr.ts +42 -1
package/dist/augmentations.d.ts
CHANGED
|
@@ -15,9 +15,29 @@
|
|
|
15
15
|
* simply inert.
|
|
16
16
|
*/
|
|
17
17
|
import type { AuroraManager } from "./AuroraManager.js";
|
|
18
|
+
import type { AuroraRequestRenderer } from "./middleware.js";
|
|
18
19
|
declare module "@c9up/ream/types" {
|
|
19
20
|
interface ContainerBindings {
|
|
20
21
|
/** The Aurora manager, bound by `AuroraProvider`. */
|
|
21
22
|
aurora: AuroraManager;
|
|
22
23
|
}
|
|
23
24
|
}
|
|
25
|
+
declare module "@c9up/ream" {
|
|
26
|
+
interface HttpContext {
|
|
27
|
+
/**
|
|
28
|
+
* Render a page for THIS request — `ctx.aurora.render(name, props)`.
|
|
29
|
+
*
|
|
30
|
+
* Attached by the `auroraContext` middleware, which is what the docs tell
|
|
31
|
+
* an application to register. Without this declaration the property the
|
|
32
|
+
* middleware sets did not exist as far as the compiler was concerned, so
|
|
33
|
+
* the shorthand the documentation teaches did not typecheck, and a
|
|
34
|
+
* controller had to reach for the module-level `aurora.render(ctx, ...)`
|
|
35
|
+
* or assert its way past it.
|
|
36
|
+
*
|
|
37
|
+
* Optional, because the middleware is: an application that never
|
|
38
|
+
* registers it has no `ctx.aurora`, and saying otherwise would let a
|
|
39
|
+
* controller call something that is not there.
|
|
40
|
+
*/
|
|
41
|
+
aurora?: AuroraRequestRenderer;
|
|
42
|
+
}
|
|
43
|
+
}
|
package/dist/html.js
CHANGED
|
@@ -216,11 +216,90 @@ function collectSlots(root, classification) {
|
|
|
216
216
|
visit(root.content, []);
|
|
217
217
|
return slots;
|
|
218
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Elements that only ever exist inside `<svg>`.
|
|
221
|
+
*
|
|
222
|
+
* Names shared with HTML — `a`, `title`, `style`, `script`, `text` in some
|
|
223
|
+
* dialects — are deliberately absent: seeing one says nothing about which
|
|
224
|
+
* namespace was meant, and guessing wrong would move an ordinary anchor into
|
|
225
|
+
* SVG.
|
|
226
|
+
*/
|
|
227
|
+
const SVG_ONLY = new Set([
|
|
228
|
+
"animate",
|
|
229
|
+
"animatemotion",
|
|
230
|
+
"animatetransform",
|
|
231
|
+
"circle",
|
|
232
|
+
"clippath",
|
|
233
|
+
"defs",
|
|
234
|
+
"desc",
|
|
235
|
+
"ellipse",
|
|
236
|
+
"feblend",
|
|
237
|
+
"fecolormatrix",
|
|
238
|
+
"fegaussianblur",
|
|
239
|
+
"femerge",
|
|
240
|
+
"feoffset",
|
|
241
|
+
"filter",
|
|
242
|
+
"foreignobject",
|
|
243
|
+
"g",
|
|
244
|
+
"image",
|
|
245
|
+
"line",
|
|
246
|
+
"lineargradient",
|
|
247
|
+
"marker",
|
|
248
|
+
"mask",
|
|
249
|
+
"path",
|
|
250
|
+
"pattern",
|
|
251
|
+
"polygon",
|
|
252
|
+
"polyline",
|
|
253
|
+
"radialgradient",
|
|
254
|
+
"rect",
|
|
255
|
+
"stop",
|
|
256
|
+
"svg",
|
|
257
|
+
"symbol",
|
|
258
|
+
"tspan",
|
|
259
|
+
"use",
|
|
260
|
+
]);
|
|
261
|
+
/**
|
|
262
|
+
* Whether this markup is SVG content that lost its `<svg>` ancestor.
|
|
263
|
+
*
|
|
264
|
+
* A template compiled on its own — `html\`<path/><path/>\``, the body of an
|
|
265
|
+
* icon helper — is parsed with no parent, and the HTML parser has no
|
|
266
|
+
* self-closing tag for an unknown element: the second `<path>` becomes a CHILD
|
|
267
|
+
* of the first, in the XHTML namespace. Nothing throws and nothing is logged;
|
|
268
|
+
* the icon is simply invisible, because `<path>` in the wrong namespace paints
|
|
269
|
+
* nothing. Parsing the same markup inside an `<svg>` makes the parser apply
|
|
270
|
+
* foreign-content rules and produce the two siblings that were written.
|
|
271
|
+
*
|
|
272
|
+
* `<svg>` itself is excluded: the parser already handles it when it is the root
|
|
273
|
+
* of the markup, and wrapping one in another would nest them.
|
|
274
|
+
*/
|
|
275
|
+
function isOrphanedSvgContent(root) {
|
|
276
|
+
const elements = Array.from(root.content.childNodes).filter((node) => node.nodeType === 1);
|
|
277
|
+
if (elements.length === 0)
|
|
278
|
+
return false;
|
|
279
|
+
return elements.every((el) => {
|
|
280
|
+
const name = el.localName.toLowerCase();
|
|
281
|
+
return name !== "svg" && SVG_ONLY.has(name);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
219
284
|
function compile(strings) {
|
|
220
285
|
const classification = classifySlots(strings);
|
|
221
286
|
const markup = buildMarkup(strings, classification);
|
|
222
|
-
|
|
287
|
+
let tpl = document.createElement("template");
|
|
223
288
|
tpl.innerHTML = markup;
|
|
289
|
+
if (isOrphanedSvgContent(tpl)) {
|
|
290
|
+
// Re-parsed with the ancestor the markup was written for, then lifted
|
|
291
|
+
// back out: the nodes keep the SVG namespace they were given, and the
|
|
292
|
+
// slot paths below are collected against the shape that will actually
|
|
293
|
+
// be cloned.
|
|
294
|
+
const wrapper = document.createElement("template");
|
|
295
|
+
wrapper.innerHTML = `<svg>${markup}</svg>`;
|
|
296
|
+
const svg = wrapper.content.firstElementChild;
|
|
297
|
+
if (svg !== null) {
|
|
298
|
+
tpl = document.createElement("template");
|
|
299
|
+
while (svg.firstChild)
|
|
300
|
+
tpl.content.appendChild(svg.firstChild);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
224
303
|
const slots = collectSlots(tpl, classification);
|
|
225
304
|
return { element: tpl, slots };
|
|
226
305
|
}
|
package/dist/ssr.js
CHANGED
|
@@ -64,15 +64,37 @@ function stringifyTemplateResult(result) {
|
|
|
64
64
|
// value written into the HTML, and any exception swallowed.
|
|
65
65
|
const directiveMatch = segment.match(/\s([@?.][\w-]+)=("|'|)$/);
|
|
66
66
|
const skipValue = directiveMatch !== null;
|
|
67
|
+
// Set when the skipped directive is a boolean attribute, which — unlike
|
|
68
|
+
// the other two — still has markup to emit. See below.
|
|
69
|
+
let booleanAttrName;
|
|
67
70
|
if (directiveMatch) {
|
|
68
|
-
const [whole = "", , quote] = directiveMatch;
|
|
71
|
+
const [whole = "", directive = "", quote] = directiveMatch;
|
|
69
72
|
segment = segment.slice(0, segment.length - whole.length);
|
|
73
|
+
// `?disabled=${x}` is HTML STATE, not a client-only binding.
|
|
74
|
+
// `@click` is a listener and `.value` a DOM property: neither
|
|
75
|
+
// exists until the runtime binds it, so dropping them is right.
|
|
76
|
+
// A boolean attribute is different — the browser acts on it while
|
|
77
|
+
// parsing. Skipping it too made the server contradict the very
|
|
78
|
+
// first client render: a `?hidden` panel arrived visible and
|
|
79
|
+
// blinked away once hydration caught up.
|
|
80
|
+
if (directive.startsWith("?"))
|
|
81
|
+
booleanAttrName = directive.slice(1);
|
|
70
82
|
// Only a quoted directive leaves a closing quote to swallow.
|
|
71
83
|
pendingClosingQuote =
|
|
72
84
|
quote === '"' ? '"' : quote === "'" ? "'" : undefined;
|
|
73
85
|
}
|
|
74
86
|
out += segment;
|
|
75
87
|
scanner.consume(segment);
|
|
88
|
+
if (booleanAttrName !== undefined && i < values.length) {
|
|
89
|
+
// Present-and-empty when truthy, absent otherwise — byte-for-byte
|
|
90
|
+
// what applyBooleanAttrSlot writes on the client, so hydration
|
|
91
|
+
// re-applying the effect is a no-op instead of a correction.
|
|
92
|
+
if (resolveBooleanValue(values[i])) {
|
|
93
|
+
const rendered = ` ${booleanAttrName}=""`;
|
|
94
|
+
out += rendered;
|
|
95
|
+
scanner.consume(rendered);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
76
98
|
if (i < values.length && !skipValue) {
|
|
77
99
|
const value = values[i];
|
|
78
100
|
const inAttr = scanner.insideTag;
|
|
@@ -151,6 +173,26 @@ class TagScanner {
|
|
|
151
173
|
return this.#inTag;
|
|
152
174
|
}
|
|
153
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Read a boolean attribute's value the way the client reads it: a signal or a
|
|
178
|
+
* reactive expression is called ONCE, then coerced. One level is not an
|
|
179
|
+
* approximation — `applyBooleanAttrSlot` does exactly the same, so a signal
|
|
180
|
+
* that returns a signal is truthy on both sides.
|
|
181
|
+
*/
|
|
182
|
+
function resolveBooleanValue(value) {
|
|
183
|
+
if (isSignal(value) || typeof value === "function") {
|
|
184
|
+
try {
|
|
185
|
+
return Boolean(value());
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// Same fail-soft as stringifyValue: an expression that throws
|
|
189
|
+
// server-side leaves the attribute off and lets the client effect
|
|
190
|
+
// decide once it has a real DOM to read.
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return Boolean(value);
|
|
195
|
+
}
|
|
154
196
|
function stringifyValue(value, inAttribute) {
|
|
155
197
|
if (value === null || value === undefined || value === false)
|
|
156
198
|
return "";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@c9up/aurora",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.37",
|
|
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",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"@types/node": "^22.19.15",
|
|
59
59
|
"@vitest/browser": "4.1.11",
|
|
60
60
|
"@vitest/browser-playwright": "4.1.11",
|
|
61
|
+
"@vitest/coverage-v8": "4.1.9",
|
|
61
62
|
"jsdom": "^30.0.1",
|
|
62
63
|
"playwright": "^1.61.1",
|
|
63
64
|
"typescript": "^6.0.2",
|
|
@@ -81,7 +82,7 @@
|
|
|
81
82
|
"build": "tsc -p tsconfig.build.json",
|
|
82
83
|
"typecheck": "tsc --noEmit",
|
|
83
84
|
"test": "vitest run",
|
|
84
|
-
"lint": "biome check src/",
|
|
85
|
+
"lint": "biome check src/ tests/",
|
|
85
86
|
"test:coverage": "vitest run --coverage",
|
|
86
87
|
"test:browser": "vitest run -c vitest.browser.config.ts"
|
|
87
88
|
}
|
package/src/augmentations.ts
CHANGED
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
* simply inert.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
// Referenced so the
|
|
18
|
+
// Referenced so the augmentations below resolve the modules they augment.
|
|
19
|
+
import type {} from "@c9up/ream";
|
|
19
20
|
import type {} from "@c9up/ream/types";
|
|
20
21
|
|
|
21
22
|
import type { AuroraManager } from "./AuroraManager.js";
|
|
23
|
+
import type { AuroraRequestRenderer } from "./middleware.js";
|
|
22
24
|
|
|
23
25
|
declare module "@c9up/ream/types" {
|
|
24
26
|
interface ContainerBindings {
|
|
@@ -26,3 +28,23 @@ declare module "@c9up/ream/types" {
|
|
|
26
28
|
aurora: AuroraManager;
|
|
27
29
|
}
|
|
28
30
|
}
|
|
31
|
+
|
|
32
|
+
declare module "@c9up/ream" {
|
|
33
|
+
interface HttpContext {
|
|
34
|
+
/**
|
|
35
|
+
* Render a page for THIS request — `ctx.aurora.render(name, props)`.
|
|
36
|
+
*
|
|
37
|
+
* Attached by the `auroraContext` middleware, which is what the docs tell
|
|
38
|
+
* an application to register. Without this declaration the property the
|
|
39
|
+
* middleware sets did not exist as far as the compiler was concerned, so
|
|
40
|
+
* the shorthand the documentation teaches did not typecheck, and a
|
|
41
|
+
* controller had to reach for the module-level `aurora.render(ctx, ...)`
|
|
42
|
+
* or assert its way past it.
|
|
43
|
+
*
|
|
44
|
+
* Optional, because the middleware is: an application that never
|
|
45
|
+
* registers it has no `ctx.aurora`, and saying otherwise would let a
|
|
46
|
+
* controller call something that is not there.
|
|
47
|
+
*/
|
|
48
|
+
aurora?: AuroraRequestRenderer;
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/html.ts
CHANGED
|
@@ -259,11 +259,94 @@ function collectSlots(
|
|
|
259
259
|
return slots;
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Elements that only ever exist inside `<svg>`.
|
|
264
|
+
*
|
|
265
|
+
* Names shared with HTML — `a`, `title`, `style`, `script`, `text` in some
|
|
266
|
+
* dialects — are deliberately absent: seeing one says nothing about which
|
|
267
|
+
* namespace was meant, and guessing wrong would move an ordinary anchor into
|
|
268
|
+
* SVG.
|
|
269
|
+
*/
|
|
270
|
+
const SVG_ONLY = new Set([
|
|
271
|
+
"animate",
|
|
272
|
+
"animatemotion",
|
|
273
|
+
"animatetransform",
|
|
274
|
+
"circle",
|
|
275
|
+
"clippath",
|
|
276
|
+
"defs",
|
|
277
|
+
"desc",
|
|
278
|
+
"ellipse",
|
|
279
|
+
"feblend",
|
|
280
|
+
"fecolormatrix",
|
|
281
|
+
"fegaussianblur",
|
|
282
|
+
"femerge",
|
|
283
|
+
"feoffset",
|
|
284
|
+
"filter",
|
|
285
|
+
"foreignobject",
|
|
286
|
+
"g",
|
|
287
|
+
"image",
|
|
288
|
+
"line",
|
|
289
|
+
"lineargradient",
|
|
290
|
+
"marker",
|
|
291
|
+
"mask",
|
|
292
|
+
"path",
|
|
293
|
+
"pattern",
|
|
294
|
+
"polygon",
|
|
295
|
+
"polyline",
|
|
296
|
+
"radialgradient",
|
|
297
|
+
"rect",
|
|
298
|
+
"stop",
|
|
299
|
+
"svg",
|
|
300
|
+
"symbol",
|
|
301
|
+
"tspan",
|
|
302
|
+
"use",
|
|
303
|
+
]);
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Whether this markup is SVG content that lost its `<svg>` ancestor.
|
|
307
|
+
*
|
|
308
|
+
* A template compiled on its own — `html\`<path/><path/>\``, the body of an
|
|
309
|
+
* icon helper — is parsed with no parent, and the HTML parser has no
|
|
310
|
+
* self-closing tag for an unknown element: the second `<path>` becomes a CHILD
|
|
311
|
+
* of the first, in the XHTML namespace. Nothing throws and nothing is logged;
|
|
312
|
+
* the icon is simply invisible, because `<path>` in the wrong namespace paints
|
|
313
|
+
* nothing. Parsing the same markup inside an `<svg>` makes the parser apply
|
|
314
|
+
* foreign-content rules and produce the two siblings that were written.
|
|
315
|
+
*
|
|
316
|
+
* `<svg>` itself is excluded: the parser already handles it when it is the root
|
|
317
|
+
* of the markup, and wrapping one in another would nest them.
|
|
318
|
+
*/
|
|
319
|
+
function isOrphanedSvgContent(root: HTMLTemplateElement): boolean {
|
|
320
|
+
const elements = Array.from(root.content.childNodes).filter(
|
|
321
|
+
(node): node is Element => node.nodeType === 1,
|
|
322
|
+
);
|
|
323
|
+
if (elements.length === 0) return false;
|
|
324
|
+
return elements.every((el) => {
|
|
325
|
+
const name = el.localName.toLowerCase();
|
|
326
|
+
return name !== "svg" && SVG_ONLY.has(name);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
262
330
|
function compile(strings: TemplateStringsArray): Template {
|
|
263
331
|
const classification = classifySlots(strings);
|
|
264
332
|
const markup = buildMarkup(strings, classification);
|
|
265
|
-
|
|
333
|
+
let tpl = document.createElement("template");
|
|
266
334
|
tpl.innerHTML = markup;
|
|
335
|
+
|
|
336
|
+
if (isOrphanedSvgContent(tpl)) {
|
|
337
|
+
// Re-parsed with the ancestor the markup was written for, then lifted
|
|
338
|
+
// back out: the nodes keep the SVG namespace they were given, and the
|
|
339
|
+
// slot paths below are collected against the shape that will actually
|
|
340
|
+
// be cloned.
|
|
341
|
+
const wrapper = document.createElement("template");
|
|
342
|
+
wrapper.innerHTML = `<svg>${markup}</svg>`;
|
|
343
|
+
const svg = wrapper.content.firstElementChild;
|
|
344
|
+
if (svg !== null) {
|
|
345
|
+
tpl = document.createElement("template");
|
|
346
|
+
while (svg.firstChild) tpl.content.appendChild(svg.firstChild);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
267
350
|
const slots = collectSlots(tpl, classification);
|
|
268
351
|
return { element: tpl, slots };
|
|
269
352
|
}
|
package/src/ssr.ts
CHANGED
|
@@ -68,15 +68,36 @@ function stringifyTemplateResult(result: TemplateResult): string {
|
|
|
68
68
|
// value written into the HTML, and any exception swallowed.
|
|
69
69
|
const directiveMatch = segment.match(/\s([@?.][\w-]+)=("|'|)$/);
|
|
70
70
|
const skipValue = directiveMatch !== null;
|
|
71
|
+
// Set when the skipped directive is a boolean attribute, which — unlike
|
|
72
|
+
// the other two — still has markup to emit. See below.
|
|
73
|
+
let booleanAttrName: string | undefined;
|
|
71
74
|
if (directiveMatch) {
|
|
72
|
-
const [whole = "", , quote] = directiveMatch;
|
|
75
|
+
const [whole = "", directive = "", quote] = directiveMatch;
|
|
73
76
|
segment = segment.slice(0, segment.length - whole.length);
|
|
77
|
+
// `?disabled=${x}` is HTML STATE, not a client-only binding.
|
|
78
|
+
// `@click` is a listener and `.value` a DOM property: neither
|
|
79
|
+
// exists until the runtime binds it, so dropping them is right.
|
|
80
|
+
// A boolean attribute is different — the browser acts on it while
|
|
81
|
+
// parsing. Skipping it too made the server contradict the very
|
|
82
|
+
// first client render: a `?hidden` panel arrived visible and
|
|
83
|
+
// blinked away once hydration caught up.
|
|
84
|
+
if (directive.startsWith("?")) booleanAttrName = directive.slice(1);
|
|
74
85
|
// Only a quoted directive leaves a closing quote to swallow.
|
|
75
86
|
pendingClosingQuote =
|
|
76
87
|
quote === '"' ? '"' : quote === "'" ? "'" : undefined;
|
|
77
88
|
}
|
|
78
89
|
out += segment;
|
|
79
90
|
scanner.consume(segment);
|
|
91
|
+
if (booleanAttrName !== undefined && i < values.length) {
|
|
92
|
+
// Present-and-empty when truthy, absent otherwise — byte-for-byte
|
|
93
|
+
// what applyBooleanAttrSlot writes on the client, so hydration
|
|
94
|
+
// re-applying the effect is a no-op instead of a correction.
|
|
95
|
+
if (resolveBooleanValue(values[i])) {
|
|
96
|
+
const rendered = ` ${booleanAttrName}=""`;
|
|
97
|
+
out += rendered;
|
|
98
|
+
scanner.consume(rendered);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
80
101
|
if (i < values.length && !skipValue) {
|
|
81
102
|
const value = values[i];
|
|
82
103
|
const inAttr = scanner.insideTag;
|
|
@@ -155,6 +176,26 @@ class TagScanner {
|
|
|
155
176
|
}
|
|
156
177
|
}
|
|
157
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Read a boolean attribute's value the way the client reads it: a signal or a
|
|
181
|
+
* reactive expression is called ONCE, then coerced. One level is not an
|
|
182
|
+
* approximation — `applyBooleanAttrSlot` does exactly the same, so a signal
|
|
183
|
+
* that returns a signal is truthy on both sides.
|
|
184
|
+
*/
|
|
185
|
+
function resolveBooleanValue(value: unknown): boolean {
|
|
186
|
+
if (isSignal(value) || typeof value === "function") {
|
|
187
|
+
try {
|
|
188
|
+
return Boolean((value as () => unknown)());
|
|
189
|
+
} catch {
|
|
190
|
+
// Same fail-soft as stringifyValue: an expression that throws
|
|
191
|
+
// server-side leaves the attribute off and lets the client effect
|
|
192
|
+
// decide once it has a real DOM to read.
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return Boolean(value);
|
|
197
|
+
}
|
|
198
|
+
|
|
158
199
|
function stringifyValue(value: unknown, inAttribute: boolean): string {
|
|
159
200
|
if (value === null || value === undefined || value === false) return "";
|
|
160
201
|
if (value === true) return inAttribute ? "" : "true";
|