@jxsuite/compiler 6.1.0 → 8.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 +255 -296
- package/dist/compiler.js +147 -186
- package/package.json +15 -9
- package/src/compiler.ts +21 -12
- package/src/shared.ts +133 -10
- package/src/site/bundler-backend.bun.ts +84 -0
- package/src/site/bundler-backend.node.ts +58 -0
- package/src/site/bundler-backend.ts +53 -0
- package/src/site/bundler.ts +49 -110
- package/src/site/client-runtime.ts +6 -11
- package/src/site/format-host.ts +98 -48
- package/src/site/image-cache.ts +38 -12
- package/src/site/layout-resolver.ts +43 -3
- package/src/site/schema-command.ts +26 -28
- 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
|
@@ -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)) {
|
|
@@ -1,65 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Compile-server.js —
|
|
2
|
+
* Compile-server.js — the site worker
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Emits the one Hono worker a site deploys beside its static output: every `timing: "server"` entry
|
|
5
|
+
* the build collected as a `POST /_jx/server/<export>` route, the extension server mounts, and the
|
|
6
|
+
* adapter's fallthrough to static assets (compiler.md §6.3).
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { withBase } from "@jxsuite/schema/asset-paths";
|
|
9
|
-
import { dereference } from "@apidevtools/json-schema-ref-parser";
|
|
10
|
-
import { collectServerEntries } from "../shared.ts";
|
|
11
10
|
import { localeNegotiationMiddleware } from "../site/locale-negotiation.ts";
|
|
12
11
|
import type { ResolvedI18n } from "../site/i18n.ts";
|
|
13
|
-
import type { JxDocumentValue
|
|
12
|
+
import type { JxDocumentValue } from "@jxsuite/schema/types";
|
|
14
13
|
|
|
15
14
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* found.
|
|
15
|
+
* The route prefix the site worker registers its server functions under, and a compiled page or
|
|
16
|
+
* component posts to: the deployment base (site-architecture.md §14.7) in front of `baseUrl`.
|
|
19
17
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* One function builds it for both sides. The worker IS the origin, so a route left at the bare path
|
|
19
|
+
* never matches on a site served from a subfolder, and no pass rewrites a module after it is
|
|
20
|
+
* compiled, so the caller has to be given the same prefix when the module is.
|
|
23
21
|
*
|
|
24
|
-
* @param {string
|
|
25
|
-
* @param {
|
|
26
|
-
* @
|
|
27
|
-
* @returns {Promise<string | null>} Hono server handler source string, or null
|
|
22
|
+
* @param {string} base - The deployment base, `""` for a site at an origin root
|
|
23
|
+
* @param {string} [baseUrl] - Route prefix before the base is applied, `"/_jx/server"` by default
|
|
24
|
+
* @returns {string}
|
|
28
25
|
*/
|
|
29
|
-
export
|
|
30
|
-
|
|
31
|
-
opts: {
|
|
32
|
-
baseUrl?: string;
|
|
33
|
-
} = {},
|
|
34
|
-
) {
|
|
35
|
-
const { baseUrl = "/_jx/server" } = opts;
|
|
36
|
-
/* SAFETY: the parameter is a Jx element document or the path of one, and `dereference` only
|
|
37
|
-
replaces each `$ref` node with the value it points to, so the result is that same document
|
|
38
|
-
with its references inlined. A file read from disk is not re-validated here. */
|
|
39
|
-
const doc = (await dereference(sourcePath)) as JxElement;
|
|
40
|
-
const entries = collectServerEntries(doc);
|
|
41
|
-
if (entries.length === 0) {
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const imports = entries
|
|
46
|
-
.map(({ exportName, src }) => `import { ${exportName} } from '${src}'`)
|
|
47
|
-
.join("\n");
|
|
48
|
-
|
|
49
|
-
const routes = entries.map(({ exportName }) => buildRoute(exportName, baseUrl)).join("\n");
|
|
50
|
-
|
|
51
|
-
return `// Generated by @jxsuite/compiler — do not edit manually
|
|
52
|
-
// Deploy as a Cloudflare Worker, Node server, or Bun process.
|
|
53
|
-
// Requires: npm install hono
|
|
54
|
-
|
|
55
|
-
import { Hono } from 'hono'
|
|
56
|
-
${imports}
|
|
57
|
-
|
|
58
|
-
const app = new Hono()
|
|
59
|
-
${routes}
|
|
60
|
-
|
|
61
|
-
export default app
|
|
62
|
-
`;
|
|
26
|
+
export function serverRoutePrefix(base: string, baseUrl = "/_jx/server"): string {
|
|
27
|
+
return withBase(base, baseUrl);
|
|
63
28
|
}
|
|
64
29
|
|
|
65
30
|
/**
|
|
@@ -131,7 +96,7 @@ export function compileSiteServer(
|
|
|
131
96
|
* everything behind it routes on the bare path. Each is internally consistent: production serves
|
|
132
97
|
* exactly one URL space, while `jx dev` deliberately answers both.
|
|
133
98
|
*/
|
|
134
|
-
const baseUrl =
|
|
99
|
+
const baseUrl = serverRoutePrefix(base, opts.baseUrl);
|
|
135
100
|
if (entries.length === 0 && mounts.length === 0 && !adapter) {
|
|
136
101
|
return null;
|
|
137
102
|
}
|
|
@@ -217,7 +182,7 @@ function buildRoute(exportName: string, baseUrl: string) {
|
|
|
217
182
|
app.post('${baseUrl}/${exportName}', async (c) => {
|
|
218
183
|
const args = await c.req.json().catch(() => ({}))
|
|
219
184
|
try {
|
|
220
|
-
return c.json(await ${exportName}(args, c.env))
|
|
185
|
+
return c.json((await ${exportName}(args, c.env)) ?? null)
|
|
221
186
|
} catch (e) {
|
|
222
187
|
return c.json({ ok: false, error: e?.message ?? 'Server error' }, 500)
|
|
223
188
|
}
|