@jxsuite/compiler 1.0.0 → 1.2.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.
@@ -9,7 +9,6 @@
9
9
  */
10
10
 
11
11
  import {
12
- copyFileSync,
13
12
  cpSync,
14
13
  existsSync,
15
14
  mkdirSync,
@@ -19,8 +18,16 @@ import {
19
18
  statSync,
20
19
  writeFileSync,
21
20
  } from "node:fs";
22
- import { basename, dirname, join, resolve } from "node:path";
21
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
23
22
  import { isMappedArray, isRef } from "@jxsuite/schema/guards";
23
+ import { isNpmSpecifier, sidecarAssetPath } from "@jxsuite/schema/asset-paths";
24
+ import {
25
+ CLIENT_EXTERNALS,
26
+ bundleEntry,
27
+ bundleWorkerSource,
28
+ isBundleableSrc,
29
+ resolveSidecarEntry,
30
+ } from "./bundler.ts";
24
31
  import { loadProjectConfig } from "./site-loader.ts";
25
32
  import { discoverPages, expandDynamicRoutes, readPageDocument } from "./pages-discovery.ts";
26
33
  import { buildProjectExtensionRegistry } from "./format-host.ts";
@@ -40,10 +47,12 @@ import {
40
47
  buildInitialScope,
41
48
  collectServerEntries,
42
49
  collectStyles,
50
+ colorSchemePrePaintScript,
43
51
  evaluateStaticTemplate,
44
52
  isComponentFullyStatic,
45
53
  isTemplateString,
46
54
  preRenderComponentHtml,
55
+ pureSchemeOf,
47
56
  renderStaticNode,
48
57
  resolveRefValue,
49
58
  resolveStaticValue,
@@ -106,6 +115,43 @@ export async function buildSite(
106
115
  const publicDir = resolve(projectRoot, "public");
107
116
  const trailingSlash = projectConfig.build.trailingSlash ?? "always";
108
117
 
118
+ // Client sidecar bundles: bundleable Function-def `$src` specifiers (and lowered-def `$bundle`
119
+ // Hints) are rewritten to their /assets/ URL during compilation, registered here, and bundled
120
+ // In step 6d (spec.md §12). Keyed by asset path so duplicate specifiers bundle once.
121
+ const sidecarBundles = new Map<string, { entryPath: string; specifier: string }>();
122
+ const rewriteSidecarSrc = (specifier: string, docDir: string | null): string => {
123
+ if (!isBundleableSrc(specifier)) {
124
+ return specifier;
125
+ }
126
+ try {
127
+ const entryPath = resolveSidecarEntry(specifier, docDir ?? projectRoot, projectRoot);
128
+ // A declared-but-absent relative sidecar keeps its verbatim import (the historical
129
+ // Behavior) with a warning — the doc may only ever run interpreted. Unresolvable npm
130
+ // Specifiers stay hard errors: a missing dependency should fail the build.
131
+ if (!isNpmSpecifier(specifier) && !existsSync(entryPath)) {
132
+ console.warn(`Sidecar "${specifier}" not found at ${entryPath} — emitting unbundled`);
133
+ return specifier;
134
+ }
135
+ // Relative specifiers key on their project-relative resolved path so `./x.js` declared in
136
+ // Two directories cannot collide on one slug; npm specifiers are location-independent.
137
+ const assetKey = isNpmSpecifier(specifier)
138
+ ? specifier
139
+ : `./${relative(projectRoot, entryPath)}`;
140
+ const assetPath = sidecarAssetPath(assetKey);
141
+ const existing = sidecarBundles.get(assetPath);
142
+ if (existing && existing.entryPath !== entryPath) {
143
+ throw new Error(
144
+ `"${specifier}" and "${existing.specifier}" both bundle to ${assetPath} — rename one`,
145
+ );
146
+ }
147
+ sidecarBundles.set(assetPath, { entryPath, specifier });
148
+ return assetPath;
149
+ } catch (error) {
150
+ errors.push(`Sidecar "${specifier}": ${(error as Error).message}`);
151
+ return specifier;
152
+ }
153
+ };
154
+
109
155
  // ── 1b. Build the extension registry from project.json#/extensions ─────
110
156
  const registry = await buildProjectExtensionRegistry(projectRoot, projectConfig);
111
157
  const formatRegistry = registry.formats;
@@ -194,6 +240,7 @@ export async function buildSite(
194
240
  const result = await compileElement(componentPath, {
195
241
  ...(projectConfig.$media ? { $media: projectConfig.$media } : {}),
196
242
  formats: formatRegistry,
243
+ rewriteSrc: rewriteSidecarSrc,
197
244
  });
198
245
  for (const f of result.files) {
199
246
  // Reduce the emitted path (an absolute source path with .json swapped to .js) to a bare
@@ -277,6 +324,7 @@ export async function buildSite(
277
324
  imageMetaCache,
278
325
  formatRegistry,
279
326
  registry,
327
+ rewriteSidecarSrc,
280
328
  );
281
329
 
282
330
  // Determine which component tags are fully static (for script omission)
@@ -367,7 +415,9 @@ export async function buildSite(
367
415
 
368
416
  // ── 6b. Save image cache and copy variants to dist ──────────────────────
369
417
  if (imageCache && projectConfig.images.optimize) {
370
- saveCache(projectRoot, imageCache);
418
+ // Prune only on fully successful builds — a route that failed to compile never
419
+ // Touched its images, and pruning would evict their still-valid entries.
420
+ saveCache(projectRoot, imageCache, { prune: errors.length === 0 });
371
421
  const cacheOptimizedDir = resolve(getImageCacheDir(projectRoot), "_optimized");
372
422
  if (existsSync(cacheOptimizedDir)) {
373
423
  const distOptimizedDir = resolve(outDir, "images/_optimized");
@@ -410,10 +460,23 @@ export async function buildSite(
410
460
  : compileSiteServer([...deduped.values()], { adapter, connectors, mounts });
411
461
 
412
462
  if (workerSource) {
463
+ // Bundle the worker self-contained for the adapter's runtime (compiler.md §12): mount
464
+ // Modules, connectors, hono, and user server functions are inlined, so dist deploys
465
+ // Without node_modules or deploy-time bundling.
413
466
  const workerName = adapter === "cloudflare-pages" ? "_worker.js" : "worker.js";
414
- writeFileSync(resolve(outDir, workerName), workerSource, "utf8");
415
- fileCount += 1;
416
- log(` Generated dist/${workerName} (${deduped.size} server function(s))`);
467
+ try {
468
+ await bundleWorkerSource(workerSource, {
469
+ adapter,
470
+ outfile: resolve(outDir, workerName),
471
+ projectRoot,
472
+ });
473
+ fileCount += 1;
474
+ log(` Generated dist/${workerName} (bundled, ${deduped.size} server function(s))`);
475
+ } catch (error) {
476
+ const msg = `Error bundling dist/${workerName}: ${(error as Error).message}`;
477
+ errors.push(msg);
478
+ console.error(msg);
479
+ }
417
480
 
418
481
  if (adapter === "cloudflare-pages") {
419
482
  // Only invoke the worker for server routes; everything else stays static.
@@ -424,24 +487,76 @@ export async function buildSite(
424
487
  );
425
488
  fileCount += 1;
426
489
  }
490
+ }
491
+ }
427
492
 
428
- // Copy server source files into dist/components/ so worker imports resolve
429
- const distComponentsDir = resolve(outDir, "components");
430
- mkdirSync(distComponentsDir, { recursive: true });
431
- for (const { src } of deduped.values()) {
432
- const srcFile = resolve(projectRoot, src.replace(/^\.\//, ""));
433
- const destFile = resolve(distComponentsDir, src.replace(/^\.\/components\//, ""));
434
- if (existsSync(srcFile)) {
435
- copyFileSync(srcFile, destFile);
493
+ // ── 6d. Bundle client sidecar modules (spec.md §12) ─────────────────────
494
+ if (sidecarBundles.size > 0) {
495
+ log(`Bundling ${sidecarBundles.size} client sidecar module(s)...`);
496
+ for (const [assetPath, bundle] of sidecarBundles) {
497
+ try {
498
+ const outfile = resolve(outDir, assetPath.replace(/^\//, ""));
499
+ mkdirSync(dirname(outfile), { recursive: true });
500
+ await bundleEntry(
501
+ { entryPath: bundle.entryPath, outfile },
502
+ { external: CLIENT_EXTERNALS, target: "browser" },
503
+ );
504
+ fileCount += 1;
505
+ log(` ${bundle.specifier} → ${assetPath}`);
506
+ } catch (error) {
507
+ const msg = `Error bundling sidecar "${bundle.specifier}": ${(error as Error).message}`;
508
+ errors.push(msg);
509
+ console.error(msg);
510
+ }
511
+ }
512
+ }
513
+
514
+ // ── 6e. Extension-emitted assets (extensions.md §8.4) ───────────────────
515
+ // Section-owner classes with an `emit` capability contribute derived build artifacts
516
+ // (search indexes, feeds, …). The host writes the returned files so extensions stay pure;
517
+ // Paths are outDir-relative and guarded against traversal. Note the `public/` copy (7c)
518
+ // Runs later and can shadow emitted files — same semantics as sitemap.xml.
519
+ for (const entry of registry.emitters()) {
520
+ const sectionKey = entry.project?.key;
521
+ const sectionValue = sectionKey
522
+ ? (projectConfig as unknown as Record<string, unknown>)[sectionKey]
523
+ : undefined;
524
+ if (
525
+ sectionKey &&
526
+ (sectionValue == null ||
527
+ (typeof sectionValue === "object" && Object.keys(sectionValue).length === 0))
528
+ ) {
529
+ continue; // Section owner with nothing declared — same gating as sections/mounts
530
+ }
531
+ try {
532
+ const emitted = (await entry.call("emit", sectionValue ?? null, {
533
+ projectConfig,
534
+ root: projectRoot,
535
+ routes,
536
+ sections,
537
+ })) as { path: string; content: string | Uint8Array }[] | null;
538
+ for (const file of emitted ?? []) {
539
+ const target = resolve(outDir, file.path.replace(/^\//, ""));
540
+ if (!target.startsWith(outDir + sep)) {
541
+ throw new Error(`emit path escapes outDir: ${file.path}`);
436
542
  }
543
+ mkdirSync(dirname(target), { recursive: true });
544
+ writeFileSync(target, file.content);
545
+ fileCount += 1;
546
+ log(` ${entry.name} emitted ${file.path}`);
437
547
  }
548
+ } catch (error) {
549
+ const msg = `Error in ${entry.name}.emit: ${(error as Error).message}`;
550
+ errors.push(msg);
551
+ console.error(msg);
438
552
  }
439
553
  }
440
554
 
441
555
  // ── 7. Generate redirects ───────────────────────────────────────────────
442
556
  if (projectConfig.redirects && Object.keys(projectConfig.redirects).length > 0) {
443
557
  log("Generating redirects...");
444
- const redirectFiles = generateRedirects(projectConfig.redirects, outDir);
558
+ const compiledUrls = new Set(routes.map((r) => r.urlPattern));
559
+ const redirectFiles = generateRedirects(projectConfig.redirects, outDir, compiledUrls);
445
560
  fileCount += redirectFiles;
446
561
  }
447
562
 
@@ -514,6 +629,7 @@ async function compilePage(
514
629
  imageMetaCache: ImageMetaCache | null = null,
515
630
  formatRegistry?: FormatRegistry,
516
631
  registry?: ExtensionRegistry,
632
+ rewriteSidecarSrc?: (specifier: string, docDir: string | null) => string,
517
633
  ) {
518
634
  // Load the raw page document (.json natively, other formats via the registry)
519
635
  const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
@@ -538,6 +654,13 @@ async function compilePage(
538
654
  config: projectConfig,
539
655
  sections,
540
656
  ...(registry === undefined ? {} : { registry }),
657
+ ...(rewriteSidecarSrc === undefined
658
+ ? {}
659
+ : {
660
+ registerBundle: (specifier: string) => {
661
+ rewriteSidecarSrc(specifier, null);
662
+ },
663
+ }),
541
664
  });
542
665
 
543
666
  // Build scope from resolved state so template strings in title/$head can be evaluated
@@ -606,6 +729,13 @@ async function compilePage(
606
729
  layoutDoc.$media = { ...projectConfig.$media, ...layoutDoc.$media };
607
730
  }
608
731
 
732
+ // Declaring a pure color-scheme query opts the page into the forced-scheme contract: restore
733
+ // The visitor's persisted scheme before first paint (spec §9.5). Injected after the
734
+ // Charset/viewport defaults and ahead of every preserved <style> block.
735
+ if (Object.values(layoutDoc.$media ?? {}).some((q) => pureSchemeOf(String(q)) !== null)) {
736
+ mergedHead.splice(2, 0, { tagName: "script", textContent: colorSchemePrePaintScript() });
737
+ }
738
+
609
739
  // Transform <img> nodes for responsive image optimization
610
740
  if (projectConfig.images?.optimize && (imageCache || imageMetaCache)) {
611
741
  await transformImageNodes(
@@ -620,7 +750,14 @@ async function compilePage(
620
750
  // Compile the document using the existing compiler
621
751
  const result = await compile(layoutDoc, {
622
752
  lang: projectConfig.defaults?.lang ?? "en",
753
+ prePaintScheme: false, // Injected via the merged <head> above, not the target template
623
754
  projectStyle: projectConfig.style ?? null,
755
+ ...(rewriteSidecarSrc === undefined
756
+ ? {}
757
+ : {
758
+ rewriteSrc: (specifier: string) =>
759
+ rewriteSidecarSrc(specifier, dirname(route.sourcePath as string)),
760
+ }),
624
761
  title,
625
762
  });
626
763
 
@@ -1315,14 +1452,18 @@ function routeToOutputPath(urlPattern: string, outDir: string, trailingSlash: st
1315
1452
  *
1316
1453
  * @param {Record<string, string | { destination: string; status?: number }>} redirects
1317
1454
  * @param {string} outDir
1455
+ * @param {Set<string>} [compiledUrls] - URL patterns of compiled routes, for conflict warnings
1318
1456
  * @returns {number} Number of files written
1319
1457
  */
1320
1458
  function generateRedirects(
1321
1459
  redirects: Record<string, string | { destination: string; status?: number }>,
1322
1460
  outDir: string,
1461
+ compiledUrls = new Set<string>(),
1323
1462
  ) {
1324
1463
  let count = 0;
1325
1464
  const redirectLines: string[] = [];
1465
+ const normalizeUrl = (u: string) => (u.length > 1 && u.endsWith("/") ? u.slice(0, -1) : u);
1466
+ const compiled = new Set([...compiledUrls].map((u) => normalizeUrl(u)));
1326
1467
 
1327
1468
  for (const [source, target] of Object.entries(redirects)) {
1328
1469
  const dest = typeof target === "object" ? target.destination : target;
@@ -1334,6 +1475,13 @@ function generateRedirects(
1334
1475
  continue;
1335
1476
  }
1336
1477
 
1478
+ if (compiled.has(normalizeUrl(source))) {
1479
+ console.warn(
1480
+ `Redirect "${source}" overwrites a compiled page at the same route — ` +
1481
+ `remove the redirect or the page.`,
1482
+ );
1483
+ }
1484
+
1337
1485
  // Static redirect — emit an HTML file with meta refresh
1338
1486
  const htmlPath = routeToOutputPath(source, outDir, "always");
1339
1487
  const html = `<!DOCTYPE html>
@@ -16,21 +16,27 @@ import {
16
16
  DEFAULT_LIT_HTML_SRC,
17
17
  DEFAULT_REACTIVITY_SRC,
18
18
  buildAttrs,
19
+ colorSchemePrePaintScript,
19
20
  compileExpression,
21
+ compileStatements,
20
22
  compileStyles,
21
23
  createCompileContext,
24
+ emitFormulaFn,
22
25
  escapeHtml,
23
26
  isMutating,
24
27
  isRefObject,
25
28
  isSchemaOnly,
26
29
  isTemplateString,
30
+ pureSchemeOf,
27
31
  resolveStaticValue,
28
32
  } from "../shared.ts";
29
33
  import {
34
+ hasStructuredBody,
30
35
  isExpandedSignal,
31
36
  isExpressionDef,
32
37
  isFunctionDef,
33
38
  isMappedArray,
39
+ isNamedFormulaDef,
34
40
  isPrototypeDef,
35
41
  paramNames,
36
42
  } from "@jxsuite/schema/guards";
@@ -72,6 +78,7 @@ export function compileClient(
72
78
  litHtmlSrc?: string;
73
79
  modulePath?: string;
74
80
  projectStyle?: JxStyle | null;
81
+ prePaintScheme?: boolean;
75
82
  [key: string]: unknown;
76
83
  },
77
84
  ) {
@@ -124,7 +131,11 @@ export function compileClient(
124
131
  eventParam: "e",
125
132
  statePrefix: "state",
126
133
  });
127
- if (isMutating(node.operator)) {
134
+ if (isNamedFormulaDef(def)) {
135
+ // Named formula → a scope callable mapping positional args onto declared parameters.
136
+ // Call sites stay positional, matching the interpreter.
137
+ initBlocks.push(`state[${JSON.stringify(key)}] = ${emitFormulaFn(def, compiled)};`);
138
+ } else if (isMutating(node.operator)) {
128
139
  onEntries.push([key, { args: ["state", "e"], body: compiled }]);
129
140
  } else {
130
141
  computedEntries.push([key, `() => ${compiled}`]);
@@ -135,7 +146,18 @@ export function compileClient(
135
146
  // $prototype: "Function"
136
147
  if (isFunctionDef(def)) {
137
148
  const args = def.parameters ? paramNames(def.parameters) : def.arguments;
138
- if (def.$src) {
149
+ if (hasStructuredBody(def)) {
150
+ // Structured body (spec §20) → an event handler compiled from statements.
151
+ // With parameters, a positional callable like a named formula.
152
+ const compiled = compileStatements(def.body, { eventParam: "e", statePrefix: "state" });
153
+ if (def.parameters && def.parameters.length > 0) {
154
+ initBlocks.push(
155
+ `state[${JSON.stringify(key)}] = ${emitFormulaFn(def, `(() => {\n${compiled}\n})()`)};`,
156
+ );
157
+ } else {
158
+ onEntries.push([key, { args: ["state", "e"], body: compiled }]);
159
+ }
160
+ } else if (def.$src) {
139
161
  if (!srcImportMap.has(def.$src)) {
140
162
  srcImportMap.set(def.$src, new Set());
141
163
  }
@@ -143,12 +165,12 @@ export function compileClient(
143
165
 
144
166
  // $src functions always produce computed entries (they return values)
145
167
  computedEntries.push([key, `() => { return ${key}(state); }`]);
146
- } else if (def.body && def.body.includes("return")) {
168
+ } else if (typeof def.body === "string" && def.body.includes("return")) {
147
169
  // Body contains return → computed
148
170
  computedEntries.push([key, `() => { ${def.body} }`]);
149
171
  } else {
150
172
  // No return → event handler
151
- onEntries.push([key, { args: args ?? ["state"], body: def.body }]);
173
+ onEntries.push([key, { args: args ?? ["state"], body: def.body as string | undefined }]);
152
174
  }
153
175
  continue;
154
176
  }
@@ -219,6 +241,7 @@ export function compileClient(
219
241
  srcImportMap,
220
242
  counter,
221
243
  reactivitySrc,
244
+ opts.rewriteSrc as ((specifier: string) => string) | undefined,
222
245
  );
223
246
 
224
247
  // Build importmap entries — always include lit-html since compiled custom elements need it
@@ -227,13 +250,20 @@ export function compileClient(
227
250
  ` "lit-html": "${litHtmlSrc}"`,
228
251
  ];
229
252
 
253
+ // Forced-scheme contract (spec §9.5): restore the persisted scheme before any styles apply
254
+ const prePaint =
255
+ opts.prePaintScheme !== false &&
256
+ Object.values(raw.$media ?? {}).some((q) => pureSchemeOf(String(q)) !== null)
257
+ ? `<script>${colorSchemePrePaintScript()}</script>\n `
258
+ : "";
259
+
230
260
  const html = `<!DOCTYPE html>
231
261
  <html lang="en">
232
262
  <head>
233
263
  <meta charset="utf-8">
234
264
  <meta name="viewport" content="width=device-width, initial-scale=1">
235
265
  <title>${escapeHtml(title)}</title>
236
- <script type="importmap">
266
+ ${prePaint}<script type="importmap">
237
267
  {
238
268
  "imports": {
239
269
  ${importmapEntries.join(",\n")}
@@ -335,13 +365,22 @@ function buildClientNode(
335
365
  const key = refToBindingKey(val.$ref);
336
366
  bindAttrs.push(`@${eventName}="${key}"`);
337
367
  needsBind = true;
368
+ } else if (hasStructuredBody(val)) {
369
+ const key = `_h${counter.h}`;
370
+ counter.h += 1;
371
+ bindAttrs.push(`@${eventName}="${key}"`);
372
+ handlers.set(key, {
373
+ args: ["state", "e"],
374
+ body: compileStatements(val.body, { eventParam: "e", statePrefix: "state" }),
375
+ });
376
+ needsBind = true;
338
377
  } else if (isFunctionDef(val)) {
339
378
  const key = `_h${counter.h}`;
340
379
  counter.h += 1;
341
380
  bindAttrs.push(`@${eventName}="${key}"`);
342
381
  handlers.set(key, {
343
382
  args: val.parameters ? paramNames(val.parameters) : (val.arguments ?? ["state", "event"]),
344
- body: val.body,
383
+ body: val.body as string | undefined,
345
384
  });
346
385
  needsBind = true;
347
386
  } else if (isExpressionDef(val)) {
@@ -594,6 +633,9 @@ function emitLitMapTemplate(def: JxMutableNode | undefined) {
594
633
  if (isRefObject(val)) {
595
634
  const key = refToBindingKey((val as JxMutableNode).$ref as string);
596
635
  attrs += ` @${eventName}=\${(e) => { state.$map = { item, index }; on.${key}(e); }}`;
636
+ } else if (hasStructuredBody(val)) {
637
+ const compiled = compileStatements(val.body, { eventParam: "e", statePrefix: "state" });
638
+ attrs += ` @${eventName}=\${(e) => { state.$map = { item, index }; ${compiled} }}`;
597
639
  } else if (val && typeof val === "object" && (val as JxMutableNode).$prototype === "Function") {
598
640
  const body = mapRefsToLit((val as JxMutableNode).body as string);
599
641
  attrs += ` @${eventName}=\${(e) => { ${body} }}`;
@@ -726,6 +768,7 @@ function emitClientModule(
726
768
  needsLit: boolean;
727
769
  },
728
770
  _reactivitySrc: string,
771
+ rewriteSrc?: (specifier: string) => string,
729
772
  ) {
730
773
  const lines: string[] = [];
731
774
  const { needsLit } = counter;
@@ -745,9 +788,11 @@ function emitClientModule(
745
788
  lines.push("import { html, render } from 'lit-html';");
746
789
  }
747
790
 
748
- // $src imports
791
+ // $src imports — bundleable specifiers (npm:…, ./relative) are rewritten to their /assets/
792
+ // Bundle URL; the site build bundles them after all documents compile (spec.md §12).
749
793
  for (const [src, names] of srcImportMap) {
750
- lines.push(`import { ${[...names].join(", ")} } from '${src}';`);
794
+ const importPath = rewriteSrc ? rewriteSrc(src) : src;
795
+ lines.push(`import { ${[...names].join(", ")} } from '${importPath}';`);
751
796
  }
752
797
 
753
798
  // State — reactive state
@@ -9,15 +9,19 @@ import { RESERVED_KEYS, camelToKebab } from "@jxsuite/runtime";
9
9
  import {
10
10
  collectStyles,
11
11
  compileExpression,
12
+ compileStatements,
13
+ emitFormulaFn,
12
14
  escapeHtml,
13
15
  isMutating,
14
16
  isSchemaOnly,
15
17
  tagNameToClassName,
16
18
  } from "../shared.ts";
17
19
  import {
20
+ hasStructuredBody,
18
21
  isExpressionDef,
19
22
  isFunctionDef,
20
23
  isMappedArray,
24
+ isNamedFormulaDef,
21
25
  isRef,
22
26
  paramNames,
23
27
  } from "@jxsuite/schema/guards";
@@ -37,6 +41,12 @@ import type {
37
41
  export interface CompileElementOptions {
38
42
  /** Resolve an `$elements` ref path to its emitted JS module path. */
39
43
  resolveElementPath?: (refPath: string, currentDir: string | null) => string;
44
+ /**
45
+ * Rewrite a Function-def `$src` specifier to its served URL (bundleable specifiers → their
46
+ * `/assets/` bundle path). Receives the directory of the declaring document for relative
47
+ * specifiers. Identity when omitted.
48
+ */
49
+ rewriteSrc?: (specifier: string, docDir: string | null) => string;
40
50
  $media?: Record<string, string>;
41
51
  formats?: FormatRegistry;
42
52
  basePath?: string | null;
@@ -131,7 +141,13 @@ export async function compileElement(
131
141
  if (optsMedia) {
132
142
  doc.$media = { ...optsMedia, ...doc.$media };
133
143
  }
134
- const jsContent = emitElementModule(doc, className, elementImports);
144
+ const { rewriteSrc } = opts;
145
+ const jsContent = emitElementModule(
146
+ doc,
147
+ className,
148
+ elementImports,
149
+ rewriteSrc ? (spec: string) => rewriteSrc(spec, currentDir) : undefined,
150
+ );
135
151
  const outputPath = filePath ? filePath.replace(/\.json$/, ".js") : `${tagName}.js`;
136
152
  files.push({ content: jsContent, path: outputPath, tagName });
137
153
  }
@@ -240,7 +256,12 @@ function extractInitialValue(def: JxMutableNode) {
240
256
  * @param {string[]} elementImports
241
257
  * @returns {string}
242
258
  */
243
- export function emitElementModule(doc: JxDocument, className: string, elementImports: string[]) {
259
+ export function emitElementModule(
260
+ doc: JxDocument,
261
+ className: string,
262
+ elementImports: string[],
263
+ rewriteSrc?: (specifier: string) => string,
264
+ ) {
244
265
  const lines: string[] = ["// Generated by @jxsuite/compiler — do not edit manually"];
245
266
 
246
267
  if (doc.$id) {
@@ -265,7 +286,10 @@ export function emitElementModule(doc: JxDocument, className: string, elementImp
265
286
  }
266
287
  }
267
288
  for (const [srcPath, names] of srcImportMap) {
268
- lines.push(`import { ${names.join(", ")} } from '${srcPath}';`);
289
+ // Bundleable specifiers (npm:…, ./relative) are rewritten to their /assets/ bundle URL;
290
+ // The site build bundles them after all documents compile (spec.md §12).
291
+ const importPath = rewriteSrc ? rewriteSrc(srcPath) : srcPath;
292
+ lines.push(`import { ${names.join(", ")} } from '${importPath}';`);
269
293
  }
270
294
 
271
295
  lines.push(
@@ -284,11 +308,14 @@ export function emitElementModule(doc: JxDocument, className: string, elementImp
284
308
  const computedEntries: [string, JxExpressionDef | JxFunctionDef][] = [];
285
309
  const functionEntries: [string, JxExpressionDef | JxFunctionDef][] = [];
286
310
 
311
+ const formulaEntries: [string, JxExpressionDef][] = [];
287
312
  for (const [key, def] of Object.entries(defs)) {
288
313
  const d = def as JxMutableNode;
289
314
  if (isExpressionDef(d)) {
290
315
  const node = d.$expression as ExpressionNode;
291
- if (isMutating(node.operator)) {
316
+ if (isNamedFormulaDef(d)) {
317
+ formulaEntries.push([key, d]);
318
+ } else if (isMutating(node.operator)) {
292
319
  functionEntries.push([key, d]);
293
320
  } else {
294
321
  computedEntries.push([key, d]);
@@ -324,6 +351,16 @@ export function emitElementModule(doc: JxDocument, className: string, elementImp
324
351
  statePrefix: "s",
325
352
  });
326
353
  lines.push(` this.state.${key} = (s, e) => { ${compiled}; };`);
354
+ } else if (hasStructuredBody(def)) {
355
+ // Structured body (spec §20) — statements compiled against this.state; dispatch target is
356
+ // The component instance itself (WHATWG dispatchEvent on the custom element).
357
+ const compiled = compileStatements(def.body, {
358
+ dispatchTarget: "this",
359
+ eventParam: "e",
360
+ indent: " ",
361
+ statePrefix: "this.state",
362
+ });
363
+ lines.push(` this.state.${key} = (s, e) => {`, compiled, " };");
327
364
  } else {
328
365
  const args = def.parameters ? paramNames(def.parameters) : (def.arguments ?? ["state"]);
329
366
  const paramList = args.join(", ");
@@ -333,13 +370,23 @@ export function emitElementModule(doc: JxDocument, className: string, elementImp
333
370
  } else {
334
371
  lines.push(
335
372
  ` this.state.${key} = (${paramList}) => {`,
336
- ` ${def.body ?? ""}`,
373
+ ` ${typeof def.body === "string" ? def.body : ""}`,
337
374
  " };",
338
375
  );
339
376
  }
340
377
  }
341
378
  }
342
379
 
380
+ // Emit named formulas — scope callables mapping positional args onto declared parameters
381
+ for (const [key, def] of formulaEntries) {
382
+ lines.push("");
383
+ const compiled = compileExpression(def.$expression as ExpressionNode, {
384
+ eventParam: "e",
385
+ statePrefix: "this.state",
386
+ });
387
+ lines.push(` this.state.${key} = ${emitFormulaFn(def, compiled)};`);
388
+ }
389
+
343
390
  // Emit computed signals — $src or inline body
344
391
  for (const [key, def] of computedEntries) {
345
392
  lines.push("");
@@ -353,7 +400,10 @@ export function emitElementModule(doc: JxDocument, className: string, elementImp
353
400
  lines.push(` this.state.${key} = computed(() => ${key}(this.state));`);
354
401
  } else {
355
402
  lines.push(` this.state.${key} = computed(() => {`);
356
- const body = (def.body ?? "").replaceAll("state.", "this.state.");
403
+ const body = (typeof def.body === "string" ? def.body : "").replaceAll(
404
+ "state.",
405
+ "this.state.",
406
+ );
357
407
  lines.push(` ${body}`, " });");
358
408
  }
359
409
  }
@@ -456,11 +506,16 @@ export function emitElementModule(doc: JxDocument, className: string, elementImp
456
506
  );
457
507
  }
458
508
  lines.push(
509
+ // Lifecycle: onMount (spec §16.4) — same microtask contract as the runtime
510
+ " if (typeof this.state.onMount === 'function') {",
511
+ " queueMicrotask(() => this.state.onMount(this.state));",
512
+ " }",
459
513
  " }",
460
514
  "",
461
515
  // DisconnectedCallback
462
516
  " disconnectedCallback() {",
463
517
  " if (this.#dispose) { this.#dispose(); this.#dispose = null; }",
518
+ " if (typeof this.state.onUnmount === 'function') { this.state.onUnmount(this.state); }",
464
519
  " }",
465
520
  "}",
466
521
  "",
@@ -765,7 +820,14 @@ function toLitTextContent(value: unknown) {
765
820
  * @returns {string}
766
821
  */
767
822
  function inlineHandlerBody(def: JxFunctionDef) {
768
- const body = def.body ?? "";
823
+ if (hasStructuredBody(def)) {
824
+ return compileStatements(def.body, {
825
+ dispatchTarget: "e.currentTarget",
826
+ eventParam: "e",
827
+ statePrefix: "s",
828
+ });
829
+ }
830
+ const body = typeof def.body === "string" ? def.body : "";
769
831
  return body.replaceAll(/(?<!this\.)state\./g, "s.").replaceAll(/(?<!this\.)state(?!\.)/g, "s");
770
832
  }
771
833