@jxsuite/compiler 0.28.2 → 0.28.3

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
@@ -6,17 +6,29 @@
6
6
  * compilation targets (static, client, element, server).
7
7
  */
8
8
 
9
- import { camelToKebab, toCSSText, RESERVED_KEYS } from "@jxsuite/runtime";
10
- import { compileExpression, isMutating, evaluateExpression } from "@jxsuite/runtime/expression";
9
+ import { RESERVED_KEYS, camelToKebab, toCSSText } from "@jxsuite/runtime";
10
+ import { compileExpression, evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
11
+ import {
12
+ isExpressionDef,
13
+ isFunctionDef,
14
+ isPrototypeDef,
15
+ isRef,
16
+ isSchemaOnlyDef as isSchemaOnly,
17
+ isServerFnDef,
18
+ isTemplateString,
19
+ paramNames,
20
+ } from "@jxsuite/schema/guards";
11
21
  import type { ExpressionNode } from "@jxsuite/runtime/expression";
12
22
  import type {
23
+ JsonValue,
13
24
  JxElement,
25
+ JxMappedArray,
14
26
  JxMutableNode,
15
- JxStyle,
16
- JxStateDefinition,
17
27
  JxPrototypeDef,
28
+ JxRef,
29
+ JxStateDefinition,
18
30
  JxStateObject,
19
- JxMappedArray,
31
+ JxStyle,
20
32
  } from "@jxsuite/schema/types";
21
33
 
22
34
  // Re-export runtime utilities used by submodules
@@ -34,30 +46,14 @@ export type { ExpressionNode };
34
46
  export const DEFAULT_REACTIVITY_SRC = "https://esm.sh/@vue/reactivity@3.5.32";
35
47
  export const DEFAULT_LIT_HTML_SRC = "https://esm.sh/lit-html@3.3.0";
36
48
 
37
- // ─── Schema keywords ─────────────────────────────────────────────────────────
49
+ // ─── Schema keywords & detection ─────────────────────────────────────────────
50
+ // Centralized in @jxsuite/schema/guards; re-exported here for existing callers.
38
51
 
39
- /**
40
- * Schema-only keywords used to detect pure type definitions (Shape 2b). An object with ONLY these
41
- * keys and no `default` is a type def, not a signal.
42
- */
43
- export const SCHEMA_KEYWORDS = new Set([
44
- "type",
45
- "enum",
46
- "minimum",
47
- "maximum",
48
- "minLength",
49
- "maxLength",
50
- "pattern",
51
- "format",
52
- "items",
53
- "properties",
54
- "required",
55
- "description",
56
- "title",
57
- "$comment",
58
- ]);
59
-
60
- // ─── Detection ────────────────────────────────────────────────────────────────
52
+ export {
53
+ SCHEMA_KEYWORDS,
54
+ isSchemaOnlyDef as isSchemaOnly,
55
+ isTemplateString,
56
+ } from "@jxsuite/schema/guards";
61
57
 
62
58
  /**
63
59
  * Returns true if a $src path points to a .class.json schema-defined class.
@@ -65,33 +61,10 @@ export const SCHEMA_KEYWORDS = new Set([
65
61
  * @param {unknown} src
66
62
  * @returns {boolean}
67
63
  */
68
- export function isClassJsonSrc(src: unknown) {
64
+ export function isClassJsonSrc(src?: unknown): src is string {
69
65
  return typeof src === "string" && src.endsWith(".class.json");
70
66
  }
71
67
 
72
- /**
73
- * Returns true if an object contains only schema keywords (no `default`, no `$prototype`).
74
- *
75
- * @param {Record<string, unknown>} obj
76
- * @returns {boolean}
77
- */
78
- export function isSchemaOnly(obj: Record<string, unknown>) {
79
- for (const k of Object.keys(obj)) {
80
- if (!SCHEMA_KEYWORDS.has(k)) return false;
81
- }
82
- return true;
83
- }
84
-
85
- /**
86
- * Returns true if a string contains a ${} template expression.
87
- *
88
- * @param {unknown} val
89
- * @returns {boolean}
90
- */
91
- export function isTemplateString(val: unknown) {
92
- return typeof val === "string" && val.includes("${");
93
- }
94
-
95
68
  /**
96
69
  * Determine whether a node (or any of its descendants) requires client-side JavaScript.
97
70
  *
@@ -99,55 +72,84 @@ export function isTemplateString(val: unknown) {
99
72
  * @returns {boolean}
100
73
  */
101
74
  export function isDynamic(def: JxElement | JxMutableNode | string) {
102
- if (!def || typeof def !== "object") return false;
75
+ if (!def || typeof def !== "object") {
76
+ return false;
77
+ }
103
78
 
104
79
  if (def.state) {
105
80
  for (const [k, d] of Object.entries(def.state)) {
106
81
  // Skip injected context (read-only, not reactive)
107
- if (k === "$site" || k === "$page") continue;
82
+ if (k === "$site" || k === "$page") {
83
+ continue;
84
+ }
108
85
  // Skip timing: "compiler" entries — resolved at build time, baked into static HTML
109
86
  if (
110
87
  d &&
111
88
  typeof d === "object" &&
112
89
  !Array.isArray(d) &&
113
90
  (d as JxPrototypeDef).timing === "compiler"
114
- )
91
+ ) {
92
+ continue;
93
+ }
94
+ if (typeof d !== "object" || d === null || Array.isArray(d)) {
95
+ return true;
96
+ }
97
+ if ((d as JxPrototypeDef).$prototype) {
98
+ return true;
99
+ }
100
+ if ("default" in /** @type {object} */ d) {
101
+ return true;
102
+ }
103
+ if (isSchemaOnly(d)) {
115
104
  continue;
116
- if (typeof d !== "object" || d === null || Array.isArray(d)) return true;
117
- if ((d as JxPrototypeDef).$prototype) return true;
118
- if ("default" in /** @type {object} */ d) return true;
119
- if (isSchemaOnly(d)) continue;
105
+ }
120
106
  return true;
121
107
  }
122
108
  }
123
109
 
124
- if (def.$switch) return true;
110
+ if (def.$switch) {
111
+ return true;
112
+ }
125
113
  if (
126
114
  !Array.isArray(def.children) &&
127
115
  (def.children as JxMappedArray | undefined)?.$prototype === "Array"
128
- )
116
+ ) {
129
117
  return true;
118
+ }
130
119
 
131
- if (Array.isArray(def.children)) {
132
- if (def.children.some((c) => isDynamic(c))) return true;
120
+ if (Array.isArray(def.children) && def.children.some((c) => isDynamic(c))) {
121
+ return true;
133
122
  }
134
123
 
135
124
  for (const [key, val] of Object.entries(def)) {
136
- if (RESERVED_KEYS.has(key)) continue;
137
- if (val !== null && typeof val === "object" && typeof (val as JxMutableNode).$ref === "string")
125
+ if (RESERVED_KEYS.has(key)) {
126
+ continue;
127
+ }
128
+ if (
129
+ val !== null &&
130
+ typeof val === "object" &&
131
+ typeof (val as JxMutableNode).$ref === "string"
132
+ ) {
138
133
  return true;
139
- if (isTemplateString(val)) return true;
134
+ }
135
+ if (isTemplateString(val)) {
136
+ return true;
137
+ }
140
138
  }
141
139
 
142
140
  if (def.style && typeof def.style === "object") {
143
141
  for (const val of Object.values(def.style)) {
144
- if (isTemplateString(val)) return true;
142
+ if (isTemplateString(val)) {
143
+ return true;
144
+ }
145
145
  }
146
146
  }
147
147
 
148
148
  if (def.attributes && typeof def.attributes === "object") {
149
149
  for (const val of Object.values(def.attributes)) {
150
- if (isTemplateString(val)) return true;
150
+ if (isTemplateString(val)) {
151
+ return true;
152
+ }
151
153
  }
152
154
  }
153
155
 
@@ -161,31 +163,49 @@ export function isDynamic(def: JxElement | JxMutableNode | string) {
161
163
  * @returns {boolean}
162
164
  */
163
165
  export function isNodeDynamic(def: JxElement | JxMutableNode | string) {
164
- if (!def || typeof def !== "object") return false;
166
+ if (!def || typeof def !== "object") {
167
+ return false;
168
+ }
165
169
 
166
- if (def.$switch) return true;
170
+ if (def.$switch) {
171
+ return true;
172
+ }
167
173
  if (
168
174
  !Array.isArray(def.children) &&
169
175
  (def.children as JxMappedArray | undefined)?.$prototype === "Array"
170
- )
176
+ ) {
171
177
  return true;
178
+ }
172
179
 
173
180
  for (const [key, val] of Object.entries(def)) {
174
- if (RESERVED_KEYS.has(key)) continue;
175
- if (val !== null && typeof val === "object" && typeof (val as JxMutableNode).$ref === "string")
181
+ if (RESERVED_KEYS.has(key)) {
182
+ continue;
183
+ }
184
+ if (
185
+ val !== null &&
186
+ typeof val === "object" &&
187
+ typeof (val as JxMutableNode).$ref === "string"
188
+ ) {
176
189
  return true;
177
- if (isTemplateString(val)) return true;
190
+ }
191
+ if (isTemplateString(val)) {
192
+ return true;
193
+ }
178
194
  }
179
195
 
180
196
  if (def.style && typeof def.style === "object") {
181
197
  for (const val of Object.values(def.style)) {
182
- if (isTemplateString(val)) return true;
198
+ if (isTemplateString(val)) {
199
+ return true;
200
+ }
183
201
  }
184
202
  }
185
203
 
186
204
  if (def.attributes && typeof def.attributes === "object") {
187
205
  for (const val of Object.values(def.attributes)) {
188
- if (isTemplateString(val)) return true;
206
+ if (isTemplateString(val)) {
207
+ return true;
208
+ }
189
209
  }
190
210
  }
191
211
 
@@ -199,9 +219,15 @@ export function isNodeDynamic(def: JxElement | JxMutableNode | string) {
199
219
  * @returns {boolean}
200
220
  */
201
221
  export function hasAnyIsland(def: JxElement | JxMutableNode | string): boolean {
202
- if (!def || typeof def !== "object") return false;
203
- if (isDynamic(def)) return true;
204
- if (Array.isArray(def.children)) return def.children.some((c): boolean => hasAnyIsland(c));
222
+ if (!def || typeof def !== "object") {
223
+ return false;
224
+ }
225
+ if (isDynamic(def)) {
226
+ return true;
227
+ }
228
+ if (Array.isArray(def.children)) {
229
+ return def.children.some((c): boolean => hasAnyIsland(c));
230
+ }
205
231
  return false;
206
232
  }
207
233
 
@@ -212,22 +238,24 @@ export function hasAnyIsland(def: JxElement | JxMutableNode | string): boolean {
212
238
  * @param {Record<string, unknown> | null} [parentScope]
213
239
  * @param {Record<string, unknown>} [scopeDefs]
214
240
  * @param {Record<string, string>} [media]
215
- * @returns {{
216
- * scope: Record<string, unknown>;
217
- * scopeDefs: Record<string, unknown>;
218
- * media: Record<string, string>;
219
- * }}
241
+ * @returns {CompileContext}
220
242
  */
243
+ export interface CompileContext {
244
+ scope: Record<string, unknown>;
245
+ scopeDefs: Record<string, unknown>;
246
+ media: Record<string, string>;
247
+ }
248
+
221
249
  export function createCompileContext(
222
250
  raw: JxElement | JxMutableNode | null,
223
251
  parentScope: Record<string, unknown> | null = null,
224
252
  scopeDefs: Record<string, unknown> = {},
225
253
  media: Record<string, string> = {},
226
- ) {
254
+ ): CompileContext {
227
255
  const scope = raw?.state
228
256
  ? buildInitialScope(raw.state, parentScope)
229
257
  : (parentScope ?? Object.create(null));
230
- return { scope, scopeDefs, media };
258
+ return { media, scope, scopeDefs };
231
259
  }
232
260
 
233
261
  /**
@@ -261,12 +289,13 @@ export function buildInitialScope(
261
289
  defineLazyScopeValue(scope, key, () => evaluateStaticTemplate(def, scope));
262
290
  continue;
263
291
  }
264
- if (!def || typeof def !== "object") continue;
265
- const d = def as JxStateObject & JxPrototypeDef;
266
- if ("$expression" in d) {
267
- const node = (d as Record<string, unknown>).$expression as ExpressionNode;
292
+ if (!def || typeof def !== "object") {
293
+ continue;
294
+ }
295
+ if (isExpressionDef(def)) {
296
+ const node = def.$expression as ExpressionNode;
268
297
  if (isMutating(node.operator)) {
269
- setOwnScopeValue(scope, key, (s: Record<string, any>, event: Event) =>
298
+ setOwnScopeValue(scope, key, (s: Record<string, unknown>, event: Event) =>
270
299
  evaluateExpression(node, s, event),
271
300
  );
272
301
  } else {
@@ -274,21 +303,25 @@ export function buildInitialScope(
274
303
  }
275
304
  continue;
276
305
  }
277
- if (d.$prototype === "Function") {
278
- if (d.body) {
279
- const fn = new Function("state", ...(d.parameters ?? d.arguments ?? []), d.body);
280
- if (d.body.includes("return")) {
306
+ if (isFunctionDef(def)) {
307
+ if (def.body) {
308
+ const names = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
309
+ const fn = new Function("state", ...names, def.body);
310
+ if (def.body.includes("return")) {
281
311
  defineLazyScopeValue(scope, key, () => fn(scope));
282
312
  } else {
283
313
  setOwnScopeValue(scope, key, fn);
284
314
  }
285
- } else if (!d.body?.includes("return")) {
315
+ } else {
286
316
  setOwnScopeValue(scope, key, () => {});
287
317
  }
288
318
  continue;
289
319
  }
290
- if (d.$prototype === "LocalStorage" || d.$prototype === "SessionStorage") {
291
- setOwnScopeValue(scope, key, cloneValue(d.default ?? null));
320
+ if (
321
+ isPrototypeDef(def) &&
322
+ (def.$prototype === "LocalStorage" || def.$prototype === "SessionStorage")
323
+ ) {
324
+ setOwnScopeValue(scope, key, cloneValue(def.default ?? null));
292
325
  }
293
326
  }
294
327
 
@@ -302,9 +335,9 @@ export function buildInitialScope(
302
335
  */
303
336
  export function setOwnScopeValue(scope: Record<string, unknown>, key: string, value: unknown) {
304
337
  Object.defineProperty(scope, key, {
305
- value,
306
- enumerable: true,
307
338
  configurable: true,
339
+ enumerable: true,
340
+ value,
308
341
  writable: true,
309
342
  });
310
343
  }
@@ -320,8 +353,8 @@ export function defineLazyScopeValue(
320
353
  getter: () => unknown,
321
354
  ) {
322
355
  Object.defineProperty(scope, key, {
323
- enumerable: true,
324
356
  configurable: true,
357
+ enumerable: true,
325
358
  get: getter,
326
359
  });
327
360
  }
@@ -332,8 +365,12 @@ export function defineLazyScopeValue(
332
365
  * @returns {unknown}
333
366
  */
334
367
  export function resolveStaticValue(value: unknown, scope: Record<string, unknown> | null) {
335
- if (isRefObject(value)) return resolveRefValue((value as JxMutableNode).$ref, scope!);
336
- if (isTemplateString(value)) return evaluateStaticTemplate(value as string, scope!);
368
+ if (isRefObject(value)) {
369
+ return resolveRefValue((value as JxMutableNode).$ref, scope!);
370
+ }
371
+ if (isTemplateString(value)) {
372
+ return evaluateStaticTemplate(value as string, scope!);
373
+ }
337
374
  return value;
338
375
  }
339
376
 
@@ -341,10 +378,8 @@ export function resolveStaticValue(value: unknown, scope: Record<string, unknown
341
378
  * @param {unknown} value
342
379
  * @returns {boolean}
343
380
  */
344
- export function isRefObject(value: unknown) {
345
- return (
346
- value !== null && typeof value === "object" && typeof (value as JxMutableNode).$ref === "string"
347
- );
381
+ export function isRefObject(value: unknown): value is JxRef {
382
+ return isRef(value);
348
383
  }
349
384
 
350
385
  /**
@@ -353,17 +388,21 @@ export function isRefObject(value: unknown) {
353
388
  * @returns {unknown}
354
389
  */
355
390
  export function resolveRefValue(refValue: unknown, scope: Record<string, unknown>) {
356
- if (typeof refValue !== "string") return refValue;
391
+ if (typeof refValue !== "string") {
392
+ return refValue;
393
+ }
357
394
  if (refValue.startsWith("$map/")) {
358
395
  const parts = refValue.split("/");
359
- const key = parts[1];
360
- const base = (scope.$map as Record<string, unknown> | undefined)?.[key] ?? scope["$map/" + key];
396
+ const [, key] = parts;
397
+ const base = (scope.$map as Record<string, unknown> | undefined)?.[key] ?? scope[`$map/${key}`];
361
398
  return parts.length > 2 ? getPathValue(base, parts.slice(2).join("/")) : base;
362
399
  }
363
400
  if (refValue.startsWith("#/state/")) {
364
401
  const sub = refValue.slice("#/state/".length);
365
402
  const slash = sub.indexOf("/");
366
- if (slash < 0) return scope[sub];
403
+ if (slash === -1) {
404
+ return scope[sub];
405
+ }
367
406
  return getPathValue(scope[sub.slice(0, slash)], sub.slice(slash + 1));
368
407
  }
369
408
  return scope[refValue] ?? null;
@@ -394,13 +433,14 @@ export function evaluateStaticTemplate(str: string, scope: Record<string, unknow
394
433
  * @returns {unknown}
395
434
  */
396
435
  export function getPathValue(base: unknown, path: string) {
397
- if (!path) return base;
398
- return path
399
- .split("/")
400
- .reduce<unknown>(
401
- (acc, key: string) => (acc == null ? undefined : (acc as Record<string, unknown>)[key]),
402
- base,
403
- );
436
+ if (!path) {
437
+ return base;
438
+ }
439
+ let acc: unknown = base;
440
+ for (const key of path.split("/")) {
441
+ acc = acc == null ? undefined : (acc as Record<string, unknown>)[key];
442
+ }
443
+ return acc;
404
444
  }
405
445
 
406
446
  /**
@@ -408,8 +448,10 @@ export function getPathValue(base: unknown, path: string) {
408
448
  * @returns {unknown}
409
449
  */
410
450
  export function cloneValue(value: unknown) {
411
- if (value === null || typeof value !== "object") return value;
412
- return JSON.parse(JSON.stringify(value));
451
+ if (value === null || typeof value !== "object") {
452
+ return value;
453
+ }
454
+ return structuredClone(value);
413
455
  }
414
456
 
415
457
  // ─── HTML building ────────────────────────────────────────────────────────────
@@ -431,14 +473,27 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
431
473
  const lang = resolveStaticValue(def.lang, scope);
432
474
  const dir = resolveStaticValue(def.dir, scope);
433
475
 
434
- if (id) out += ` id="${escapeHtml(String(id))}"`;
435
- if (className) out += ` class="${escapeHtml(String(className))}"`;
436
- if (hidden) out += " hidden";
437
- if (tabIndex !== undefined && tabIndex !== null)
476
+ if (id) {
477
+ out += ` id="${escapeHtml(String(id))}"`;
478
+ }
479
+ if (className) {
480
+ out += ` class="${escapeHtml(String(className))}"`;
481
+ }
482
+ if (hidden) {
483
+ out += " hidden";
484
+ }
485
+ if (tabIndex !== undefined && tabIndex !== null) {
438
486
  out += ` tabindex="${escapeHtml(String(tabIndex))}"`;
439
- if (title) out += ` title="${escapeHtml(String(title))}"`;
440
- if (lang) out += ` lang="${escapeHtml(String(lang))}"`;
441
- if (dir) out += ` dir="${escapeHtml(String(dir))}"`;
487
+ }
488
+ if (title) {
489
+ out += ` title="${escapeHtml(String(title))}"`;
490
+ }
491
+ if (lang) {
492
+ out += ` lang="${escapeHtml(String(lang))}"`;
493
+ }
494
+ if (dir) {
495
+ out += ` dir="${escapeHtml(String(dir))}"`;
496
+ }
442
497
 
443
498
  if (def.style && scope) {
444
499
  const inline = Object.entries(def.style)
@@ -460,7 +515,9 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
460
515
  })
461
516
  .filter(Boolean)
462
517
  .join("; ");
463
- if (inline) out += ` style="${inline}"`;
518
+ if (inline) {
519
+ out += ` style="${inline}"`;
520
+ }
464
521
  }
465
522
  if (def.attributes) {
466
523
  for (const [k, v] of Object.entries(def.attributes)) {
@@ -476,8 +533,12 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
476
533
  }
477
534
 
478
535
  if (def.tagName === "img") {
479
- if (!def.attributes?.loading) out += ` loading="lazy"`;
480
- if (!def.attributes?.decoding) out += ` decoding="async"`;
536
+ if (!def.attributes?.loading) {
537
+ out += ` loading="lazy"`;
538
+ }
539
+ if (!def.attributes?.decoding) {
540
+ out += ` decoding="async"`;
541
+ }
481
542
  }
482
543
 
483
544
  if (def.$static) {
@@ -499,7 +560,7 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
499
560
  * scopeDefs: Record<string, unknown>;
500
561
  * media: Record<string, string>;
501
562
  * }} context
502
- * @param {(def: any, raw: any, context: any) => string} childCompiler
563
+ * @param {(def: unknown, raw: unknown, context: unknown) => string} childCompiler
503
564
  * @returns {string}
504
565
  */
505
566
  export function buildInner(
@@ -518,8 +579,9 @@ export function buildInner(
518
579
  const value = resolveStaticValue(source.textContent, context.scope);
519
580
  return value == null ? "" : escapeHtml(String(value));
520
581
  }
521
- if (source.innerHTML)
582
+ if (source.innerHTML) {
522
583
  return (resolveStaticValue(source.innerHTML, context.scope) as string) ?? "";
584
+ }
523
585
  if (Array.isArray(source.children)) {
524
586
  const rawChildren = raw?.children;
525
587
  return source.children
@@ -550,30 +612,42 @@ export function compileStyles(
550
612
  const rules: string[] = [];
551
613
 
552
614
  // Emit project-level (site-wide) styles — CSS custom properties go on :root,
553
- // everything else on body. Project-level style is implicitly :root, so a
554
- // flat object like { "--bg": "#000", "margin": "0" } is the expected format.
615
+ // Everything else on body. Project-level style is implicitly :root, so a
616
+ // Flat object like { "--bg": "#000", "margin": "0" } is the expected format.
555
617
  if (projectStyle && typeof projectStyle === "object") {
556
- function emitProjectRules(selector: string, obj: Record<string, unknown>) {
618
+ const emitProjectRules = (selector: string, obj: Record<string, unknown>) => {
557
619
  const props = toCSSText(obj);
558
- if (props) rules.push(`${selector} { ${props} }`);
620
+ if (props) {
621
+ rules.push(`${selector} { ${props} }`);
622
+ }
559
623
  for (const [key, val] of Object.entries(obj)) {
560
- if (val === null || typeof val !== "object" || Array.isArray(val)) continue;
624
+ if (val === null || typeof val !== "object" || Array.isArray(val)) {
625
+ continue;
626
+ }
561
627
  if (key.startsWith("@")) {
562
628
  const atRule = key.startsWith("@--")
563
629
  ? `@media ${mediaQueries[key.slice(1)] ?? key.slice(1)}`
564
630
  : key;
565
631
  const mProps = toCSSText(val);
566
- if (mProps) rules.push(`${atRule} { ${selector} { ${mProps} } }`);
632
+ if (mProps) {
633
+ rules.push(`${atRule} { ${selector} { ${mProps} } }`);
634
+ }
567
635
  for (const [sel, sub] of Object.entries(/** @type {Record<string, unknown>} */ val)) {
568
- if (sub === null || typeof sub !== "object" || Array.isArray(sub)) continue;
569
- if (sel.startsWith("@")) continue;
636
+ if (sub === null || typeof sub !== "object" || Array.isArray(sub)) {
637
+ continue;
638
+ }
639
+ if (sel.startsWith("@")) {
640
+ continue;
641
+ }
570
642
  const resolved = sel.startsWith("&")
571
643
  ? sel.replace("&", selector)
572
644
  : sel.startsWith(":") || sel.startsWith(".") || sel.startsWith("[")
573
645
  ? `${selector}${sel}`
574
646
  : `${selector} ${sel}`;
575
647
  const subProps = toCSSText(sub);
576
- if (subProps) rules.push(`${atRule} { ${resolved} { ${subProps} } }`);
648
+ if (subProps) {
649
+ rules.push(`${atRule} { ${resolved} { ${subProps} } }`);
650
+ }
577
651
  }
578
652
  continue;
579
653
  }
@@ -584,7 +658,7 @@ export function compileStyles(
584
658
  : `${selector} ${key}`;
585
659
  emitProjectRules(resolved, val as Record<string, unknown>);
586
660
  }
587
- }
661
+ };
588
662
 
589
663
  for (const [key, val] of Object.entries(projectStyle)) {
590
664
  if (key.startsWith(":") || key.startsWith(".") || key.startsWith("[")) {
@@ -612,9 +686,17 @@ export function compileStyles(
612
686
  // Collect direct CSS properties into body {}
613
687
  const bodyProps: Record<string, unknown> = {};
614
688
  for (const [key, val] of Object.entries(projectStyle)) {
615
- if (key.startsWith(":") || key.startsWith(".") || key.startsWith("[") || key.startsWith("@"))
689
+ if (
690
+ key.startsWith(":") ||
691
+ key.startsWith(".") ||
692
+ key.startsWith("[") ||
693
+ key.startsWith("@")
694
+ ) {
616
695
  continue;
617
- if (val !== null && typeof val === "object" && !Array.isArray(val)) continue;
696
+ }
697
+ if (val !== null && typeof val === "object" && !Array.isArray(val)) {
698
+ continue;
699
+ }
618
700
  if (key.startsWith("--")) {
619
701
  rootProps[key] = val;
620
702
  } else {
@@ -633,7 +715,9 @@ export function compileStyles(
633
715
 
634
716
  const counter = { n: 0 };
635
717
  collectStyles(doc, rules, mediaQueries, "", counter);
636
- if (rules.length === 0) return "";
718
+ if (rules.length === 0) {
719
+ return "";
720
+ }
637
721
  return `<style>\n${rules.join("\n")}\n</style>`;
638
722
  }
639
723
 
@@ -652,9 +736,13 @@ function emitNestedElement(
652
736
  mediaQueries: Record<string, string>,
653
737
  ) {
654
738
  const props = toCSSText(obj);
655
- if (props) rules.push(`${selector} { ${props} }`);
739
+ if (props) {
740
+ rules.push(`${selector} { ${props} }`);
741
+ }
656
742
  for (const [key, val] of Object.entries(obj)) {
657
- if (val === null || typeof val !== "object" || Array.isArray(val)) continue;
743
+ if (val === null || typeof val !== "object" || Array.isArray(val)) {
744
+ continue;
745
+ }
658
746
  if (key.startsWith("@")) {
659
747
  const atRule = key.startsWith("@--")
660
748
  ? `@media ${mediaQueries[key.slice(1)] ?? key.slice(1)}`
@@ -662,17 +750,25 @@ function emitNestedElement(
662
750
  ? `@media ${key.slice(1)}`
663
751
  : key;
664
752
  const mProps = toCSSText(val);
665
- if (mProps) rules.push(`${atRule} { ${selector} { ${mProps} } }`);
753
+ if (mProps) {
754
+ rules.push(`${atRule} { ${selector} { ${mProps} } }`);
755
+ }
666
756
  for (const [sel, sub] of Object.entries(/** @type {Record<string, unknown>} */ val)) {
667
- if (sub === null || typeof sub !== "object" || Array.isArray(sub)) continue;
668
- if (sel.startsWith("@")) continue;
757
+ if (sub === null || typeof sub !== "object" || Array.isArray(sub)) {
758
+ continue;
759
+ }
760
+ if (sel.startsWith("@")) {
761
+ continue;
762
+ }
669
763
  const resolved = sel.startsWith("&")
670
764
  ? sel.replace("&", selector)
671
765
  : sel.startsWith(":") || sel.startsWith(".") || sel.startsWith("[")
672
766
  ? `${selector}${sel}`
673
767
  : `${selector} ${sel}`;
674
768
  const subProps = toCSSText(sub);
675
- if (subProps) rules.push(`${atRule} { ${resolved} { ${subProps} } }`);
769
+ if (subProps) {
770
+ rules.push(`${atRule} { ${resolved} { ${subProps} } }`);
771
+ }
676
772
  }
677
773
  continue;
678
774
  }
@@ -696,16 +792,18 @@ export function collectStyles(
696
792
  def: JxElement | JxMutableNode | string,
697
793
  rules: string[],
698
794
  mediaQueries: Record<string, string>,
699
- _parentSel: string = "",
700
- counter: { n: number } = { n: 0 },
795
+ _parentSel = "",
796
+ counterArg?: { n: number },
701
797
  prefix = "jx",
702
798
  ) {
703
- if (!def || typeof def !== "object") return;
799
+ const counter = counterArg ?? { n: 0 };
800
+ if (!def || typeof def !== "object") {
801
+ return;
802
+ }
704
803
 
705
- if (def.style) {
706
- if (!def.id && !def.className) {
707
- def.className = `${prefix}-${counter.n++}`;
708
- }
804
+ if (def.style && !def.id && !def.className) {
805
+ def.className = `${prefix}-${counter.n}`;
806
+ counter.n += 1;
709
807
  }
710
808
 
711
809
  const selector = def.id
@@ -723,10 +821,15 @@ export function collectStyles(
723
821
  prop.startsWith("&") ||
724
822
  prop.startsWith("[") ||
725
823
  prop.startsWith("@")
726
- )
824
+ ) {
825
+ continue;
826
+ }
827
+ if (value === null || typeof value === "object") {
727
828
  continue;
728
- if (value === null || typeof value === "object") continue;
729
- if (typeof value === "string" && isTemplateString(value)) continue;
829
+ }
830
+ if (typeof value === "string" && isTemplateString(value)) {
831
+ continue;
832
+ }
730
833
  baseDecls.push(` ${camelToKebab(prop)}: ${value};`);
731
834
  }
732
835
  if (baseDecls.length > 0) {
@@ -734,7 +837,9 @@ export function collectStyles(
734
837
  }
735
838
 
736
839
  for (const [prop, val] of Object.entries(def.style)) {
737
- if (val === null || typeof val !== "object" || Array.isArray(val)) continue;
840
+ if (val === null || typeof val !== "object" || Array.isArray(val)) {
841
+ continue;
842
+ }
738
843
  if (prop.startsWith("@")) {
739
844
  const atRule = prop.startsWith("@--")
740
845
  ? `@media ${mediaQueries[prop.slice(1)] ?? prop.slice(1)}`
@@ -742,13 +847,22 @@ export function collectStyles(
742
847
  ? `@media ${prop.slice(1)}`
743
848
  : prop;
744
849
  const atBaseProps = toCSSText(val);
745
- if (atBaseProps) rules.push(`${atRule} { ${selector} { ${atBaseProps} } }`);
850
+ if (atBaseProps) {
851
+ rules.push(`${atRule} { ${selector} { ${atBaseProps} } }`);
852
+ }
746
853
  for (const [sel, nestedRules] of Object.entries(
747
854
  /** @type {Record<string, unknown>} */ val,
748
855
  )) {
749
- if (nestedRules === null || typeof nestedRules !== "object" || Array.isArray(nestedRules))
856
+ if (
857
+ nestedRules === null ||
858
+ typeof nestedRules !== "object" ||
859
+ Array.isArray(nestedRules)
860
+ ) {
861
+ continue;
862
+ }
863
+ if (sel.startsWith("@")) {
750
864
  continue;
751
- if (sel.startsWith("@")) continue;
865
+ }
752
866
  const resolved = sel.startsWith("&")
753
867
  ? sel.replace("&", selector)
754
868
  : sel.startsWith(":") || sel.startsWith(".") || sel.startsWith("[")
@@ -773,9 +887,23 @@ export function collectStyles(
773
887
  }
774
888
 
775
889
  if (Array.isArray(def.children)) {
776
- def.children.forEach((c: JxElement | JxMutableNode | string) => {
890
+ for (const c of def.children) {
777
891
  collectStyles(c, rules, mediaQueries, selector, counter, prefix);
778
- });
892
+ }
893
+ }
894
+
895
+ // $switch case nodes render in place of the container's content — collect their styles too
896
+ if (def.cases && typeof def.cases === "object") {
897
+ for (const c of Object.values(def.cases)) {
898
+ collectStyles(
899
+ c as JxElement | JxMutableNode | string,
900
+ rules,
901
+ mediaQueries,
902
+ selector,
903
+ counter,
904
+ prefix,
905
+ );
906
+ }
779
907
  }
780
908
  }
781
909
 
@@ -789,11 +917,11 @@ export function collectStyles(
789
917
  */
790
918
  export function escapeHtml(str: string) {
791
919
  return String(str)
792
- .replace(/&/g, "&amp;")
793
- .replace(/</g, "&lt;")
794
- .replace(/>/g, "&gt;")
795
- .replace(/"/g, "&quot;")
796
- .replace(/'/g, "&#39;");
920
+ .replaceAll("&", "&amp;")
921
+ .replaceAll("<", "&lt;")
922
+ .replaceAll(">", "&gt;")
923
+ .replaceAll('"', "&quot;")
924
+ .replaceAll("'", "&#39;");
797
925
  }
798
926
 
799
927
  /**
@@ -805,8 +933,8 @@ export function escapeHtml(str: string) {
805
933
  export function titleToTagName(title: string) {
806
934
  const slug = title
807
935
  .toLowerCase()
808
- .replace(/[^a-z0-9]+/g, "-")
809
- .replace(/^-|-$/g, "");
936
+ .replaceAll(/[^a-z0-9]+/g, "-")
937
+ .replaceAll(/^-|-$/g, "");
810
938
  return slug.includes("-") ? slug : `jx-${slug}`;
811
939
  }
812
940
 
@@ -828,7 +956,7 @@ export function tagNameToClassName(tagName: string) {
828
956
  * @returns {string[]}
829
957
  */
830
958
  export function collectSrcImports(doc: JxElement) {
831
- const srcs: Set<string> = new Set();
959
+ const srcs = new Set<string>();
832
960
  _walkSrc(doc, srcs);
833
961
  return [...srcs];
834
962
  }
@@ -838,7 +966,9 @@ export function collectSrcImports(doc: JxElement) {
838
966
  * @param {Set<string>} srcs
839
967
  */
840
968
  function _walkSrc(def: JxElement | JxMutableNode | string, srcs: Set<string>) {
841
- if (!def || typeof def !== "object") return;
969
+ if (!def || typeof def !== "object") {
970
+ return;
971
+ }
842
972
  if (def.state) {
843
973
  for (const d of Object.values(def.state)) {
844
974
  if (
@@ -852,7 +982,9 @@ function _walkSrc(def: JxElement | JxMutableNode | string, srcs: Set<string>) {
852
982
  }
853
983
  }
854
984
  if (Array.isArray(def.children)) {
855
- def.children.forEach((c: JxElement | JxMutableNode | string) => _walkSrc(c, srcs));
985
+ for (const c of def.children) {
986
+ _walkSrc(c, srcs);
987
+ }
856
988
  }
857
989
  }
858
990
 
@@ -877,34 +1009,26 @@ function _walkServerEntries(
877
1009
  def: JxElement | JxMutableNode | string,
878
1010
  entries: Map<string, { key: string; exportName: string; src: string }>,
879
1011
  ) {
880
- if (!def || typeof def !== "object") return;
1012
+ if (!def || typeof def !== "object") {
1013
+ return;
1014
+ }
881
1015
  if (def.state) {
882
1016
  for (const [key, d] of Object.entries(def.state)) {
883
- const entry = d as JxMutableNode;
884
- if (
885
- entry &&
886
- typeof entry === "object" &&
887
- entry.timing === "server" &&
888
- entry.$src &&
889
- entry.$export &&
890
- !entry.$prototype
891
- ) {
892
- entries.set(entry.$export, {
893
- key,
894
- exportName: entry.$export,
895
- src: entry.$src,
896
- });
1017
+ if (isServerFnDef(d)) {
1018
+ entries.set(d.$export, { exportName: d.$export, key, src: d.$src });
897
1019
  }
898
1020
  }
899
1021
  }
900
1022
  if (Array.isArray(def.children)) {
901
- def.children.forEach((c: JxElement | JxMutableNode | string) => _walkServerEntries(c, entries));
1023
+ for (const c of def.children) {
1024
+ _walkServerEntries(c, entries);
1025
+ }
902
1026
  }
903
1027
  }
904
1028
 
905
1029
  // ─── Component pre-rendering ─────────────────────────────────────────────────
906
1030
 
907
- const SELF_CLOSING: Set<string> = new Set([
1031
+ const SELF_CLOSING = new Set<string>([
908
1032
  "input",
909
1033
  "br",
910
1034
  "hr",
@@ -936,26 +1060,55 @@ export function renderStaticNode(
936
1060
  }
937
1061
  return escapeHtml(node);
938
1062
  }
939
- if (typeof node === "number" || typeof node === "boolean") return escapeHtml(String(node));
940
- if (Array.isArray(node))
1063
+ if (typeof node === "number" || typeof node === "boolean") {
1064
+ return escapeHtml(String(node));
1065
+ }
1066
+ if (Array.isArray(node)) {
941
1067
  return (node as (JxElement | JxMutableNode | string)[])
942
1068
  .map((c: JxElement | JxMutableNode | string): string =>
943
1069
  renderStaticNode(c, scope, slotContent),
944
1070
  )
945
1071
  .join("\n");
946
- if (!node || typeof node !== "object") return "";
1072
+ }
1073
+ if (!node || typeof node !== "object") {
1074
+ return "";
1075
+ }
947
1076
 
948
1077
  // Skip mapped arrays — can't pre-render dynamic lists
949
- if (node.$prototype === "Array") return "";
1078
+ if (node.$prototype === "Array") {
1079
+ return "";
1080
+ }
1081
+
1082
+ // $switch/cases — resolve the key statically and render the matched case inside a container
1083
+ // Element (mirrors the runtime's renderSwitch DOM shape). External $ref cases can't be fetched
1084
+ // At compile time, so they render the empty container.
1085
+ if (node.$switch) {
1086
+ const switchTag = node.tagName ?? "div";
1087
+ const attrs = buildAttrs(node, scope);
1088
+ const key = resolveStaticValue(node.$switch, scope);
1089
+ const caseDef =
1090
+ key == null
1091
+ ? undefined
1092
+ : (node.cases as Record<string, JxElement | string> | undefined)?.[String(key)];
1093
+ const inner =
1094
+ caseDef !== undefined && !isRefObject(caseDef)
1095
+ ? renderStaticNode(caseDef, scope, slotContent)
1096
+ : "";
1097
+ return `<${switchTag}${attrs}>${inner}</${switchTag}>`;
1098
+ }
950
1099
 
951
1100
  const tag = node.tagName ?? "div";
952
1101
 
953
1102
  // Replace <slot> with provided slot content
954
- if (tag === "slot" && slotContent != null) return slotContent;
1103
+ if (tag === "slot" && slotContent != null) {
1104
+ return slotContent;
1105
+ }
955
1106
 
956
1107
  const attrs = buildAttrs(node, scope);
957
1108
 
958
- if (SELF_CLOSING.has(tag)) return `<${tag}${attrs}>`;
1109
+ if (SELF_CLOSING.has(tag)) {
1110
+ return `<${tag}${attrs}>`;
1111
+ }
959
1112
 
960
1113
  let inner = "";
961
1114
  if (node.textContent !== undefined) {
@@ -977,14 +1130,14 @@ export function renderStaticNode(
977
1130
  * Pre-render a component definition to static HTML for its inner content.
978
1131
  *
979
1132
  * @param {JxElement} doc - Component JSON definition
980
- * @param {Record<string, unknown> | null} [propsOverride] - Instance-specific prop values to merge
981
- * into state
1133
+ * @param {Record<string, JsonValue> | null} [propsOverride] - Instance-specific prop values to
1134
+ * merge into state
982
1135
  * @param {string | null} [slotContent] - HTML to substitute for `<slot>` elements
983
1136
  * @returns {string} The pre-rendered innerHTML
984
1137
  */
985
1138
  export function preRenderComponentHtml(
986
1139
  doc: JxElement,
987
- propsOverride: Record<string, unknown> | null = null,
1140
+ propsOverride: Record<string, JsonValue> | null = null,
988
1141
  slotContent: string | null = null,
989
1142
  ) {
990
1143
  let stateDefs: Record<string, JxStateDefinition> = doc.state ?? {};
@@ -993,26 +1146,22 @@ export function preRenderComponentHtml(
993
1146
  for (const [key, value] of Object.entries(propsOverride)) {
994
1147
  if (key in stateDefs) {
995
1148
  const existing = stateDefs[key];
996
- if (
1149
+ stateDefs[key] =
997
1150
  existing &&
998
1151
  typeof existing === "object" &&
999
1152
  !Array.isArray(existing) &&
1000
1153
  "default" in existing
1001
- ) {
1002
- stateDefs[key] = {
1003
- .../** @type {JxStateObject} */ existing,
1004
- default: value,
1005
- };
1006
- } else {
1007
- stateDefs[key] = value as JxStateDefinition;
1008
- }
1154
+ ? { .../** @type {JxStateObject} */ existing, default: value }
1155
+ : (value as JxStateDefinition);
1009
1156
  } else {
1010
1157
  stateDefs[key] = value as JxStateDefinition;
1011
1158
  }
1012
1159
  }
1013
1160
  }
1014
1161
  const scope = buildInitialScope(stateDefs, null);
1015
- if (!Array.isArray(doc.children)) return "";
1162
+ if (!Array.isArray(doc.children)) {
1163
+ return "";
1164
+ }
1016
1165
  return doc.children
1017
1166
  .map((c: JxElement | JxMutableNode | string) => renderStaticNode(c, scope, slotContent))
1018
1167
  .join("\n");
@@ -1035,36 +1184,53 @@ export function isComponentFullyStatic(doc: JxElement) {
1035
1184
  * @param {JxElement | string | (JxElement | string)[]} node
1036
1185
  * @returns {boolean}
1037
1186
  */
1038
- function _isStaticNode(node: JxElement | string | (JxElement | string)[]) {
1039
- if (!node || typeof node !== "object") return true;
1040
- if (Array.isArray(node)) return node.every(_isStaticNode);
1187
+ function _isStaticNode(node: JxElement | string | (JxElement | string)[]): boolean {
1188
+ if (!node || typeof node !== "object") {
1189
+ return true;
1190
+ }
1191
+ if (Array.isArray(node)) {
1192
+ return node.every((n) => _isStaticNode(n));
1193
+ }
1041
1194
 
1042
1195
  // Check for $prototype (Functions, Request, Storage, etc.)
1043
- if (node.$prototype) return false;
1196
+ if (node.$prototype) {
1197
+ return false;
1198
+ }
1044
1199
  // Check for $ref
1045
- if (node.$ref) return false;
1200
+ if (node.$ref) {
1201
+ return false;
1202
+ }
1046
1203
 
1047
1204
  // Check state entries
1048
1205
  if (node.state) {
1049
1206
  for (const def of Object.values(node.state)) {
1050
- if (!def || typeof def !== "object") continue;
1207
+ if (!def || typeof def !== "object") {
1208
+ continue;
1209
+ }
1051
1210
  const d = def as JxMutableNode;
1052
- if (d.$prototype) return false;
1053
- if (d.$ref) return false;
1211
+ if (d.$prototype) {
1212
+ return false;
1213
+ }
1214
+ if (d.$ref) {
1215
+ return false;
1216
+ }
1054
1217
  }
1055
1218
  }
1056
1219
 
1057
1220
  // Check for event handlers
1058
1221
  for (const key of Object.keys(node)) {
1059
- if (key.startsWith("on") && key !== "observedAttributes") return false;
1222
+ if (key.startsWith("on") && key !== "observedAttributes") {
1223
+ return false;
1224
+ }
1060
1225
  }
1061
1226
 
1062
1227
  // Recurse into children
1063
1228
  if (Array.isArray(node.children)) {
1064
- if (!node.children.every(_isStaticNode)) return false;
1065
- } else if (node.children && typeof node.children === "object") {
1066
- // children descriptor object ($prototype: "Array", etc.)
1067
- if (node.children.$prototype) return false;
1229
+ return node.children.every((c) => _isStaticNode(c));
1230
+ }
1231
+ // Children descriptor object ($prototype: "Array", etc.)
1232
+ if (node.children && typeof node.children === "object" && node.children.$prototype) {
1233
+ return false;
1068
1234
  }
1069
1235
 
1070
1236
  return true;
@@ -1082,7 +1248,7 @@ function _isStaticNode(node: JxElement | string | (JxElement | string)[]) {
1082
1248
  */
1083
1249
  export function buildComponentCSS(
1084
1250
  tagName: string,
1085
- styleDef: JxStyle | null | undefined,
1251
+ styleDef?: JxStyle | null | undefined,
1086
1252
  doc: JxElement | null = null,
1087
1253
  mediaQueries: Record<string, string> = {},
1088
1254
  ) {
@@ -1097,10 +1263,15 @@ export function buildComponentCSS(
1097
1263
  prop.startsWith("&") ||
1098
1264
  prop.startsWith("[") ||
1099
1265
  prop.startsWith("@")
1100
- )
1266
+ ) {
1101
1267
  continue;
1102
- if (value === null || typeof value === "object") continue;
1103
- if (typeof value === "string" && isTemplateString(value)) continue;
1268
+ }
1269
+ if (value === null || typeof value === "object") {
1270
+ continue;
1271
+ }
1272
+ if (typeof value === "string" && isTemplateString(value)) {
1273
+ continue;
1274
+ }
1104
1275
  decls.push(` ${camelToKebab(prop)}: ${value};`);
1105
1276
  }
1106
1277
  if (decls.length > 0) {
@@ -1134,5 +1305,5 @@ export function buildComponentCSS(
1134
1305
  }
1135
1306
  }
1136
1307
 
1137
- return rules.length > 0 ? rules.join("\n") + "\n" : "";
1308
+ return rules.length > 0 ? `${rules.join("\n")}\n` : "";
1138
1309
  }