@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.
- package/dist/cli.js +251 -176
- package/dist/compiler.js +117 -112
- package/package.json +7 -6
- package/src/cli.ts +30 -0
- package/src/compiler.ts +18 -2
- package/src/shared.ts +220 -112
- package/src/site/bundler.ts +187 -0
- package/src/site/dev-command.ts +84 -0
- 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/preview-server.ts +93 -0
- package/src/site/prototype-resolver.ts +15 -0
- package/src/site/site-build.ts +163 -15
- package/src/targets/compile-client.ts +53 -8
- package/src/targets/compile-element.ts +69 -7
- package/src/targets/compile-static.ts +17 -2
package/src/site/site-build.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|