@jxsuite/compiler 0.35.0 → 1.1.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.
@@ -23,13 +23,16 @@ import { basename, dirname, join, resolve } from "node:path";
23
23
  import { isMappedArray, isRef } from "@jxsuite/schema/guards";
24
24
  import { loadProjectConfig } from "./site-loader.ts";
25
25
  import { discoverPages, expandDynamicRoutes, readPageDocument } from "./pages-discovery.ts";
26
- import { buildProjectFormatRegistry } from "./format-host.ts";
27
- import type { FormatRegistry } from "@jxsuite/schema/format-registry";
26
+ import { buildProjectExtensionRegistry } from "./format-host.ts";
27
+ import { loadProjectSections } from "./project-sections.ts";
28
+ import type { FormatEntry, FormatRegistry } from "@jxsuite/schema/format-registry";
29
+ import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
28
30
  import { resolveLayout } from "./layout-resolver.ts";
29
31
  import { mergeHead, renderHead } from "./head-merger.ts";
30
32
  import { injectContext } from "./context-injection.ts";
31
33
  import { compile, compileServer, compileSiteServer } from "../compiler.ts";
32
34
  import { compileElement } from "../targets/compile-element.ts";
35
+ import type { SiteConnectorSpec, SiteMountSpec } from "../targets/compile-server.ts";
33
36
  import {
34
37
  DEFAULT_LIT_HTML_SRC,
35
38
  DEFAULT_REACTIVITY_SRC,
@@ -45,7 +48,6 @@ import {
45
48
  resolveRefValue,
46
49
  resolveStaticValue,
47
50
  } from "../shared.ts";
48
- import { loadContentConfig, loadContentTypes, resolveContentTypeRefs } from "./content-loader.ts";
49
51
  import { resolvePrototypes } from "./prototype-resolver.ts";
50
52
  import { transformImageNodes } from "./image-transform.ts";
51
53
  import { getImageCacheDir, loadCache, saveCache } from "./image-cache.ts";
@@ -62,7 +64,6 @@ import type {
62
64
  JxStyle,
63
65
  ProjectConfig,
64
66
  } from "@jxsuite/schema/types";
65
- import type { ContentLoaderEntry } from "@jxsuite/parser/types";
66
67
  import type { SiteRoute } from "../types.ts";
67
68
  import type { CacheManifest } from "./image-cache.js";
68
69
 
@@ -90,13 +91,24 @@ export async function buildSite(
90
91
  log("Loading project.json...");
91
92
  const { config: projectConfig } = loadProjectConfig(projectRoot);
92
93
 
94
+ // The legacy imports-based content model is gone (specs/extensions.md v2) — fail loudly with
95
+ // The migration path instead of silently ignoring the section.
96
+ if ("contentTypes" in projectConfig) {
97
+ throw new Error(
98
+ `project.json declares "contentTypes", which is no longer supported. ` +
99
+ `Run \`bun scripts/migrate-project-extensions.ts <project>\` to migrate to ` +
100
+ `"extensions" + "content", then \`jx schema\`.`,
101
+ );
102
+ }
103
+
93
104
  const outDir = resolve(projectRoot, projectConfig.build.outDir);
94
105
  const pagesDir = resolve(projectRoot, "pages");
95
106
  const publicDir = resolve(projectRoot, "public");
96
107
  const trailingSlash = projectConfig.build.trailingSlash ?? "always";
97
108
 
98
- // ── 1b. Build the format registry from project imports ─────────────────
99
- const formatRegistry = await buildProjectFormatRegistry(projectRoot, projectConfig);
109
+ // ── 1b. Build the extension registry from project.json#/extensions ─────
110
+ const registry = await buildProjectExtensionRegistry(projectRoot, projectConfig);
111
+ const formatRegistry = registry.formats;
100
112
  if (formatRegistry.entries.length > 0) {
101
113
  log(
102
114
  ` Registered ${formatRegistry.entries.length} format(s): ${formatRegistry.entries
@@ -105,6 +117,28 @@ export async function buildSite(
105
117
  );
106
118
  }
107
119
 
120
+ // ── 1c. Active extension server mounts (specs/extensions.md §11) ────────
121
+ // A mount is active when its class owns no project section, or the project declares a
122
+ // Non-empty value for its section key. Sites with active mounts always get a worker; a
123
+ // Static site cannot serve dynamic sections, so that combination is a build error.
124
+ const activeMounts = registry.serverMounts().filter((entry) => {
125
+ if (!entry.project) {
126
+ return true;
127
+ }
128
+ const value = projectConfig[entry.project.key] as Record<string, unknown> | undefined;
129
+ return value !== undefined && typeof value === "object" && Object.keys(value).length > 0;
130
+ });
131
+ if (activeMounts.length > 0 && !projectConfig.build.adapter) {
132
+ const sections = activeMounts
133
+ .map((entry) => (entry.project ? `"${entry.project.key}"` : `"${entry.server!.basePath}"`))
134
+ .join(", ");
135
+ throw new Error(
136
+ `project.json declares dynamic section(s) ${sections} served by extension mounts, but ` +
137
+ `build.adapter is "static". Dynamic tables need a server-capable adapter — set ` +
138
+ `build.adapter to "cloudflare-workers", "cloudflare-pages", "node", or "bun".`,
139
+ );
140
+ }
141
+
108
142
  // ── 2. Clean output directory ───────────────────────────────────────────
109
143
  if (clean && existsSync(outDir)) {
110
144
  rmSync(outDir, { force: true, recursive: true });
@@ -120,20 +154,22 @@ export async function buildSite(
120
154
  const staticRoutes = await discoverPages(pagesDir, formatRegistry);
121
155
  log(` Found ${staticRoutes.length} page(s)`);
122
156
 
123
- // ── 3b. Load content types ─────────────────────────────────────────────
124
- log("Loading content types...");
125
- const contentTypes = await loadContentTypes(projectRoot, projectConfig, formatRegistry);
126
- if (contentTypes.size > 0) {
127
- log(` Loaded ${contentTypes.size} content type(s): ${[...contentTypes.keys()].join(", ")}`);
128
- // Resolve cross-content-type $ref references
129
- const contentConfig = loadContentConfig(projectRoot, projectConfig);
130
- if (contentConfig) {
131
- resolveContentTypeRefs(contentTypes, contentConfig.config);
132
- }
157
+ // ── 3b. Load extension-contributed project sections ────────────────────
158
+ log("Loading project sections...");
159
+ const sections = await loadProjectSections(projectRoot, projectConfig, registry);
160
+ const sectionKeys = Object.keys(sections);
161
+ if (sectionKeys.length > 0) {
162
+ log(` Loaded ${sectionKeys.length} section(s): ${sectionKeys.join(", ")}`);
133
163
  }
134
164
 
135
165
  // ── 4. Expand dynamic routes ────────────────────────────────────────────
136
- const routes = await expandDynamicRoutes(staticRoutes, projectRoot, contentTypes, formatRegistry);
166
+ const routes = await expandDynamicRoutes(
167
+ staticRoutes,
168
+ projectRoot,
169
+ sections,
170
+ registry,
171
+ projectConfig,
172
+ );
137
173
  log(` ${routes.length} route(s) after expansion`);
138
174
 
139
175
  let fileCount = 0;
@@ -235,11 +271,12 @@ export async function buildSite(
235
271
  route as unknown as SiteRoute,
236
272
  projectConfig,
237
273
  projectRoot,
238
- contentTypes,
274
+ sections,
239
275
  imageCache,
240
276
  componentDefs,
241
277
  imageMetaCache,
242
278
  formatRegistry,
279
+ registry,
243
280
  );
244
281
 
245
282
  // Determine which component tags are fully static (for script omission)
@@ -330,7 +367,9 @@ export async function buildSite(
330
367
 
331
368
  // ── 6b. Save image cache and copy variants to dist ──────────────────────
332
369
  if (imageCache && projectConfig.images.optimize) {
333
- saveCache(projectRoot, imageCache);
370
+ // Prune only on fully successful builds — a route that failed to compile never
371
+ // Touched its images, and pruning would evict their still-valid entries.
372
+ saveCache(projectRoot, imageCache, { prune: errors.length === 0 });
334
373
  const cacheOptimizedDir = resolve(getImageCacheDir(projectRoot), "_optimized");
335
374
  if (existsSync(cacheOptimizedDir)) {
336
375
  const distOptimizedDir = resolve(outDir, "images/_optimized");
@@ -355,11 +394,22 @@ export async function buildSite(
355
394
  }
356
395
  }
357
396
 
397
+ // Extension server mounts: static imports of the mount modules and used connector classes,
398
+ // Inlined JSON options (the project's section manifest — identifiers only, never secrets).
399
+ const { mounts, connectors } = buildMountSpecs(activeMounts, registry, projectConfig);
400
+ if (mounts.length > 0) {
401
+ log(
402
+ ` Mounting ${mounts.length} extension route(s): ${mounts.map((m) => m.basePath).join(", ")}`,
403
+ );
404
+ }
405
+
358
406
  // Cloudflare Pages uses advanced mode (_worker.js inside the build output) — the
359
407
  // Functions/ directory convention only works from the project root, not from dist/.
360
- // A static-only Pages site needs no worker at all.
361
- const skipWorker = adapter === "cloudflare-pages" && deduped.size === 0;
362
- const workerSource = skipWorker ? null : compileSiteServer([...deduped.values()], { adapter });
408
+ // A static-only Pages site needs no worker at all; sites with mounts always get one.
409
+ const skipWorker = adapter === "cloudflare-pages" && deduped.size === 0 && mounts.length === 0;
410
+ const workerSource = skipWorker
411
+ ? null
412
+ : compileSiteServer([...deduped.values()], { adapter, connectors, mounts });
363
413
 
364
414
  if (workerSource) {
365
415
  const workerName = adapter === "cloudflare-pages" ? "_worker.js" : "worker.js";
@@ -393,7 +443,8 @@ export async function buildSite(
393
443
  // ── 7. Generate redirects ───────────────────────────────────────────────
394
444
  if (projectConfig.redirects && Object.keys(projectConfig.redirects).length > 0) {
395
445
  log("Generating redirects...");
396
- const redirectFiles = generateRedirects(projectConfig.redirects, outDir);
446
+ const compiledUrls = new Set(routes.map((r) => r.urlPattern));
447
+ const redirectFiles = generateRedirects(projectConfig.redirects, outDir, compiledUrls);
397
448
  fileCount += redirectFiles;
398
449
  }
399
450
 
@@ -445,7 +496,7 @@ export async function buildSite(
445
496
  * @param {SiteRoute} route
446
497
  * @param {ProjectConfig} projectConfig
447
498
  * @param {string} projectRoot
448
- * @param {Map<string, ContentLoaderEntry[]>} [contentTypes]
499
+ * @param {Record<string, unknown>} [sections] - Extension-loaded project sections
449
500
  * @param {import("./image-cache.js").CacheManifest | null} [imageCache]
450
501
  * @param {Map<string, JxElement>} [componentDefs]
451
502
  * @param {ImageMetaCache | null} [imageMetaCache] - Set when images.service is "cloudflare"
@@ -460,11 +511,12 @@ async function compilePage(
460
511
  route: SiteRoute,
461
512
  projectConfig: ProjectConfig,
462
513
  projectRoot: string,
463
- contentTypes = new Map<string, ContentLoaderEntry[]>(),
514
+ sections: Record<string, unknown> = {},
464
515
  imageCache: CacheManifest | null = null,
465
516
  componentDefs = new Map<string, JxElement>(),
466
517
  imageMetaCache: ImageMetaCache | null = null,
467
518
  formatRegistry?: FormatRegistry,
519
+ registry?: ExtensionRegistry,
468
520
  ) {
469
521
  // Load the raw page document (.json natively, other formats via the registry)
470
522
  const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
@@ -482,12 +534,13 @@ async function compilePage(
482
534
  delete layoutDoc._pageTitle;
483
535
 
484
536
  // Inject $site and $page context
485
- injectContext(layoutDoc, projectConfig, route, contentTypes, projectRoot);
537
+ injectContext(layoutDoc, projectConfig, route, projectRoot);
486
538
 
487
- // Resolve generic $prototype entries via .class.json imports
539
+ // Resolve generic $prototype entries via .class.json imports (and lower registry classes)
488
540
  await resolvePrototypes(layoutDoc, route, projectRoot, {
489
541
  config: projectConfig,
490
- contentTypes,
542
+ sections,
543
+ ...(registry === undefined ? {} : { registry }),
491
544
  });
492
545
 
493
546
  // Build scope from resolved state so template strings in title/$head can be evaluated
@@ -617,6 +670,92 @@ async function compilePage(
617
670
  };
618
671
  }
619
672
 
673
+ /**
674
+ * Build the mount + connector specs for the generated worker (specs/extensions.md §11–§12).
675
+ *
676
+ * Mount options inline the extension-contributed sections from project.json — identifiers only,
677
+ * never secrets (§13). Connector classes are included when any section entry names their
678
+ * `connector.provider`; the import specifier comes from the descriptor's `connector.module` (bare
679
+ * specifier — Workers cannot import filesystem paths).
680
+ *
681
+ * @param {FormatEntry[]} activeMounts - Server-mount classes with active sections
682
+ * @param {ExtensionRegistry} registry
683
+ * @param {ProjectConfig} projectConfig
684
+ * @returns {{ mounts: SiteMountSpec[]; connectors: SiteConnectorSpec[] }}
685
+ */
686
+ function buildMountSpecs(
687
+ activeMounts: FormatEntry[],
688
+ registry: ExtensionRegistry,
689
+ projectConfig: ProjectConfig,
690
+ ): { mounts: SiteMountSpec[]; connectors: SiteConnectorSpec[] } {
691
+ if (activeMounts.length === 0) {
692
+ return { connectors: [], mounts: [] };
693
+ }
694
+
695
+ const sections: Record<string, unknown> = {};
696
+ for (const contribution of registry.projectContributions()) {
697
+ const { key } = contribution.project!;
698
+ if (key in projectConfig) {
699
+ sections[key] = projectConfig[key];
700
+ }
701
+ }
702
+
703
+ const mounts: SiteMountSpec[] = activeMounts.map((entry) => {
704
+ const server = entry.server!;
705
+ const { module } = server;
706
+ if (typeof module !== "string" || module === "") {
707
+ throw new Error(
708
+ `Extension class "${entry.name}": server.module (a bare import specifier) is required ` +
709
+ `to generate a deployable worker`,
710
+ );
711
+ }
712
+ return {
713
+ basePath: server.basePath,
714
+ className: (entry.classDef.title as string | undefined) ?? entry.name,
715
+ module,
716
+ options: { basePath: server.basePath, sections },
717
+ order: server.order ?? 100,
718
+ };
719
+ });
720
+
721
+ // Used providers: any section entry object carrying a string `provider` field (the connector
722
+ // Vocabulary, §12) marks that provider's class for import.
723
+ const usedProviders = new Set<string>();
724
+ for (const value of Object.values(sections)) {
725
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
726
+ continue;
727
+ }
728
+ for (const sectionEntry of Object.values(value as Record<string, unknown>)) {
729
+ const provider = (sectionEntry as { provider?: unknown } | null)?.provider;
730
+ if (typeof provider === "string") {
731
+ usedProviders.add(provider);
732
+ }
733
+ }
734
+ }
735
+
736
+ const connectors: SiteConnectorSpec[] = [];
737
+ for (const entry of registry.connectors()) {
738
+ const provider = String(entry.connector!.provider);
739
+ if (!usedProviders.has(provider)) {
740
+ continue;
741
+ }
742
+ const { module } = entry.connector!;
743
+ if (typeof module !== "string" || module === "") {
744
+ throw new Error(
745
+ `Connector class "${entry.name}": connector.module (a bare import specifier) is ` +
746
+ `required to generate a deployable worker`,
747
+ );
748
+ }
749
+ connectors.push({
750
+ className: (entry.classDef.title as string | undefined) ?? entry.name,
751
+ module,
752
+ provider,
753
+ });
754
+ }
755
+
756
+ return { connectors, mounts };
757
+ }
758
+
620
759
  /**
621
760
  * Resolve template strings in $head entries against the compiled scope.
622
761
  *
@@ -1179,14 +1318,18 @@ function routeToOutputPath(urlPattern: string, outDir: string, trailingSlash: st
1179
1318
  *
1180
1319
  * @param {Record<string, string | { destination: string; status?: number }>} redirects
1181
1320
  * @param {string} outDir
1321
+ * @param {Set<string>} [compiledUrls] - URL patterns of compiled routes, for conflict warnings
1182
1322
  * @returns {number} Number of files written
1183
1323
  */
1184
1324
  function generateRedirects(
1185
1325
  redirects: Record<string, string | { destination: string; status?: number }>,
1186
1326
  outDir: string,
1327
+ compiledUrls = new Set<string>(),
1187
1328
  ) {
1188
1329
  let count = 0;
1189
1330
  const redirectLines: string[] = [];
1331
+ const normalizeUrl = (u: string) => (u.length > 1 && u.endsWith("/") ? u.slice(0, -1) : u);
1332
+ const compiled = new Set([...compiledUrls].map((u) => normalizeUrl(u)));
1190
1333
 
1191
1334
  for (const [source, target] of Object.entries(redirects)) {
1192
1335
  const dest = typeof target === "object" ? target.destination : target;
@@ -1198,6 +1341,13 @@ function generateRedirects(
1198
1341
  continue;
1199
1342
  }
1200
1343
 
1344
+ if (compiled.has(normalizeUrl(source))) {
1345
+ console.warn(
1346
+ `Redirect "${source}" overwrites a compiled page at the same route — ` +
1347
+ `remove the redirect or the page.`,
1348
+ );
1349
+ }
1350
+
1201
1351
  // Static redirect — emit an HTML file with meta refresh
1202
1352
  const htmlPath = routeToOutputPath(source, outDir, "always");
1203
1353
  const html = `<!DOCTYPE html>
@@ -39,7 +39,6 @@ const DEFAULTS = {
39
39
  provider: null,
40
40
  trailingSlash: "always",
41
41
  },
42
- contentTypes: {},
43
42
  defaults: {
44
43
  charset: "utf8",
45
44
  lang: "en",
@@ -83,7 +82,9 @@ export function loadProjectConfig(projectRoot: string) {
83
82
  throw new Error(`Invalid project.json: ${errorMessage(error)}`, { cause: error });
84
83
  }
85
84
 
86
- // Deep merge with defaults
85
+ // Deep merge with defaults. The `...raw` spread carries every author-declared key through
86
+ // Untouched — including `extensions` and extension-contributed sections (`content`, ...), which
87
+ // Are opaque to the loader (specs/extensions.md §3.1).
87
88
  const config: ResolvedProjectConfig = {
88
89
  ...DEFAULTS,
89
90
  ...raw,
@@ -111,9 +112,6 @@ export function loadProjectConfig(projectRoot: string) {
111
112
  if (raw.imports) {
112
113
  config.imports = raw.imports;
113
114
  }
114
- if (raw.contentTypes) {
115
- config.contentTypes = raw.contentTypes;
116
- }
117
115
 
118
116
  // Validate adapter
119
117
  const VALID_ADAPTERS = ["cloudflare-workers", "cloudflare-pages", "node", "bun"];
@@ -17,8 +17,10 @@ import {
17
17
  DEFAULT_REACTIVITY_SRC,
18
18
  buildAttrs,
19
19
  compileExpression,
20
+ compileStatements,
20
21
  compileStyles,
21
22
  createCompileContext,
23
+ emitFormulaFn,
22
24
  escapeHtml,
23
25
  isMutating,
24
26
  isRefObject,
@@ -27,10 +29,12 @@ import {
27
29
  resolveStaticValue,
28
30
  } from "../shared.ts";
29
31
  import {
32
+ hasStructuredBody,
30
33
  isExpandedSignal,
31
34
  isExpressionDef,
32
35
  isFunctionDef,
33
36
  isMappedArray,
37
+ isNamedFormulaDef,
34
38
  isPrototypeDef,
35
39
  paramNames,
36
40
  } from "@jxsuite/schema/guards";
@@ -124,7 +128,11 @@ export function compileClient(
124
128
  eventParam: "e",
125
129
  statePrefix: "state",
126
130
  });
127
- if (isMutating(node.operator)) {
131
+ if (isNamedFormulaDef(def)) {
132
+ // Named formula → a scope callable mapping positional args onto declared parameters.
133
+ // Call sites stay positional, matching the interpreter.
134
+ initBlocks.push(`state[${JSON.stringify(key)}] = ${emitFormulaFn(def, compiled)};`);
135
+ } else if (isMutating(node.operator)) {
128
136
  onEntries.push([key, { args: ["state", "e"], body: compiled }]);
129
137
  } else {
130
138
  computedEntries.push([key, `() => ${compiled}`]);
@@ -135,7 +143,18 @@ export function compileClient(
135
143
  // $prototype: "Function"
136
144
  if (isFunctionDef(def)) {
137
145
  const args = def.parameters ? paramNames(def.parameters) : def.arguments;
138
- if (def.$src) {
146
+ if (hasStructuredBody(def)) {
147
+ // Structured body (spec §20) → an event handler compiled from statements.
148
+ // With parameters, a positional callable like a named formula.
149
+ const compiled = compileStatements(def.body, { eventParam: "e", statePrefix: "state" });
150
+ if (def.parameters && def.parameters.length > 0) {
151
+ initBlocks.push(
152
+ `state[${JSON.stringify(key)}] = ${emitFormulaFn(def, `(() => {\n${compiled}\n})()`)};`,
153
+ );
154
+ } else {
155
+ onEntries.push([key, { args: ["state", "e"], body: compiled }]);
156
+ }
157
+ } else if (def.$src) {
139
158
  if (!srcImportMap.has(def.$src)) {
140
159
  srcImportMap.set(def.$src, new Set());
141
160
  }
@@ -143,12 +162,12 @@ export function compileClient(
143
162
 
144
163
  // $src functions always produce computed entries (they return values)
145
164
  computedEntries.push([key, `() => { return ${key}(state); }`]);
146
- } else if (def.body && def.body.includes("return")) {
165
+ } else if (typeof def.body === "string" && def.body.includes("return")) {
147
166
  // Body contains return → computed
148
167
  computedEntries.push([key, `() => { ${def.body} }`]);
149
168
  } else {
150
169
  // No return → event handler
151
- onEntries.push([key, { args: args ?? ["state"], body: def.body }]);
170
+ onEntries.push([key, { args: args ?? ["state"], body: def.body as string | undefined }]);
152
171
  }
153
172
  continue;
154
173
  }
@@ -335,13 +354,22 @@ function buildClientNode(
335
354
  const key = refToBindingKey(val.$ref);
336
355
  bindAttrs.push(`@${eventName}="${key}"`);
337
356
  needsBind = true;
357
+ } else if (hasStructuredBody(val)) {
358
+ const key = `_h${counter.h}`;
359
+ counter.h += 1;
360
+ bindAttrs.push(`@${eventName}="${key}"`);
361
+ handlers.set(key, {
362
+ args: ["state", "e"],
363
+ body: compileStatements(val.body, { eventParam: "e", statePrefix: "state" }),
364
+ });
365
+ needsBind = true;
338
366
  } else if (isFunctionDef(val)) {
339
367
  const key = `_h${counter.h}`;
340
368
  counter.h += 1;
341
369
  bindAttrs.push(`@${eventName}="${key}"`);
342
370
  handlers.set(key, {
343
371
  args: val.parameters ? paramNames(val.parameters) : (val.arguments ?? ["state", "event"]),
344
- body: val.body,
372
+ body: val.body as string | undefined,
345
373
  });
346
374
  needsBind = true;
347
375
  } else if (isExpressionDef(val)) {
@@ -594,6 +622,9 @@ function emitLitMapTemplate(def: JxMutableNode | undefined) {
594
622
  if (isRefObject(val)) {
595
623
  const key = refToBindingKey((val as JxMutableNode).$ref as string);
596
624
  attrs += ` @${eventName}=\${(e) => { state.$map = { item, index }; on.${key}(e); }}`;
625
+ } else if (hasStructuredBody(val)) {
626
+ const compiled = compileStatements(val.body, { eventParam: "e", statePrefix: "state" });
627
+ attrs += ` @${eventName}=\${(e) => { state.$map = { item, index }; ${compiled} }}`;
597
628
  } else if (val && typeof val === "object" && (val as JxMutableNode).$prototype === "Function") {
598
629
  const body = mapRefsToLit((val as JxMutableNode).body as string);
599
630
  attrs += ` @${eventName}=\${(e) => { ${body} }}`;
@@ -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";
@@ -284,11 +288,14 @@ export function emitElementModule(doc: JxDocument, className: string, elementImp
284
288
  const computedEntries: [string, JxExpressionDef | JxFunctionDef][] = [];
285
289
  const functionEntries: [string, JxExpressionDef | JxFunctionDef][] = [];
286
290
 
291
+ const formulaEntries: [string, JxExpressionDef][] = [];
287
292
  for (const [key, def] of Object.entries(defs)) {
288
293
  const d = def as JxMutableNode;
289
294
  if (isExpressionDef(d)) {
290
295
  const node = d.$expression as ExpressionNode;
291
- if (isMutating(node.operator)) {
296
+ if (isNamedFormulaDef(d)) {
297
+ formulaEntries.push([key, d]);
298
+ } else if (isMutating(node.operator)) {
292
299
  functionEntries.push([key, d]);
293
300
  } else {
294
301
  computedEntries.push([key, d]);
@@ -324,6 +331,16 @@ export function emitElementModule(doc: JxDocument, className: string, elementImp
324
331
  statePrefix: "s",
325
332
  });
326
333
  lines.push(` this.state.${key} = (s, e) => { ${compiled}; };`);
334
+ } else if (hasStructuredBody(def)) {
335
+ // Structured body (spec §20) — statements compiled against this.state; dispatch target is
336
+ // The component instance itself (WHATWG dispatchEvent on the custom element).
337
+ const compiled = compileStatements(def.body, {
338
+ dispatchTarget: "this",
339
+ eventParam: "e",
340
+ indent: " ",
341
+ statePrefix: "this.state",
342
+ });
343
+ lines.push(` this.state.${key} = (s, e) => {`, compiled, " };");
327
344
  } else {
328
345
  const args = def.parameters ? paramNames(def.parameters) : (def.arguments ?? ["state"]);
329
346
  const paramList = args.join(", ");
@@ -333,13 +350,23 @@ export function emitElementModule(doc: JxDocument, className: string, elementImp
333
350
  } else {
334
351
  lines.push(
335
352
  ` this.state.${key} = (${paramList}) => {`,
336
- ` ${def.body ?? ""}`,
353
+ ` ${typeof def.body === "string" ? def.body : ""}`,
337
354
  " };",
338
355
  );
339
356
  }
340
357
  }
341
358
  }
342
359
 
360
+ // Emit named formulas — scope callables mapping positional args onto declared parameters
361
+ for (const [key, def] of formulaEntries) {
362
+ lines.push("");
363
+ const compiled = compileExpression(def.$expression as ExpressionNode, {
364
+ eventParam: "e",
365
+ statePrefix: "this.state",
366
+ });
367
+ lines.push(` this.state.${key} = ${emitFormulaFn(def, compiled)};`);
368
+ }
369
+
343
370
  // Emit computed signals — $src or inline body
344
371
  for (const [key, def] of computedEntries) {
345
372
  lines.push("");
@@ -353,7 +380,10 @@ export function emitElementModule(doc: JxDocument, className: string, elementImp
353
380
  lines.push(` this.state.${key} = computed(() => ${key}(this.state));`);
354
381
  } else {
355
382
  lines.push(` this.state.${key} = computed(() => {`);
356
- const body = (def.body ?? "").replaceAll("state.", "this.state.");
383
+ const body = (typeof def.body === "string" ? def.body : "").replaceAll(
384
+ "state.",
385
+ "this.state.",
386
+ );
357
387
  lines.push(` ${body}`, " });");
358
388
  }
359
389
  }
@@ -765,7 +795,14 @@ function toLitTextContent(value: unknown) {
765
795
  * @returns {string}
766
796
  */
767
797
  function inlineHandlerBody(def: JxFunctionDef) {
768
- const body = def.body ?? "";
798
+ if (hasStructuredBody(def)) {
799
+ return compileStatements(def.body, {
800
+ dispatchTarget: "e.currentTarget",
801
+ eventParam: "e",
802
+ statePrefix: "s",
803
+ });
804
+ }
805
+ const body = typeof def.body === "string" ? def.body : "";
769
806
  return body.replaceAll(/(?<!this\.)state\./g, "s.").replaceAll(/(?<!this\.)state(?!\.)/g, "s");
770
807
  }
771
808