@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.
@@ -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
- const tpl = document.createElement("template");
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.36",
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
  }
@@ -15,10 +15,12 @@
15
15
  * simply inert.
16
16
  */
17
17
 
18
- // Referenced so the augmentation below resolves the module it augments.
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
- const tpl = document.createElement("template");
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";