@jxsuite/compiler 6.1.0 → 7.0.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/README.md +0 -2
- package/dist/cli.js +256 -295
- package/dist/compiler.js +147 -186
- package/package.json +7 -8
- package/src/compiler.ts +21 -12
- package/src/shared.ts +133 -10
- package/src/site/layout-resolver.ts +43 -3
- package/src/site/server-entries.ts +118 -0
- package/src/site/site-build.ts +114 -52
- package/src/targets/compile-client.ts +54 -12
- package/src/targets/compile-element.ts +37 -0
- package/src/targets/compile-server.ts +17 -52
package/src/site/site-build.ts
CHANGED
|
@@ -68,20 +68,22 @@ import { collectAssetRefs, copyMountedAssets, loadAssetMounts } from "./asset-mo
|
|
|
68
68
|
import type { AssetMount } from "@jxsuite/schema/asset-paths";
|
|
69
69
|
import type { FormatEntry, FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
70
70
|
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
71
|
-
import { resolveLayout } from "./layout-resolver.ts";
|
|
71
|
+
import { layoutChain, resolveLayout } from "./layout-resolver.ts";
|
|
72
|
+
import { dedupeServerEntries, documentServerEntries } from "./server-entries.ts";
|
|
73
|
+
import type { DedupedServerEntries, ServerEntryRecord } from "./server-entries.ts";
|
|
72
74
|
import { mergeHead, renderHead, withPageDescription } from "@jxsuite/site/head-merger";
|
|
73
75
|
import { unregisteredHeadRelations } from "./link-relations.ts";
|
|
74
76
|
import { injectContext } from "./context-injection.ts";
|
|
75
|
-
import { compile,
|
|
77
|
+
import { compile, compileSiteServer } from "../compiler.ts";
|
|
76
78
|
import type { CompileOptions } from "../compiler.ts";
|
|
77
79
|
import { compileElement } from "../targets/compile-element.ts";
|
|
78
80
|
import type { CompileElementOptions } from "../targets/compile-element.ts";
|
|
81
|
+
import { serverRoutePrefix } from "../targets/compile-server.ts";
|
|
79
82
|
import type { SiteConnectorSpec, SiteMountSpec } from "../targets/compile-server.ts";
|
|
80
83
|
import {
|
|
81
84
|
buildComponentCSS,
|
|
82
85
|
buildInitialScope,
|
|
83
86
|
buildInstanceScope,
|
|
84
|
-
collectServerEntries,
|
|
85
87
|
collectStyles,
|
|
86
88
|
colorSchemePrePaintScript,
|
|
87
89
|
createSlotFill,
|
|
@@ -171,6 +173,13 @@ declare module "@jxsuite/schema/format-registry" {
|
|
|
171
173
|
}
|
|
172
174
|
}
|
|
173
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The remedy both adapter errors give: a dynamic section and a server function are served by the
|
|
178
|
+
* same worker, so a project that declares either needs one of the same four adapters.
|
|
179
|
+
*/
|
|
180
|
+
const ADAPTER_REMEDY =
|
|
181
|
+
'set build.adapter to "cloudflare-workers", "cloudflare-pages", "node", or "bun".';
|
|
182
|
+
|
|
174
183
|
/**
|
|
175
184
|
* Build an entire Jx site from a project directory.
|
|
176
185
|
*
|
|
@@ -364,8 +373,7 @@ export async function buildSite(
|
|
|
364
373
|
.join(", ");
|
|
365
374
|
throw new Error(
|
|
366
375
|
`project.json declares dynamic section(s) ${sections} served by extension mounts, but ` +
|
|
367
|
-
`build.adapter is "static". Dynamic tables need a server-capable adapter —
|
|
368
|
-
`build.adapter to "cloudflare-workers", "cloudflare-pages", "node", or "bun".`,
|
|
376
|
+
`build.adapter is "static". Dynamic tables need a server-capable adapter — ${ADAPTER_REMEDY}`,
|
|
369
377
|
);
|
|
370
378
|
}
|
|
371
379
|
|
|
@@ -445,6 +453,8 @@ export async function buildSite(
|
|
|
445
453
|
const compiledComponentTags: string[] = [];
|
|
446
454
|
const componentCSS = new Map<string, string>(); // TagName → CSS text
|
|
447
455
|
const componentDefs = new Map<string, JxElement>(); // TagName → parsed component definition
|
|
456
|
+
// TagName → the absolute path its definition was read from, for what is relative to that file.
|
|
457
|
+
const componentPathByTag = new Map<string, string>();
|
|
448
458
|
if (existsSync(componentsDir)) {
|
|
449
459
|
log("Compiling components...");
|
|
450
460
|
const componentExtensions = [".json", ...formatRegistry.documentExtensions("component")];
|
|
@@ -478,6 +488,9 @@ export async function buildSite(
|
|
|
478
488
|
* served from a path does not have.
|
|
479
489
|
*/
|
|
480
490
|
const componentBase = siteBasePath(projectConfig.url);
|
|
491
|
+
// A component's server-function call is written into its module, which no later pass rewrites,
|
|
492
|
+
// So it is given the deployment base now (site-architecture.md §14.7).
|
|
493
|
+
const serverBaseUrl = serverRoutePrefix(componentBase);
|
|
481
494
|
|
|
482
495
|
for (const file of componentFiles) {
|
|
483
496
|
try {
|
|
@@ -491,6 +504,7 @@ export async function buildSite(
|
|
|
491
504
|
},
|
|
492
505
|
rewriteSrc: (specifier: string, docDir: string | null) =>
|
|
493
506
|
withBase(componentBase, rewriteSidecarSrc(specifier, docDir)),
|
|
507
|
+
serverBaseUrl,
|
|
494
508
|
};
|
|
495
509
|
if (projectConfig.$media) {
|
|
496
510
|
elementOptions.$media = projectConfig.$media;
|
|
@@ -528,6 +542,7 @@ export async function buildSite(
|
|
|
528
542
|
const doc = await readPageDocument(componentPath, formatRegistry);
|
|
529
543
|
if (doc.tagName) {
|
|
530
544
|
componentDefs.set(doc.tagName, doc);
|
|
545
|
+
componentPathByTag.set(doc.tagName, componentPath);
|
|
531
546
|
const componentShadow = resolveShadowMode(doc, projectConfig.defaults);
|
|
532
547
|
const css = buildComponentCSS(
|
|
533
548
|
doc.tagName,
|
|
@@ -561,19 +576,31 @@ export async function buildSite(
|
|
|
561
576
|
);
|
|
562
577
|
}
|
|
563
578
|
|
|
564
|
-
// ── 5b. Collect server entries
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
579
|
+
// ── 5b. Collect server entries ──────────────────────────────────────────
|
|
580
|
+
/*
|
|
581
|
+
* Every `timing: "server"` entry the site declares, before deduplication: each compiled
|
|
582
|
+
* component's here, then `project.json`'s `state`, which every page that does not shadow a key
|
|
583
|
+
* inherits (site-architecture.md §3.2), then each route's page and the layouts it is wrapped in,
|
|
584
|
+
* pushed as the route compiles. Each document is read as written, so a relative `$src` resolves
|
|
585
|
+
* against the file that declares it (`server-entries.ts`). Collected whatever the adapter; step
|
|
586
|
+
* 6c deduplicates and serves them when there is one.
|
|
587
|
+
*/
|
|
588
|
+
const siteServerEntries: ServerEntryRecord[] = [];
|
|
589
|
+
for (const [tag, doc] of componentDefs) {
|
|
590
|
+
siteServerEntries.push(
|
|
591
|
+
...documentServerEntries(doc, componentPathByTag.get(tag)!, projectRoot),
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
if (projectConfig.state) {
|
|
595
|
+
siteServerEntries.push(
|
|
596
|
+
...documentServerEntries(
|
|
597
|
+
/* SAFETY: `ProjectConfig.state` is a plain record of state entries, and the collector reads
|
|
598
|
+
only a document's `state`, so a document carrying that one member is all it needs. */
|
|
599
|
+
{ state: projectConfig.state } as JxElement,
|
|
600
|
+
resolve(projectRoot, "project.json"),
|
|
601
|
+
projectRoot,
|
|
602
|
+
),
|
|
603
|
+
);
|
|
577
604
|
}
|
|
578
605
|
|
|
579
606
|
// ── 6. Compile each route ───────────────────────────────────────────────
|
|
@@ -741,6 +768,7 @@ export async function buildSite(
|
|
|
741
768
|
runtimeImports,
|
|
742
769
|
registerElementBundle,
|
|
743
770
|
);
|
|
771
|
+
siteServerEntries.push(...result.serverEntries);
|
|
744
772
|
|
|
745
773
|
for (const relation of result.unregisteredRelations) {
|
|
746
774
|
if (!warnedRelations.has(relation)) {
|
|
@@ -877,13 +905,6 @@ export async function buildSite(
|
|
|
877
905
|
writeFileSync(filePath, file.content, "utf8");
|
|
878
906
|
fileCount += 1;
|
|
879
907
|
}
|
|
880
|
-
|
|
881
|
-
// Write server handler if present
|
|
882
|
-
if (result.serverHandler) {
|
|
883
|
-
const serverPath = resolve(dirname(outPath), "_server.js");
|
|
884
|
-
writeFileSync(serverPath, result.serverHandler, "utf8");
|
|
885
|
-
fileCount += 1;
|
|
886
|
-
}
|
|
887
908
|
} catch (error) {
|
|
888
909
|
const msg = `Error compiling ${route.urlPattern}: ${errorMessage(error)}`;
|
|
889
910
|
errors.push(msg);
|
|
@@ -909,15 +930,47 @@ export async function buildSite(
|
|
|
909
930
|
}
|
|
910
931
|
|
|
911
932
|
// ── 6c. Generate site-wide server worker ────────────────────────────────
|
|
933
|
+
// What the worker serves, handed back to the caller: `jx dev` answers the same routes from it.
|
|
934
|
+
// Empty with no adapter, where nothing is served (compiler.md §6.3).
|
|
935
|
+
let serverEntries: DedupedServerEntries["entries"] = [];
|
|
936
|
+
/*
|
|
937
|
+
* A server function is served by the site worker and by nothing else, so with no adapter its
|
|
938
|
+
* entry has no route, and a page built that way would ship without the value it declared. It is
|
|
939
|
+
* an error as an active mount is, but a server entry is only known once each page's layout is
|
|
940
|
+
* resolved, which is after `dist/` is cleaned: the error goes into `errors`, so the pages are
|
|
941
|
+
* still written and `jx dev` still serves them, and `jx build` exits non-zero. `siteServerEntries`
|
|
942
|
+
* holds one record per document that declares an entry, a shared layout's once per page using it,
|
|
943
|
+
* so each distinct export and document is named once, in the order first seen.
|
|
944
|
+
*/
|
|
945
|
+
if (!projectConfig.build.adapter && siteServerEntries.length > 0) {
|
|
946
|
+
const named = [
|
|
947
|
+
...new Set(
|
|
948
|
+
siteServerEntries.map(({ exportName, declaredIn }) => `${exportName} (${declaredIn})`),
|
|
949
|
+
),
|
|
950
|
+
];
|
|
951
|
+
const message =
|
|
952
|
+
`timing: "server" entries ${named.join(", ")} need a server-capable adapter, but ` +
|
|
953
|
+
`build.adapter is "static": a static deployment has nothing to serve /_jx/server — ${
|
|
954
|
+
ADAPTER_REMEDY
|
|
955
|
+
}`;
|
|
956
|
+
errors.push(message);
|
|
957
|
+
console.error(message);
|
|
958
|
+
}
|
|
912
959
|
if (projectConfig.build.adapter) {
|
|
913
960
|
const { adapter } = projectConfig.build;
|
|
914
961
|
log("Generating site-wide server worker...");
|
|
915
962
|
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
963
|
+
/*
|
|
964
|
+
* One route per export name (compiler.md §6.3). Two modules claiming one name is a build
|
|
965
|
+
* error, not a silent choice: the worker can serve only one of them, and a page calling the
|
|
966
|
+
* other would receive its answer. The first module is still kept, so the worker is written
|
|
967
|
+
* and `jx dev` keeps serving the pages while the author renames one.
|
|
968
|
+
*/
|
|
969
|
+
const { entries: dedupedEntries, errors: clashErrors } = dedupeServerEntries(siteServerEntries);
|
|
970
|
+
serverEntries = dedupedEntries;
|
|
971
|
+
for (const error of clashErrors) {
|
|
972
|
+
errors.push(error);
|
|
973
|
+
console.error(error);
|
|
921
974
|
}
|
|
922
975
|
|
|
923
976
|
// Extension server mounts: static imports of the mount modules and used connector classes,
|
|
@@ -932,10 +985,11 @@ export async function buildSite(
|
|
|
932
985
|
// Cloudflare Pages uses advanced mode (_worker.js inside the build output) — the
|
|
933
986
|
// Functions/ directory convention only works from the project root, not from dist/.
|
|
934
987
|
// A static-only Pages site needs no worker at all; sites with mounts always get one.
|
|
935
|
-
const skipWorker =
|
|
988
|
+
const skipWorker =
|
|
989
|
+
adapter === "cloudflare-pages" && serverEntries.length === 0 && mounts.length === 0;
|
|
936
990
|
const workerSource = skipWorker
|
|
937
991
|
? null
|
|
938
|
-
: compileSiteServer(
|
|
992
|
+
: compileSiteServer(serverEntries, {
|
|
939
993
|
adapter,
|
|
940
994
|
base: basePath,
|
|
941
995
|
connectors,
|
|
@@ -955,7 +1009,7 @@ export async function buildSite(
|
|
|
955
1009
|
projectRoot,
|
|
956
1010
|
});
|
|
957
1011
|
fileCount += 1;
|
|
958
|
-
log(` Generated dist/${workerName} (bundled, ${
|
|
1012
|
+
log(` Generated dist/${workerName} (bundled, ${serverEntries.length} server function(s))`);
|
|
959
1013
|
} catch (error) {
|
|
960
1014
|
const msg = `Error bundling dist/${workerName}: ${errorMessage(error)}`;
|
|
961
1015
|
errors.push(msg);
|
|
@@ -966,12 +1020,18 @@ export async function buildSite(
|
|
|
966
1020
|
// Only invoke the worker for server routes; everything else stays static.
|
|
967
1021
|
writeFileSync(
|
|
968
1022
|
resolve(outDir, "_routes.json"),
|
|
969
|
-
|
|
970
|
-
|
|
1023
|
+
/*
|
|
1024
|
+
* `/` joins the include list only when there is a locale to negotiate: Pages invokes the
|
|
1025
|
+
* worker for included paths only, so an uninvoked middleware is an inert one. Both carry
|
|
1026
|
+
* the deployment base, because Pages matches them against the request path, and the
|
|
1027
|
+
* worker's own routes are registered under it (site-architecture.md §14.7).
|
|
1028
|
+
*/
|
|
971
1029
|
`${JSON.stringify(
|
|
972
1030
|
{
|
|
973
1031
|
exclude: [],
|
|
974
|
-
include: i18n && i18n.locales.length > 1 ? ["/", "/_jx/*"] : ["/_jx/*"]
|
|
1032
|
+
include: (i18n && i18n.locales.length > 1 ? ["/", "/_jx/*"] : ["/_jx/*"]).map(
|
|
1033
|
+
(pattern) => withBase(basePath, pattern),
|
|
1034
|
+
),
|
|
975
1035
|
version: 1,
|
|
976
1036
|
},
|
|
977
1037
|
null,
|
|
@@ -1283,7 +1343,7 @@ export async function buildSite(
|
|
|
1283
1343
|
log(` ${errors.length} error(s)`);
|
|
1284
1344
|
}
|
|
1285
1345
|
|
|
1286
|
-
return { errors, files: fileCount, routes: routes.length };
|
|
1346
|
+
return { errors, files: fileCount, routes: routes.length, serverEntries };
|
|
1287
1347
|
}
|
|
1288
1348
|
|
|
1289
1349
|
/** The route `compilePage` renders: a URL and the page document that produces it. */
|
|
@@ -1308,7 +1368,7 @@ interface PageRoute {
|
|
|
1308
1368
|
* @returns {Promise<{
|
|
1309
1369
|
* html: string;
|
|
1310
1370
|
* files: { path: string; content: string; tagName?: string }[];
|
|
1311
|
-
*
|
|
1371
|
+
* serverEntries: ServerEntryRecord[];
|
|
1312
1372
|
* doc: JxDocument;
|
|
1313
1373
|
* }>}
|
|
1314
1374
|
* Every parameter is required. There is exactly one caller and it passes all fifteen, so the
|
|
@@ -1344,6 +1404,19 @@ async function compilePage(
|
|
|
1344
1404
|
// Resolve layout (wraps page in layout with slot distribution)
|
|
1345
1405
|
const layoutDoc = await resolveLayout(pageDoc, projectConfig, projectRoot);
|
|
1346
1406
|
|
|
1407
|
+
/*
|
|
1408
|
+
* This page's and its layouts' server entries, each read from the file that declares it, so a
|
|
1409
|
+
* relative `$src` resolves against that file (compiler.md §6.3). Read here, before
|
|
1410
|
+
* `injectContext`: with no layout `resolveLayout` hands back `pageDoc` itself, and injection
|
|
1411
|
+
* then adds project.json's state to it, which `buildSite` collects once, as project.json's own.
|
|
1412
|
+
*/
|
|
1413
|
+
const serverEntries = [
|
|
1414
|
+
...documentServerEntries(pageDoc, route.sourcePath, projectRoot),
|
|
1415
|
+
...layoutChain(pageDoc, projectConfig, projectRoot).flatMap((level) =>
|
|
1416
|
+
documentServerEntries(level.doc, level.path, projectRoot),
|
|
1417
|
+
),
|
|
1418
|
+
];
|
|
1419
|
+
|
|
1347
1420
|
// Extract head arrays before they get lost in the merge
|
|
1348
1421
|
/* SAFETY: `_pageHead` is written only by `resolveLayout`, which copies the page's `$head` (a
|
|
1349
1422
|
JxHeadEntry[]) into it when the page has one. */
|
|
@@ -1559,6 +1632,8 @@ async function compilePage(
|
|
|
1559
1632
|
*/
|
|
1560
1633
|
rewriteSrc: (specifier: string) =>
|
|
1561
1634
|
withBase(basePath, rewriteSidecarSrc(specifier, dirname(route.sourcePath))),
|
|
1635
|
+
// The page's own module calls the worker's routes, under the same base the worker registers.
|
|
1636
|
+
serverBaseUrl: serverRoutePrefix(basePath),
|
|
1562
1637
|
title,
|
|
1563
1638
|
};
|
|
1564
1639
|
/*
|
|
@@ -1616,19 +1691,6 @@ async function compilePage(
|
|
|
1616
1691
|
result.html = injectNpmElementScripts(result.html, registerElementBundle(npmElements));
|
|
1617
1692
|
}
|
|
1618
1693
|
|
|
1619
|
-
// Compile server handler if applicable (skip when provider bundles site-wide)
|
|
1620
|
-
let serverHandler: string | null = null;
|
|
1621
|
-
if (!projectConfig.build.adapter) {
|
|
1622
|
-
try {
|
|
1623
|
-
const serverResult = await compileServer(route.sourcePath);
|
|
1624
|
-
if (serverResult) {
|
|
1625
|
-
serverHandler = serverResult;
|
|
1626
|
-
}
|
|
1627
|
-
} catch {
|
|
1628
|
-
// No server entries — that's fine
|
|
1629
|
-
}
|
|
1630
|
-
}
|
|
1631
|
-
|
|
1632
1694
|
return {
|
|
1633
1695
|
doc: layoutDoc,
|
|
1634
1696
|
// A page opts out of the sitemap by setting `$sitemap: false` (interim escape hatch
|
|
@@ -1642,7 +1704,7 @@ async function compilePage(
|
|
|
1642
1704
|
* say it once instead of four hundred times.
|
|
1643
1705
|
*/
|
|
1644
1706
|
unregisteredRelations: unregisteredHeadRelations(mergedHead),
|
|
1645
|
-
|
|
1707
|
+
serverEntries,
|
|
1646
1708
|
};
|
|
1647
1709
|
}
|
|
1648
1710
|
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
createCompileContext,
|
|
28
28
|
emitFormulaFn,
|
|
29
29
|
emitRequestFetch,
|
|
30
|
+
emitServerCall,
|
|
30
31
|
escapeHtml,
|
|
31
32
|
isMutating,
|
|
32
33
|
isRefObject,
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
PREFORMATTED_TAGS,
|
|
37
38
|
pureSchemeOf,
|
|
38
39
|
resolveStaticValue,
|
|
40
|
+
serverCallHelperSource,
|
|
39
41
|
srcImportBinding,
|
|
40
42
|
templateValueSource,
|
|
41
43
|
} from "../shared.ts";
|
|
@@ -51,6 +53,7 @@ import {
|
|
|
51
53
|
isNonNullObject,
|
|
52
54
|
isNumber,
|
|
53
55
|
isPrototypeDef,
|
|
56
|
+
isServerFnDef,
|
|
54
57
|
isString,
|
|
55
58
|
isTagExpression,
|
|
56
59
|
paramNames,
|
|
@@ -62,6 +65,7 @@ import {
|
|
|
62
65
|
objectKey,
|
|
63
66
|
refAccessor,
|
|
64
67
|
refBindingKey,
|
|
68
|
+
refReader,
|
|
65
69
|
refSegments,
|
|
66
70
|
} from "@jxsuite/runtime/pointer";
|
|
67
71
|
import type {
|
|
@@ -97,6 +101,7 @@ interface HandlerDef {
|
|
|
97
101
|
* litHtmlSrc?: string;
|
|
98
102
|
* modulePath?: string;
|
|
99
103
|
* projectStyle?: JxStyle | null;
|
|
104
|
+
* serverBaseUrl?: string;
|
|
100
105
|
* }} opts
|
|
101
106
|
* @returns {{ html: string; files: { path: string; content: string }[] }}
|
|
102
107
|
*/
|
|
@@ -110,6 +115,12 @@ export function compileClient(
|
|
|
110
115
|
projectStyle?: JxStyle | null;
|
|
111
116
|
prePaintScheme?: boolean;
|
|
112
117
|
rewriteSrc?: (specifier: string) => string;
|
|
118
|
+
/**
|
|
119
|
+
* The route prefix a `timing: "server"` entry posts to, deployment base included
|
|
120
|
+
* (`serverRoutePrefix`). The call is written into the module, and no later pass rewrites a
|
|
121
|
+
* module, so the base has to be known when it is compiled.
|
|
122
|
+
*/
|
|
123
|
+
serverBaseUrl?: string;
|
|
113
124
|
},
|
|
114
125
|
) {
|
|
115
126
|
const {
|
|
@@ -117,6 +128,7 @@ export function compileClient(
|
|
|
117
128
|
reactivitySrc = DEFAULT_REACTIVITY_SRC,
|
|
118
129
|
litHtmlSrc = DEFAULT_LIT_HTML_SRC,
|
|
119
130
|
modulePath = "app.js",
|
|
131
|
+
serverBaseUrl = "/_jx/server",
|
|
120
132
|
} = opts;
|
|
121
133
|
|
|
122
134
|
const context = createCompileContext(raw, null, raw.state ?? {}, raw.$media ?? {});
|
|
@@ -133,6 +145,7 @@ export function compileClient(
|
|
|
133
145
|
const bindEntries: [string, string][] = []; // [key, bodyExpr] → bind = {...}
|
|
134
146
|
const onEntries: [string, HandlerDef][] = []; // [key, { args, body }] → on = {...}
|
|
135
147
|
const initBlocks: string[] = []; // Lines emitted after state for prototype init
|
|
148
|
+
const serverCalls: string[] = []; // One block per `timing: "server"` entry, after the computed block
|
|
136
149
|
|
|
137
150
|
// Map $src path → Set of function names to import
|
|
138
151
|
const srcImportMap = new Map<string, Set<string>>();
|
|
@@ -241,6 +254,13 @@ export function compileClient(
|
|
|
241
254
|
}
|
|
242
255
|
}
|
|
243
256
|
|
|
257
|
+
// `timing: "server"`: null until the site worker's route answers (compiler.md §6.1)
|
|
258
|
+
if (isServerFnDef(def)) {
|
|
259
|
+
stateEntries.push([key, null]);
|
|
260
|
+
serverCalls.push(emitServerCall(key, def, { serverBaseUrl }));
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
244
264
|
/*
|
|
245
265
|
* Plain object → reactive state.
|
|
246
266
|
* SAFETY: `raw.state` is parsed JSON, so this entry is structurally a JsonObject; the checks
|
|
@@ -272,6 +292,7 @@ export function compileClient(
|
|
|
272
292
|
bindEntries,
|
|
273
293
|
onEntries,
|
|
274
294
|
initBlocks,
|
|
295
|
+
serverCalls,
|
|
275
296
|
srcImportMap,
|
|
276
297
|
counter,
|
|
277
298
|
reactivitySrc,
|
|
@@ -540,7 +561,7 @@ function buildClientNode(
|
|
|
540
561
|
const swKey = `_sw${counter.sw}`;
|
|
541
562
|
counter.sw += 1;
|
|
542
563
|
const discriminant = isRefObject(source.$switch)
|
|
543
|
-
?
|
|
564
|
+
? stateRead(source.$switch.$ref)
|
|
544
565
|
: `\`${String(source.$switch)}\``;
|
|
545
566
|
const cases = source.cases ?? {};
|
|
546
567
|
// Parsed JSON, so a case slot can hold `null` or a bare value the declared type leaves out.
|
|
@@ -575,13 +596,9 @@ function buildClientNode(
|
|
|
575
596
|
const arrayDef = source.children;
|
|
576
597
|
|
|
577
598
|
// Resolve items source expression
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
itemsExpr = `state.${path}`;
|
|
582
|
-
} else {
|
|
583
|
-
itemsExpr = JSON.stringify(arrayDef.items);
|
|
584
|
-
}
|
|
599
|
+
const itemsExpr = isRefObject(arrayDef.items)
|
|
600
|
+
? stateRead(arrayDef.items.$ref)
|
|
601
|
+
: JSON.stringify(arrayDef.items);
|
|
585
602
|
|
|
586
603
|
// Compile the map template to a lit-html template string
|
|
587
604
|
const litTemplate = emitLitMapTemplate(arrayDef.map);
|
|
@@ -845,9 +862,7 @@ function emitChildLit(c: JxMutableNode | JsonPrimitive, preformatted = false): s
|
|
|
845
862
|
*/
|
|
846
863
|
function emitArrayHole(arrayDef: RepeaterSlot): string {
|
|
847
864
|
const { items } = arrayDef;
|
|
848
|
-
const itemsExpr = isRefObject(items)
|
|
849
|
-
? `state.${refToBindingKey(items.$ref)}`
|
|
850
|
-
: JSON.stringify(items ?? []);
|
|
865
|
+
const itemsExpr = isRefObject(items) ? stateRead(items.$ref) : JSON.stringify(items ?? []);
|
|
851
866
|
const tpl = emitLitMapTemplate(arrayDef.map);
|
|
852
867
|
return `\${(${itemsExpr} ?? []).map((item, index) => html\`${tpl}\`)}`;
|
|
853
868
|
}
|
|
@@ -870,6 +885,7 @@ function mapRefsToLit(str: string) {
|
|
|
870
885
|
* @param {[string, string][]} bindEntries
|
|
871
886
|
* @param {[string, any][]} onEntries
|
|
872
887
|
* @param {string[]} initBlocks
|
|
888
|
+
* @param {string[]} serverCalls
|
|
873
889
|
* @param {Map<string, Set<string>>} srcImportMap
|
|
874
890
|
* @param {{
|
|
875
891
|
* t: number;
|
|
@@ -889,6 +905,7 @@ function emitClientModule(
|
|
|
889
905
|
bindEntries: [string, string][],
|
|
890
906
|
onEntries: [string, HandlerDef][],
|
|
891
907
|
initBlocks: string[],
|
|
908
|
+
serverCalls: string[],
|
|
892
909
|
srcImportMap: Map<string, Set<string>>,
|
|
893
910
|
counter: {
|
|
894
911
|
t: number;
|
|
@@ -930,7 +947,11 @@ function emitClientModule(
|
|
|
930
947
|
// State — reactive state
|
|
931
948
|
/* The boolean-attribute rule, inlined because this module loads with `@vue/reactivity` and
|
|
932
949
|
nothing else — see `attrHelperSource`. The hydration script below is its only caller. */
|
|
933
|
-
lines.push("", attrHelperSource()
|
|
950
|
+
lines.push("", attrHelperSource());
|
|
951
|
+
if (serverCalls.length > 0) {
|
|
952
|
+
lines.push("", serverCallHelperSource());
|
|
953
|
+
}
|
|
954
|
+
lines.push("", "const state = reactive({");
|
|
934
955
|
for (const [key, val] of stateEntries) {
|
|
935
956
|
/*
|
|
936
957
|
* State keys come from the document, so `{"user.name": 1}` is legal input. Pasting one raw as
|
|
@@ -956,6 +977,15 @@ function emitClientModule(
|
|
|
956
977
|
lines.push("");
|
|
957
978
|
}
|
|
958
979
|
|
|
980
|
+
// Server-function calls, after the computed block so a reference to a computed reads its value on
|
|
981
|
+
// The first run
|
|
982
|
+
if (serverCalls.length > 0) {
|
|
983
|
+
for (const block of serverCalls) {
|
|
984
|
+
lines.push(block);
|
|
985
|
+
}
|
|
986
|
+
lines.push("");
|
|
987
|
+
}
|
|
988
|
+
|
|
959
989
|
// Bind — DOM getters
|
|
960
990
|
if (bindEntries.length > 0) {
|
|
961
991
|
lines.push("const bind = {");
|
|
@@ -1126,6 +1156,18 @@ function mapRefToClientExpr(ref: string) {
|
|
|
1126
1156
|
return refAccessor("state", ref);
|
|
1127
1157
|
}
|
|
1128
1158
|
|
|
1159
|
+
/**
|
|
1160
|
+
* The read of the state a `$ref` names, for a binding whose value arrives later: a `$switch`
|
|
1161
|
+
* discriminant and a repeater's `items`. A server entry or a `Request` is `null` until it answers,
|
|
1162
|
+
* so `#/state/contact/status` must read `state.contact?.status` rather than throw on the first run,
|
|
1163
|
+
* which inside the hydration loop would end it before any later binding or handler was attached.
|
|
1164
|
+
* The path is the ref's own, never its flattened binding key: `contact/status` is two members, not
|
|
1165
|
+
* `contact_status`.
|
|
1166
|
+
*/
|
|
1167
|
+
function stateRead(ref: string) {
|
|
1168
|
+
return refReader("state", ref.startsWith("#/state/") ? ref.slice("#/state/".length) : ref);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1129
1171
|
/**
|
|
1130
1172
|
* The `bind`/`on` map key for a ref. It is emitted both as an object-literal key and as a
|
|
1131
1173
|
* `data-bind` attribute value, so it has to survive being a JavaScript identifier and an HTML
|
|
@@ -22,9 +22,11 @@ import {
|
|
|
22
22
|
compileStatements,
|
|
23
23
|
emitFormulaFn,
|
|
24
24
|
emitRequestFetch,
|
|
25
|
+
emitServerCall,
|
|
25
26
|
escapeHtml,
|
|
26
27
|
isMutating,
|
|
27
28
|
isSchemaOnly,
|
|
29
|
+
serverCallHelperSource,
|
|
28
30
|
srcExportName,
|
|
29
31
|
srcImportBinding,
|
|
30
32
|
tagNameToClassName,
|
|
@@ -42,6 +44,7 @@ import {
|
|
|
42
44
|
isNumber,
|
|
43
45
|
isPrototypeDef,
|
|
44
46
|
isRef,
|
|
47
|
+
isServerFnDef,
|
|
45
48
|
isString,
|
|
46
49
|
isTagExpression,
|
|
47
50
|
paramNames,
|
|
@@ -62,6 +65,7 @@ import type {
|
|
|
62
65
|
JxMutableNode,
|
|
63
66
|
JxPrototypeDef,
|
|
64
67
|
JxRef,
|
|
68
|
+
JxServerFnDef,
|
|
65
69
|
JxStateDefinition,
|
|
66
70
|
JxStyle,
|
|
67
71
|
ProjectConfig,
|
|
@@ -89,6 +93,12 @@ export interface CompileElementOptions {
|
|
|
89
93
|
basePath?: string | null;
|
|
90
94
|
/** Project `defaults`, read for `defaults.shadow` (spec.md §16.6). */
|
|
91
95
|
defaults?: ProjectConfig["defaults"];
|
|
96
|
+
/**
|
|
97
|
+
* The route prefix a `timing: "server"` entry posts to, deployment base included
|
|
98
|
+
* (`serverRoutePrefix`). The call is written into the module and no later pass rewrites a module,
|
|
99
|
+
* so the base has to be known when it is compiled. Handed to every `$elements` dependency too.
|
|
100
|
+
*/
|
|
101
|
+
serverBaseUrl?: string;
|
|
92
102
|
}
|
|
93
103
|
|
|
94
104
|
/**
|
|
@@ -185,6 +195,7 @@ export async function compileElement(
|
|
|
185
195
|
elementImports,
|
|
186
196
|
rewriteSrc ? (spec: string) => rewriteSrc(spec, currentDir) : undefined,
|
|
187
197
|
resolveShadowMode(doc, opts.defaults),
|
|
198
|
+
opts.serverBaseUrl,
|
|
188
199
|
);
|
|
189
200
|
const outputPath = filePath ? filePath.replace(/\.json$/, ".js") : `${tagName}.js`;
|
|
190
201
|
files.push({ content: jsContent, path: outputPath, tagName });
|
|
@@ -284,6 +295,10 @@ function extractInitialValue(def: JxStateDefinition) {
|
|
|
284
295
|
if (def.$prototype === "Request") {
|
|
285
296
|
return "null";
|
|
286
297
|
}
|
|
298
|
+
// A `timing: "server"` entry holds nothing until the site worker's route answers (§6.1)
|
|
299
|
+
if (isServerFnDef(def)) {
|
|
300
|
+
return "null";
|
|
301
|
+
}
|
|
287
302
|
// Plain object → treat as initial state value
|
|
288
303
|
return JSON.stringify(def);
|
|
289
304
|
}
|
|
@@ -371,6 +386,7 @@ export function emitElementModule(
|
|
|
371
386
|
elementImports: string[],
|
|
372
387
|
rewriteSrc?: (specifier: string) => string,
|
|
373
388
|
shadow: ShadowMode | null = null,
|
|
389
|
+
serverBaseUrl = "/_jx/server",
|
|
374
390
|
) {
|
|
375
391
|
const lines: string[] = ["// Generated by @jxsuite/compiler — do not edit manually"];
|
|
376
392
|
|
|
@@ -434,6 +450,12 @@ export function emitElementModule(
|
|
|
434
450
|
isPrototypeDef(def) && def.$prototype === "Request" ? [[key, def]] : [],
|
|
435
451
|
);
|
|
436
452
|
|
|
453
|
+
// `timing: "server"` entries each call the site worker's route from connectedCallback, after the
|
|
454
|
+
// Request fetches, and share one module-level helper that reads the response.
|
|
455
|
+
const serverEntries = Object.entries(defs).flatMap(([key, def]): [string, JxServerFnDef][] =>
|
|
456
|
+
isServerFnDef(def) ? [[key, def]] : [],
|
|
457
|
+
);
|
|
458
|
+
|
|
437
459
|
lines.push(
|
|
438
460
|
`import { reactive, computed, effect, stop } from '@vue/reactivity';`,
|
|
439
461
|
/* `nothing` is what REMOVES an attribute in a lit template, and it is the only way to express a
|
|
@@ -442,6 +464,9 @@ export function emitElementModule(
|
|
|
442
464
|
`import { render, html, nothing } from 'lit-html';`,
|
|
443
465
|
attrHelperSource(),
|
|
444
466
|
);
|
|
467
|
+
if (serverEntries.length > 0) {
|
|
468
|
+
lines.push(serverCallHelperSource());
|
|
469
|
+
}
|
|
445
470
|
/* Light-DOM slot distribution, by the rule the interpreter applies (spec.md §8.5): only a module
|
|
446
471
|
whose template draws a slot carries it, and a shadow component never does — its light children
|
|
447
472
|
ARE the slotted content, and a real `<slot>` distributes them. */
|
|
@@ -780,6 +805,18 @@ export function emitElementModule(
|
|
|
780
805
|
}),
|
|
781
806
|
);
|
|
782
807
|
}
|
|
808
|
+
// Last, so a `$ref` argument reads the props the parent delivered above, and an effect it starts
|
|
809
|
+
// Joins the registry `disconnectedCallback` stops.
|
|
810
|
+
for (const [key, def] of serverEntries) {
|
|
811
|
+
lines.push(
|
|
812
|
+
emitServerCall(key, def, {
|
|
813
|
+
collect: "this.#effects",
|
|
814
|
+
indent: " ",
|
|
815
|
+
serverBaseUrl,
|
|
816
|
+
statePrefix: "this.state",
|
|
817
|
+
}),
|
|
818
|
+
);
|
|
819
|
+
}
|
|
783
820
|
if (isNonNullObject(doc.style)) {
|
|
784
821
|
const dynamicStyles: [string, string][] = [];
|
|
785
822
|
for (const [prop, value] of Object.entries(doc.style)) {
|