@jxsuite/compiler 1.4.0 → 2.0.0

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/src/shared.ts CHANGED
@@ -18,21 +18,26 @@ import {
18
18
  import { evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
19
19
  import { runStatements } from "@jxsuite/runtime/statements";
20
20
  import {
21
+ bodyReturnsValue,
21
22
  childrenContainArray,
22
23
  hasStructuredBody,
23
24
  isExpressionDef,
24
25
  isFunctionDef,
25
26
  isMappedArray,
26
- bodyReturnsValue,
27
27
  isNamedFormulaDef,
28
28
  isPrototypeDef,
29
29
  isRef,
30
30
  isSchemaOnlyDef as isSchemaOnly,
31
31
  isServerFnDef,
32
+ isTagExpression,
32
33
  isTemplateString,
33
34
  paramNames,
35
+ tagNameCandidates,
34
36
  } from "@jxsuite/schema/guards";
37
+ import { styleScopePrefix } from "./shadow.ts";
38
+ import type { ShadowMode } from "./shadow.ts";
35
39
  import type { ExpressionNode } from "@jxsuite/runtime/expression";
40
+ import { readPath } from "@jxsuite/runtime/pointer";
36
41
  import type {
37
42
  JsonValue,
38
43
  JxElement,
@@ -54,7 +59,13 @@ export {
54
59
  schemeSelectors,
55
60
  toCSSText,
56
61
  } from "@jxsuite/runtime";
57
- export { compileExpression, evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
62
+ export {
63
+ compileExpression,
64
+ compileOperandSource,
65
+ evaluateExpression,
66
+ evaluateOperand,
67
+ isMutating,
68
+ } from "@jxsuite/runtime/expression";
58
69
  export { compileStatements, runStatements } from "@jxsuite/runtime/statements";
59
70
 
60
71
  /**
@@ -77,8 +88,133 @@ export function emitFormulaFn(def: { parameters?: unknown[] }, compiledBody: str
77
88
  }
78
89
  return `(..._a) => { const _args = { ${entries.join(", ")} }; return ${compiledBody}; }`;
79
90
  }
91
+
92
+ /**
93
+ * Emit the import-clause binding for one Function-def `$src` entry.
94
+ *
95
+ * `$export` names the export inside the module and defaults to the state key (spec.md §5.3 4d).
96
+ * When the two differ the clause has to alias, because the local name the rest of the generated
97
+ * module calls is the state key — importing the bare key instead requests an export the module does
98
+ * not have, and the browser rejects the whole module at link time.
99
+ *
100
+ * Aliasing rather than renaming call sites is what makes the awkward cases work: two keys may alias
101
+ * the same export, a key may collide with another entry's export name, and `$export: "default"` is
102
+ * only reachable as `default as key`.
103
+ *
104
+ * @param {string} key - State key, and the local binding name
105
+ * @param {unknown} def - The Function definition
106
+ * @returns {string}
107
+ */
108
+ export function srcImportBinding(key: string, def: unknown) {
109
+ const declared = (def as { $export?: unknown } | null | undefined)?.$export;
110
+ const exportName = typeof declared === "string" && declared !== "" ? declared : key;
111
+ return exportName === key ? key : `${exportName} as ${key}`;
112
+ }
113
+
114
+ /**
115
+ * Emit the auto-fetch `effect()` for a `$prototype: "Request"` state entry.
116
+ *
117
+ * Shared by the client and element targets so both honour `manual`, template URLs and
118
+ * `method`/`headers`/`body` the same way. They diverged once: the element target emitted no fetch
119
+ * at all, leaving every Request entry permanently `null` with nothing thrown.
120
+ *
121
+ * A template `url` is read inside the effect, so the fetch re-runs whenever the interpolated state
122
+ * changes; an URL that still interpolates to `undefined` is skipped rather than fetched.
123
+ *
124
+ * @param {string} key - State key receiving the response
125
+ * @param {JxPrototypeDef} def - The Request definition
126
+ * @param {{ statePrefix?: string; indent?: string; collect?: string }} [opts] - `statePrefix` is
127
+ * the expression the emitted code assigns through (`state` for the client module scope,
128
+ * `this.state` inside a custom element); `indent` prefixes every emitted line; `collect`, when
129
+ * given, is an array expression the effect runner is pushed onto so the caller can `stop()` it on
130
+ * teardown.
131
+ * @returns {string}
132
+ */
133
+ export function emitRequestFetch(
134
+ key: string,
135
+ def: JxPrototypeDef,
136
+ opts: { statePrefix?: string; indent?: string; collect?: string } = {},
137
+ ) {
138
+ const { statePrefix = "state", indent = "", collect } = opts;
139
+ const { url } = def;
140
+ const isTemplateUrl = isTemplateString(url);
141
+ const method = def.method ?? "GET";
142
+
143
+ if (def.manual) {
144
+ return `${indent}// ${key}: manual Request — fetch triggered by user action`;
145
+ }
146
+
147
+ const lines: string[] = [
148
+ `// ${key}: auto-fetch from ${isTemplateUrl ? "(dynamic URL)" : url}`,
149
+ collect ? `${collect}.push(effect(() => {` : "effect(() => {",
150
+ ];
151
+
152
+ if (isTemplateUrl) {
153
+ lines.push(
154
+ ` const url = \`${withStatePrefix(url as string, statePrefix)}\`;`,
155
+ ' if (!url || url === "undefined" || url.includes("undefined")) return;',
156
+ );
157
+ } else {
158
+ lines.push(` const url = ${JSON.stringify(url)};`);
159
+ }
160
+
161
+ const fetchOpts: string[] = [];
162
+ if (method !== "GET") {
163
+ fetchOpts.push(`method: ${JSON.stringify(method)}`);
164
+ }
165
+ if (def.headers) {
166
+ fetchOpts.push(`headers: ${JSON.stringify(def.headers)}`);
167
+ }
168
+ if (def.body) {
169
+ const bodyStr =
170
+ typeof def.body === "object"
171
+ ? JSON.stringify(JSON.stringify(def.body))
172
+ : JSON.stringify(def.body);
173
+ fetchOpts.push(`body: ${bodyStr}`);
174
+ }
175
+
176
+ const optsStr = fetchOpts.length > 0 ? `, { ${fetchOpts.join(", ")} }` : "";
177
+ lines.push(
178
+ ` fetch(url${optsStr})`,
179
+ " .then(r => r.ok ? r.json() : Promise.reject(r.statusText))",
180
+ ` .then(d => { ${statePrefix}.${key} = d; })`,
181
+ ` .catch(e => { ${statePrefix}.${key} = { error: String(e) }; });`,
182
+ collect ? "}));" : "});",
183
+ );
184
+
185
+ return lines.map((line) => `${indent}${line}`).join("\n");
186
+ }
187
+
188
+ /**
189
+ * Rebase the bare `state.` reads in a template string onto `statePrefix`.
190
+ *
191
+ * Only the `${…}` interpolation holes are rewritten. A blanket replace would corrupt the literal
192
+ * text around them — `/api/state.json?q=${state.q}` would request `/api/this.state.json` — and the
193
+ * `\b` guard keeps an identifier that merely ends in `state` (`substate.z`) intact. Reads already
194
+ * carrying the prefix are left alone.
195
+ *
196
+ * @param {string} str
197
+ * @param {string} statePrefix
198
+ * @returns {string}
199
+ */
200
+ function withStatePrefix(str: string, statePrefix: string) {
201
+ if (statePrefix === "state") {
202
+ return str;
203
+ }
204
+ return str.replaceAll(
205
+ /\$\{([^}]*)\}/g,
206
+ (_match: string, expr: string) =>
207
+ `\${${expr.replaceAll(/(?<!this\.)\bstate\./g, `${statePrefix}.`)}}`,
208
+ );
209
+ }
80
210
  export type { ExpressionNode } from "@jxsuite/runtime/expression";
81
211
 
212
+ /**
213
+ * Non-enumerable marker on a build-time scope listing the state keys that hold no build-time value.
214
+ * Non-enumerable so it stays out of `Object.entries(scope)` and out of template evaluation.
215
+ */
216
+ const RUNTIME_ONLY_KEYS = Symbol("jx.runtimeOnlyStateKeys");
217
+
82
218
  // CDN defaults
83
219
  export const DEFAULT_REACTIVITY_SRC = "https://esm.sh/@vue/reactivity@3.5.40";
84
220
  export const DEFAULT_LIT_HTML_SRC = "https://esm.sh/lit-html@3.3.0";
@@ -275,6 +411,8 @@ export interface CompileContext {
275
411
  scope: Record<string, unknown>;
276
412
  scopeDefs: Record<string, unknown>;
277
413
  media: Record<string, string>;
414
+ /** True inside a `<pre>`-like subtree, where emitters must not indent nested children. */
415
+ preformatted?: boolean;
278
416
  }
279
417
 
280
418
  export function createCompileContext(
@@ -289,6 +427,62 @@ export function createCompileContext(
289
427
  return { media, scope, scopeDefs };
290
428
  }
291
429
 
430
+ /**
431
+ * `state.x = …`, `state.x += …`, `state.x++` — every form a string handler body can use to write a
432
+ * state entry. `===`/`!==`/`<=`/`>=` are excluded by the `(?!=)` guard and by the operator set.
433
+ */
434
+ const STATE_ASSIGNMENT_RE =
435
+ /\bstate\.([A-Za-z_$][\w$]*)\s*(?:\+\+|--|(?:\*\*|\|\||&&|\?\?|<<|>>>|>>|[-+*/%&|^])?=(?!=))/g;
436
+
437
+ /** `state.list.push(…)` and friends mutate in place without ever assigning to the entry. */
438
+ const STATE_MUTATING_METHOD_RE =
439
+ /\bstate\.([A-Za-z_$][\w$]*)\s*\.\s*(?:push|pop|shift|unshift|splice|sort|reverse|fill|copyWithin)\s*\(/g;
440
+
441
+ /**
442
+ * Collect every state key a handler writes to, across all the forms a body can take: a string body,
443
+ * a structured body (spec §20), and an `$expression` node with a mutating operator.
444
+ *
445
+ * Used to keep prerender from baking a binding over an entry that changes after hydration. A plain
446
+ * `{ "type": "string" }` entry holds a perfectly ordinary build-time value, so nothing else
447
+ * distinguishes it from a constant — but if a handler assigns to it, interpolating it at build time
448
+ * replaces the template in the emitted output and the element is dead for the life of the page.
449
+ *
450
+ * @param {unknown} node
451
+ * @param {Set<string>} into
452
+ */
453
+ function collectAssignedStateKeys(node: unknown, into: Set<string>) {
454
+ if (typeof node === "string") {
455
+ for (const m of node.matchAll(STATE_ASSIGNMENT_RE)) {
456
+ into.add(m[1] as string);
457
+ }
458
+ for (const m of node.matchAll(STATE_MUTATING_METHOD_RE)) {
459
+ into.add(m[1] as string);
460
+ }
461
+ return;
462
+ }
463
+ if (!node || typeof node !== "object") {
464
+ return;
465
+ }
466
+ if (Array.isArray(node)) {
467
+ for (const entry of node) {
468
+ collectAssignedStateKeys(entry, into);
469
+ }
470
+ return;
471
+ }
472
+ const rec = node as Record<string, unknown>;
473
+ // A mutating expression names its destination as `target: { $ref: "#/state/x" }`.
474
+ const op = rec.operator;
475
+ if (typeof op === "string" && isMutating(op)) {
476
+ const target = rec.target as { $ref?: string } | undefined;
477
+ if (target && typeof target.$ref === "string" && target.$ref.startsWith("#/state/")) {
478
+ into.add(target.$ref.slice("#/state/".length).split("/")[0] as string);
479
+ }
480
+ }
481
+ for (const value of Object.values(rec)) {
482
+ collectAssignedStateKeys(value, into);
483
+ }
484
+ }
485
+
292
486
  /**
293
487
  * @param {Record<string, JxStateDefinition>} [defs]
294
488
  * @param {Record<string, unknown> | null} [parentScope]
@@ -299,6 +493,38 @@ export function buildInitialScope(
299
493
  parentScope: Record<string, unknown> | null = null,
300
494
  ) {
301
495
  const scope = Object.create(parentScope ?? null) as Record<string, unknown>;
496
+ // Keys whose real value only exists after hydration, so prerender must not interpolate them —
497
+ // See `readsRuntimeOnlyState`. Seeded from the parent so nested scopes inherit the marks.
498
+ const inherited = (parentScope as Record<PropertyKey, unknown> | null)?.[RUNTIME_ONLY_KEYS];
499
+ const runtimeOnly = new Set<string>(inherited instanceof Set ? (inherited as Set<string>) : []);
500
+ // Attached up front, and mutated in place by the passes below, so the template pass at the end can
501
+ // Consult the marks the earlier passes added.
502
+ Object.defineProperty(scope, RUNTIME_ONLY_KEYS, {
503
+ configurable: true,
504
+ enumerable: false,
505
+ value: runtimeOnly,
506
+ writable: true,
507
+ });
508
+
509
+ // Pass 0: every entry some handler in this document writes to is mutable, so prerender must not
510
+ // Bake a binding over it. Narrow on purpose — an entry nothing ever writes stays bakeable, so
511
+ // Prerendered content is preserved for SEO. A `$src` handler's assignments live in a JS file this
512
+ // Builder cannot open, so a write that only happens there is still missed (issue #125).
513
+ const assigned = new Set<string>();
514
+ for (const def of Object.values(defs)) {
515
+ if (def && typeof def === "object" && (isFunctionDef(def) || isExpressionDef(def))) {
516
+ collectAssignedStateKeys(
517
+ (def as { body?: unknown; $expression?: unknown }).body ??
518
+ (def as { $expression?: unknown }).$expression,
519
+ assigned,
520
+ );
521
+ }
522
+ }
523
+ for (const key of assigned) {
524
+ if (key in defs) {
525
+ runtimeOnly.add(key);
526
+ }
527
+ }
302
528
 
303
529
  for (const [key, def] of Object.entries(defs)) {
304
530
  if (typeof def !== "object" || def === null || Array.isArray(def)) {
@@ -315,9 +541,17 @@ export function buildInitialScope(
315
541
  }
316
542
  }
317
543
 
544
+ // Template-string entries are deferred to a third pass: whether one is resolvable depends on the
545
+ // Runtime-only marks the loop below adds, and key order must not decide the answer.
546
+ const templateDefs: [string, string][] = [];
547
+ // Computed (`bodyReturnsValue`) entries are deferred for the same reason. A computed is stored in
548
+ // The scope as its already-evaluated *result*, so a template reading it sees an ordinary string
549
+ // And prerender bakes it — destroying the binding rather than merely staling it (issue #124).
550
+ const computedBodies: [string, string][] = [];
551
+
318
552
  for (const [key, def] of Object.entries(defs)) {
319
553
  if (typeof def === "string" && isTemplateString(def)) {
320
- defineLazyScopeValue(scope, key, () => evaluateStaticTemplate(def, scope));
554
+ templateDefs.push([key, def]);
321
555
  continue;
322
556
  }
323
557
  if (!def || typeof def !== "object") {
@@ -359,18 +593,28 @@ export function buildInitialScope(
359
593
  void runStatements(body, s ?? scope, event ?? null);
360
594
  });
361
595
  } else if (typeof def.body === "string") {
362
- const names = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
596
+ const declared = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
363
597
  const { body } = def;
364
- const fn = new Function("state", ...names, body) as (
365
- state: Record<string, unknown>,
366
- ) => unknown;
598
+ // Declared names bind by name, not by position (spec.md §5.3 4d): a parameter literally
599
+ // Named `state` receives the scope, anything else receives the event. `state` is prepended
600
+ // When undeclared so a body may always reference it. Unconditionally prepending it instead —
601
+ // The older behaviour here — produced a duplicate parameter for the `["state", "event"]`
602
+ // Form that examples/components/task-manager.json uses, leaving `state` undefined.
603
+ const params = declared.includes("state") ? declared : ["state", ...declared];
604
+ const fn = new Function(...params, body) as (...args: unknown[]) => unknown;
605
+ const invoke = (state: Record<string, unknown>, event?: unknown) =>
606
+ fn(...params.map((name) => (name === "state" ? state : event)));
367
607
  if (bodyReturnsValue(body)) {
368
- defineLazyScopeValue(scope, key, () => fn(scope));
608
+ defineLazyScopeValue(scope, key, () => invoke(scope));
609
+ computedBodies.push([key, body]);
369
610
  } else {
370
- setOwnScopeValue(scope, key, fn);
611
+ setOwnScopeValue(scope, key, invoke);
371
612
  }
372
613
  } else {
614
+ // Bodyless `$src` Function — the real implementation is only loaded in the browser, so this
615
+ // Is a placeholder standing in for its callable shape, never its value.
373
616
  setOwnScopeValue(scope, key, () => {});
617
+ runtimeOnly.add(key);
374
618
  }
375
619
  continue;
376
620
  }
@@ -379,9 +623,34 @@ export function buildInitialScope(
379
623
  (def.$prototype === "LocalStorage" || def.$prototype === "SessionStorage")
380
624
  ) {
381
625
  setOwnScopeValue(scope, key, cloneValue(def.default ?? null));
626
+ continue;
627
+ }
628
+ if (isPrototypeDef(def) && def.$prototype === "Request") {
629
+ // A client-timing fetch has no build-time value at all — not even a placeholder.
630
+ runtimeOnly.add(key);
382
631
  }
383
632
  }
384
633
 
634
+ // A state entry that reads a runtime-only key cannot resolve until hydration either, so it is
635
+ // Runtime-only in turn. Without this, prerender would bake the failed evaluation (`null`) into
636
+ // Every template that reads *this* entry, one step removed from the original. Iterated to a
637
+ // Fixpoint because marking one entry can qualify another, in either direction, and declaration
638
+ // Order must not decide the answer.
639
+ let marked = true;
640
+ while (marked) {
641
+ marked = false;
642
+ for (const [key, source] of [...computedBodies, ...templateDefs]) {
643
+ if (!runtimeOnly.has(key) && readsRuntimeOnlyState(source, scope)) {
644
+ runtimeOnly.add(key);
645
+ marked = true;
646
+ }
647
+ }
648
+ }
649
+
650
+ for (const [key, template] of templateDefs) {
651
+ defineLazyScopeValue(scope, key, () => evaluateStaticTemplate(template, scope));
652
+ }
653
+
385
654
  return scope;
386
655
  }
387
656
 
@@ -455,14 +724,59 @@ export function resolveRefValue(refValue: unknown, scope: Record<string, unknown
455
724
  return parts.length > 2 ? getPathValue(base, parts.slice(2).join("/")) : base;
456
725
  }
457
726
  if (refValue.startsWith("#/state/")) {
458
- const sub = refValue.slice("#/state/".length);
459
- const slash = sub.indexOf("/");
460
- if (slash === -1) {
461
- return scope[sub];
727
+ // One call, not a hand-split leading token: slicing at the first `/` skipped unescaping it, so
728
+ // `#/state/a~1b/c` looked for a member called `a~1b` rather than `a/b`.
729
+ return getPathValue(scope, refValue.slice("#/state/".length));
730
+ }
731
+ /*
732
+ * An unrecognized scheme is still a path, matching the runtime resolvers and the lowerer.
733
+ * Reading it as one key returned null for `a/b` while the emitted client module walked it.
734
+ */
735
+ return getPathValue(scope, refValue) ?? null;
736
+ }
737
+
738
+ /**
739
+ * Detect whether a template string interpolates a state value that only exists after hydration.
740
+ *
741
+ * Evaluating one of those at build time is worse than leaving it alone. The build-time scope holds
742
+ * a `() => {}` placeholder for a bodyless `$src` Function and nothing at all for a `Request`, so
743
+ * the template resolves to text like `() => {}`, `undefined` or `""` — and that text _replaces_ the
744
+ * template in the emitted HTML, so the client-side binding is destroyed rather than merely wrong.
745
+ * Returning "unresolvable" instead leaves the template in place for the client to populate.
746
+ *
747
+ * A read that _calls_ the value is left alone: invoking a build-time callable is exactly how named
748
+ * formulas (spec.md §19.4c) are meant to be used during prerender.
749
+ *
750
+ * @param {string} str
751
+ * @param {Record<string, unknown>} scope
752
+ * @returns {boolean}
753
+ */
754
+ function readsRuntimeOnlyState(str: string, scope: Record<string, unknown>) {
755
+ if (!scope) {
756
+ return false;
757
+ }
758
+ const runtimeOnly = (scope as Record<PropertyKey, unknown>)[RUNTIME_ONLY_KEYS];
759
+ for (const match of str.matchAll(/\bstate\.([A-Za-z_$][\w$]*)\s*(\()?/g)) {
760
+ if (match[2]) {
761
+ continue;
762
+ }
763
+ const key = match[1] as string;
764
+ if (runtimeOnly instanceof Set && runtimeOnly.has(key)) {
765
+ return true;
766
+ }
767
+ // Reading the key can run a computed's getter, which is free to throw on partial build-time
768
+ // Data. A throw says nothing about whether the entry is runtime-only, so it is not a mark.
769
+ let value: unknown;
770
+ try {
771
+ value = scope[key];
772
+ } catch {
773
+ continue;
774
+ }
775
+ if (typeof value === "function") {
776
+ return true;
462
777
  }
463
- return getPathValue(scope[sub.slice(0, slash)], sub.slice(slash + 1));
464
778
  }
465
- return scope[refValue] ?? null;
779
+ return false;
466
780
  }
467
781
 
468
782
  /**
@@ -471,20 +785,24 @@ export function resolveRefValue(refValue: unknown, scope: Record<string, unknown
471
785
  * @returns {unknown}
472
786
  */
473
787
  export function evaluateStaticTemplate(str: string, scope: Record<string, unknown>) {
788
+ if (readsRuntimeOnlyState(str, scope)) {
789
+ return null;
790
+ }
791
+ /*
792
+ * `$site` and `$page` are bound as parameters, not just left on `state`.
793
+ *
794
+ * `injectContext` puts them on the document's state, so `${state.$site.name}` always worked — but
795
+ * every example in site-architecture.md §8.2 writes the bare `${$site.name}`, and that threw a
796
+ * ReferenceError the catch below turned into a silent null. A `$head` title referencing the site
797
+ * name reached the page as the literal template text.
798
+ */
799
+ const args = ["state", "$map", "$site", "$page"] as const;
800
+ const values = [scope, scope?.$map, scope?.$site, scope?.$page];
474
801
  try {
475
802
  const singleExprMatch = str.match(/^\$\{(.+)\}$/s);
476
- if (singleExprMatch) {
477
- const fn = new Function("state", "$map", `return (${singleExprMatch[1]})`) as (
478
- state: Record<string, unknown>,
479
- $map: unknown,
480
- ) => unknown;
481
- return fn(scope, scope?.$map);
482
- }
483
- const fn = new Function("state", "$map", `return \`${str}\``) as (
484
- state: Record<string, unknown>,
485
- $map: unknown,
486
- ) => unknown;
487
- return fn(scope, scope?.$map);
803
+ const body = singleExprMatch ? `return (${singleExprMatch[1]})` : `return \`${str}\``;
804
+ const fn = new Function(...args, body) as (...a: unknown[]) => unknown;
805
+ return fn(...values);
488
806
  } catch {
489
807
  return null;
490
808
  }
@@ -496,14 +814,7 @@ export function evaluateStaticTemplate(str: string, scope: Record<string, unknow
496
814
  * @returns {unknown}
497
815
  */
498
816
  export function getPathValue(base: unknown, path: string) {
499
- if (!path) {
500
- return base;
501
- }
502
- let acc: unknown = base;
503
- for (const key of path.split("/")) {
504
- acc = acc == null ? undefined : (acc as Record<string, unknown>)[key];
505
- }
506
- return acc;
817
+ return readPath(base, path);
507
818
  }
508
819
 
509
820
  /**
@@ -595,15 +906,6 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
595
906
  }
596
907
  }
597
908
 
598
- if (def.tagName === "img") {
599
- if (!def.attributes?.loading) {
600
- out += ` loading="lazy"`;
601
- }
602
- if (!def.attributes?.decoding) {
603
- out += ` decoding="async"`;
604
- }
605
- }
606
-
607
909
  if (def.$static) {
608
910
  out += ` data-jx-static`;
609
911
  } else if (def.$prerendered) {
@@ -613,6 +915,26 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
613
915
  return out;
614
916
  }
615
917
 
918
+ /**
919
+ * Tags rendered with `white-space: pre` by every UA, where inter-element whitespace is content
920
+ * rather than formatting. Emitters indent nested children for readability; inside these tags — and
921
+ * anywhere below them, since `white-space` inherits — that indentation would render as literal
922
+ * blank lines and leading spaces, so children must be concatenated with no separator.
923
+ */
924
+ export const PREFORMATTED_TAGS = new Set(["pre", "textarea", "listing", "plaintext", "xmp"]);
925
+
926
+ /**
927
+ * The separator to place between an element's compiled children: none inside a preformatted
928
+ * subtree, a newline plus `indent` elsewhere.
929
+ *
930
+ * @param {boolean | undefined} preformatted
931
+ * @param {string} [indent]
932
+ * @returns {string}
933
+ */
934
+ export function childSeparator(preformatted: boolean | undefined, indent = " "): string {
935
+ return preformatted ? "" : `\n${indent}`;
936
+ }
937
+
616
938
  /**
617
939
  * Build the inner HTML (textContent or children) for a node.
618
940
  *
@@ -622,6 +944,7 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
622
944
  * scope: Record<string, unknown>;
623
945
  * scopeDefs: Record<string, unknown>;
624
946
  * media: Record<string, string>;
947
+ * preformatted?: boolean;
625
948
  * }} context
626
949
  * @param {(def: unknown, raw: unknown, context: unknown) => string} childCompiler
627
950
  * @returns {string}
@@ -633,6 +956,7 @@ export function buildInner(
633
956
  scope: Record<string, unknown> | null;
634
957
  scopeDefs: Record<string, unknown>;
635
958
  media: Record<string, string>;
959
+ preformatted?: boolean;
636
960
  },
637
961
  childCompiler: (def: unknown, raw: unknown, context: unknown) => string,
638
962
  ) {
@@ -652,7 +976,7 @@ export function buildInner(
652
976
  const childRaw = (rawChildren as (JxElement | string)[] | undefined)?.[i] ?? c;
653
977
  return childCompiler(c, childRaw, context);
654
978
  })
655
- .join("\n ");
979
+ .join(childSeparator(context.preformatted));
656
980
  }
657
981
  return "";
658
982
  }
@@ -945,11 +1269,16 @@ export function collectStyles(
945
1269
  counter.n += 1;
946
1270
  }
947
1271
 
1272
+ /* A chosen tag needs a selector that matches every candidate, not the first one.
1273
+ `#id` and `.class` are unaffected — they identify the element regardless of what it turns out
1274
+ to be — but a bare tag selector has to become the union, or styling an `<a>|<div>` wrapper
1275
+ would style exactly one of the two branches and silently miss the other. */
1276
+ const tagSelector = tagNameCandidates(def.tagName).join(", ") || "*";
948
1277
  const selector = def.id
949
1278
  ? `#${def.id}`
950
1279
  : def.className
951
1280
  ? `.${def.className.split(" ")[0]}`
952
- : (def.tagName ?? "*");
1281
+ : tagSelector;
953
1282
 
954
1283
  if (def.style) {
955
1284
  const baseDecls = [];
@@ -1150,6 +1479,40 @@ export const SELF_CLOSING = new Set<string>([
1150
1479
  "source",
1151
1480
  ]);
1152
1481
 
1482
+ /**
1483
+ * A tag for the PRERENDER, resolved against the same scope the attributes are.
1484
+ *
1485
+ * The static renderer produces bytes, so it must commit to one element. Every candidate is a
1486
+ * literal `TagName`, so committing is safe — and it is resolved here rather than left as text
1487
+ * precisely because the old `${…}` form was left as text and then re-resolved against the page
1488
+ * scope, where a component's own state is undefined.
1489
+ *
1490
+ * @param {unknown} tagName
1491
+ * @param {Record<string, unknown>} scope
1492
+ * @returns {string}
1493
+ */
1494
+ export function resolveStaticTagName(
1495
+ tagName: unknown,
1496
+ scope: Record<string, unknown> | null,
1497
+ ): string {
1498
+ if (typeof tagName === "string") {
1499
+ return tagName;
1500
+ }
1501
+ const candidates = tagNameCandidates(tagName);
1502
+ if (candidates.length === 0) {
1503
+ return "div";
1504
+ }
1505
+ if (!isTagExpression(tagName)) {
1506
+ return candidates[0]!;
1507
+ }
1508
+ const expression = tagName.$expression;
1509
+ if (expression.operator === "?:") {
1510
+ return resolveStaticValue(expression.target, scope) ? expression.value : expression.initial;
1511
+ }
1512
+ const key = resolveStaticValue(expression.target, scope);
1513
+ return expression.cases[String(key)] ?? expression.default;
1514
+ }
1515
+
1153
1516
  /**
1154
1517
  * Recursively render a Jx node tree to static HTML for pre-rendering.
1155
1518
  *
@@ -1207,7 +1570,11 @@ export function renderStaticNode(
1207
1570
  return `<${switchTag}${attrs}>${inner}</${switchTag}>`;
1208
1571
  }
1209
1572
 
1210
- const tag = node.tagName ?? "div";
1573
+ /* Resolved against the SAME scope the attributes are resolved against, so the prerendered markup
1574
+ and the client's first render agree about what element this is. The old `${…}` form could not
1575
+ do this — it re-resolved the emitted string against the page scope, where a component's own
1576
+ state does not exist, so SSR silently emitted the fallback branch's tag. */
1577
+ const tag = resolveStaticTagName(node.tagName, scope);
1211
1578
 
1212
1579
  // Replace <slot> with provided slot content
1213
1580
  if (tag === "slot" && slotContent != null) {
@@ -1356,13 +1723,55 @@ function _isStaticNode(node: JxElement | string | (JxElement | string)[]): boole
1356
1723
  * @param {Record<string, string>} [mediaQueries] - Project media query definitions
1357
1724
  * @returns {string} CSS text, or empty string if no styles
1358
1725
  */
1726
+ /** `::slotted()` and `::part()` select through a shadow boundary, not the host that owns it. */
1727
+ const SHADOW_STANDALONE = /^(?:::slotted\(|::part\()/;
1728
+
1729
+ /**
1730
+ * Turn one nested style key into a selector, given the component's scope prefix.
1731
+ *
1732
+ * Three shapes, and the interesting one is `:host`. A style object should mean the same thing in
1733
+ * both modes, so `:host` and `:host(.foo)` are **translated** rather than passed through: inside a
1734
+ * shadow root they stand alone, and in the light DOM they become the tag name and `<tag>.foo` —
1735
+ * which is what "the host, matching this" means when there is no shadow root. Moving a component
1736
+ * between modes therefore does not silently break its styles.
1737
+ *
1738
+ * @param {string} prop - The style-object key, e.g. `":hover"`, `"& .inner"`, `":host(.wide)"`
1739
+ * @param {string} scope - `":host"` in shadow mode, the tag name otherwise
1740
+ * @returns {string}
1741
+ */
1742
+ function resolveSelector(prop: string, scope: string): string {
1743
+ if (prop.startsWith("&")) {
1744
+ return prop.replace("&", scope);
1745
+ }
1746
+ if (prop.startsWith(":host")) {
1747
+ const inner = /^:host\((.*)\)$/.exec(prop)?.[1];
1748
+ if (scope === ":host") {
1749
+ return prop;
1750
+ }
1751
+ return inner === undefined ? scope : `${scope}${inner}`;
1752
+ }
1753
+ // `:host::slotted(x)` matches nothing — the pseudo-element attaches to a slot, not the host.
1754
+ if (scope === ":host" && SHADOW_STANDALONE.test(prop)) {
1755
+ return prop;
1756
+ }
1757
+ return `${scope}${prop}`;
1758
+ }
1759
+
1359
1760
  export function buildComponentCSS(
1360
1761
  tagName: string,
1361
1762
  styleDef?: JxStyle | null | undefined,
1362
1763
  doc: JxElement | null = null,
1363
1764
  mediaQueries: Record<string, string> = {},
1765
+ shadow: ShadowMode | null = null,
1364
1766
  ) {
1365
1767
  const rules: string[] = [];
1768
+ /*
1769
+ * The scope prefix. In light DOM it is the tag name, which is what keeps `sty-card .inner` from
1770
+ * reaching another component's `.inner`. Inside a shadow root the selector cannot see the host's
1771
+ * tag name at all, and `:host` is the standard's way to address it — so the same style object
1772
+ * produces different, correct CSS in each mode without the author restating it.
1773
+ */
1774
+ const scope = styleScopePrefix(tagName, shadow);
1366
1775
 
1367
1776
  if (styleDef && typeof styleDef === "object") {
1368
1777
  const decls: string[] = [];
@@ -1385,20 +1794,21 @@ export function buildComponentCSS(
1385
1794
  decls.push(` ${camelToKebab(prop)}: ${value};`);
1386
1795
  }
1387
1796
  if (decls.length > 0) {
1388
- rules.push(`${tagName} {\n${decls.join("\n")}\n}`);
1797
+ rules.push(`${scope} {\n${decls.join("\n")}\n}`);
1389
1798
  }
1390
1799
 
1391
1800
  for (const [prop, val] of Object.entries(styleDef)) {
1392
1801
  if (prop.startsWith("@")) {
1393
- pushConditionalRule(rules, prop, mediaQueries, tagName, val as Record<string, unknown>);
1802
+ pushConditionalRule(rules, prop, mediaQueries, scope, val as Record<string, unknown>);
1394
1803
  } else if (
1395
1804
  prop.startsWith(":") ||
1396
1805
  prop.startsWith(".") ||
1397
1806
  prop.startsWith("&") ||
1398
1807
  prop.startsWith("[")
1399
1808
  ) {
1400
- const resolved = prop.startsWith("&") ? prop.replace("&", tagName) : `${tagName}${prop}`;
1401
- rules.push(`${resolved} { ${toCSSText(val as Record<string, unknown>)} }`);
1809
+ rules.push(
1810
+ `${resolveSelector(prop, scope)} { ${toCSSText(val as Record<string, unknown>)} }`,
1811
+ );
1402
1812
  }
1403
1813
  }
1404
1814
  }