@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.
- package/dist/cli.js +191 -164
- package/dist/compiler.js +121 -114
- package/package.json +11 -7
- package/src/cli.ts +132 -9
- package/src/shared.ts +53 -4
- package/src/site/context-injection.ts +0 -6
- package/src/site/db-push.ts +178 -0
- package/src/site/dev-command.ts +84 -0
- package/src/site/format-host.ts +128 -13
- package/src/site/head-merger.ts +13 -0
- package/src/site/image-cache.ts +57 -6
- package/src/site/image-transform.ts +21 -0
- package/src/site/pages-discovery.ts +61 -49
- package/src/site/preview-server.ts +93 -0
- package/src/site/project-sections.ts +48 -0
- package/src/site/prototype-resolver.ts +55 -11
- package/src/site/schema-command.ts +242 -0
- package/src/site/site-build.ts +178 -28
- package/src/site/site-loader.ts +3 -5
- package/src/targets/compile-client.ts +36 -5
- package/src/targets/compile-element.ts +41 -4
- package/src/targets/compile-server.ts +83 -5
- package/src/site/content-loader.ts +0 -385
package/src/site/site-build.ts
CHANGED
|
@@ -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 {
|
|
27
|
-
import
|
|
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
|
|
99
|
-
const
|
|
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
|
|
124
|
-
log("Loading
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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>
|
package/src/site/site-loader.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|