@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/compiler",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"description": "Jx static HTML compiler, island detector, and site builder",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -45,11 +45,10 @@
|
|
|
45
45
|
"upgrade": "bun update --latest && bun install"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@
|
|
49
|
-
"@jxsuite/
|
|
50
|
-
"@jxsuite/
|
|
51
|
-
"@jxsuite/
|
|
52
|
-
"@jxsuite/site": "^2.2.0",
|
|
48
|
+
"@jxsuite/create": "^1.4.1",
|
|
49
|
+
"@jxsuite/runtime": "^6.0.0",
|
|
50
|
+
"@jxsuite/schema": "^4.1.0",
|
|
51
|
+
"@jxsuite/site": "^2.2.1",
|
|
53
52
|
"@vue/reactivity": "3.5.44",
|
|
54
53
|
"esbuild": "^0.28.2",
|
|
55
54
|
"lit-html": "^3.3.3",
|
|
@@ -57,8 +56,8 @@
|
|
|
57
56
|
},
|
|
58
57
|
"devDependencies": {
|
|
59
58
|
"@happy-dom/global-registrator": "^21.0.4",
|
|
60
|
-
"@jxsuite/connector": "^0.8.
|
|
61
|
-
"@jxsuite/parser": "^4.1.
|
|
59
|
+
"@jxsuite/connector": "^0.8.1",
|
|
60
|
+
"@jxsuite/parser": "^4.1.1",
|
|
62
61
|
"@jxsuite/test-support": "^0.0.0",
|
|
63
62
|
"@types/mdast": "^4.0.4"
|
|
64
63
|
}
|
package/src/compiler.ts
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
* - Fully static → compile-static.js (plain HTML/CSS, zero JS)
|
|
7
7
|
* - Custom element (-) → compile-element.js (lit-html web component)
|
|
8
8
|
* - Dynamic (standard) → compile-client.js (pre-rendered HTML + reactive bindings)
|
|
9
|
-
* - Server → compile-server.js (Hono server handler)
|
|
10
9
|
*
|
|
11
10
|
* Usage (CLI): `bun packages/compiler/src/compile-cli.js <source.json> [output.html]`
|
|
12
11
|
*
|
|
@@ -23,7 +22,6 @@ import {
|
|
|
23
22
|
isDynamic,
|
|
24
23
|
tagNameToClassName,
|
|
25
24
|
} from "./shared.ts";
|
|
26
|
-
import { compileServer } from "./targets/compile-server.ts";
|
|
27
25
|
|
|
28
26
|
import { emitElementModule } from "./targets/compile-element.ts";
|
|
29
27
|
import { compileStaticPage } from "./targets/compile-static.ts";
|
|
@@ -35,7 +33,7 @@ import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
|
35
33
|
|
|
36
34
|
// Re-exports for consumers
|
|
37
35
|
export { isDynamic } from "./shared.ts";
|
|
38
|
-
export {
|
|
36
|
+
export { compileSiteServer } from "./targets/compile-server.ts";
|
|
39
37
|
export { compileElement, compileElementPage } from "./targets/compile-element.ts";
|
|
40
38
|
export { compileClient } from "./targets/compile-client.ts";
|
|
41
39
|
|
|
@@ -49,6 +47,11 @@ export interface CompileOptions {
|
|
|
49
47
|
formats?: FormatRegistry;
|
|
50
48
|
/** Rewrite Function-def `$src` specifiers to served URLs (see CompileElementOptions). */
|
|
51
49
|
rewriteSrc?: (specifier: string) => string;
|
|
50
|
+
/**
|
|
51
|
+
* The route prefix a `timing: "server"` entry posts to, deployment base included
|
|
52
|
+
* (`serverRoutePrefix`). Default `"/_jx/server"`, for a site served from its origin root.
|
|
53
|
+
*/
|
|
54
|
+
serverBaseUrl?: string;
|
|
52
55
|
/**
|
|
53
56
|
* Inject the color-scheme pre-paint script when `$media` declares a pure scheme query (default
|
|
54
57
|
* true). The site pipeline disables this and injects via the merged <head> instead.
|
|
@@ -132,7 +135,14 @@ export async function compile(sourcePath: string | JxDocument, opts: CompileOpti
|
|
|
132
135
|
if (raw.tagName && raw.tagName.includes("-")) {
|
|
133
136
|
const { tagName } = raw;
|
|
134
137
|
const className = tagNameToClassName(tagName);
|
|
135
|
-
const moduleContent = emitElementModule(
|
|
138
|
+
const moduleContent = emitElementModule(
|
|
139
|
+
raw,
|
|
140
|
+
className,
|
|
141
|
+
[],
|
|
142
|
+
opts.rewriteSrc,
|
|
143
|
+
null,
|
|
144
|
+
opts.serverBaseUrl,
|
|
145
|
+
);
|
|
136
146
|
const moduleFile = {
|
|
137
147
|
content: moduleContent,
|
|
138
148
|
path: `${tagName}.js`,
|
|
@@ -166,13 +176,17 @@ export async function compile(sourcePath: string | JxDocument, opts: CompileOpti
|
|
|
166
176
|
}
|
|
167
177
|
|
|
168
178
|
// Route 3: Dynamic with standard tagName → pre-rendered HTML + reactive bindings
|
|
169
|
-
|
|
179
|
+
const clientOptions: Parameters<typeof compileClient>[1] = {
|
|
170
180
|
litHtmlSrc,
|
|
171
181
|
projectStyle,
|
|
172
182
|
reactivitySrc,
|
|
173
183
|
...pageTargetOverrides(opts),
|
|
174
184
|
title,
|
|
175
|
-
}
|
|
185
|
+
};
|
|
186
|
+
if (opts.serverBaseUrl !== undefined) {
|
|
187
|
+
clientOptions.serverBaseUrl = opts.serverBaseUrl;
|
|
188
|
+
}
|
|
189
|
+
return compileClient(raw, clientOptions);
|
|
176
190
|
}
|
|
177
191
|
|
|
178
192
|
/**
|
|
@@ -197,7 +211,7 @@ function pageTargetOverrides(opts: CompileOptions): PageTargetOverrides {
|
|
|
197
211
|
* @param {string} [out]
|
|
198
212
|
*/
|
|
199
213
|
export async function runCli(src: string, out?: string) {
|
|
200
|
-
const
|
|
214
|
+
const result = await compile(src);
|
|
201
215
|
const { writeFileSync, mkdirSync } = await import("node:fs");
|
|
202
216
|
const { dirname, join } = await import("node:path");
|
|
203
217
|
if (out) {
|
|
@@ -213,9 +227,4 @@ export async function runCli(src: string, out?: string) {
|
|
|
213
227
|
} else {
|
|
214
228
|
process.stdout.write(result.html);
|
|
215
229
|
}
|
|
216
|
-
if (server && out) {
|
|
217
|
-
const serverOut = out.replace(/(\.[^.]+)?$/, "-server.js");
|
|
218
|
-
writeFileSync(serverOut, /** @type {string} */ server, "utf8");
|
|
219
|
-
console.error(`Server handler written to ${serverOut}`);
|
|
220
|
-
}
|
|
221
230
|
}
|
package/src/shared.ts
CHANGED
|
@@ -23,7 +23,8 @@ import {
|
|
|
23
23
|
isSingleExpression,
|
|
24
24
|
splitSelectorList,
|
|
25
25
|
} from "@jxsuite/runtime";
|
|
26
|
-
import {
|
|
26
|
+
import { spliceNestingSelector } from "@jxsuite/runtime/css";
|
|
27
|
+
import { compileOperandSource, evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
|
|
27
28
|
import { runStatements } from "@jxsuite/runtime/statements";
|
|
28
29
|
import { buildSiteStyleCSS } from "@jxsuite/site/site-style";
|
|
29
30
|
import {
|
|
@@ -52,7 +53,7 @@ import {
|
|
|
52
53
|
import type { TagNameSlot } from "@jxsuite/schema/guards";
|
|
53
54
|
import { resolveShadowMode, styleScopePrefix } from "./shadow.ts";
|
|
54
55
|
import type { ShadowMode } from "./shadow.ts";
|
|
55
|
-
import { readPath } from "@jxsuite/runtime/pointer";
|
|
56
|
+
import { escapeToken, objectKey, readPath, refAccessor } from "@jxsuite/runtime/pointer";
|
|
56
57
|
import type { JxScope } from "@jxsuite/runtime/types";
|
|
57
58
|
import type {
|
|
58
59
|
CemParameter,
|
|
@@ -63,6 +64,7 @@ import type {
|
|
|
63
64
|
JxMutableNode,
|
|
64
65
|
JxPrototypeDef,
|
|
65
66
|
JxRef,
|
|
67
|
+
JxServerFnDef,
|
|
66
68
|
JxStateDefinition,
|
|
67
69
|
JxStyle,
|
|
68
70
|
ProjectConfig,
|
|
@@ -258,6 +260,84 @@ export function emitRequestFetch(
|
|
|
258
260
|
return lines.map((line) => `${indent}${line}`).join("\n");
|
|
259
261
|
}
|
|
260
262
|
|
|
263
|
+
/**
|
|
264
|
+
* A `$ref` scheme that names something only a loop, an event or a formula has. A state entry sits
|
|
265
|
+
* in none of those scopes, so lowering one would emit an identifier nothing declares.
|
|
266
|
+
*/
|
|
267
|
+
const SERVER_CALL_REFUSED_REFS = ["$map/", "event#/", "$args/", "$reduce/"];
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Emit the call a `timing: "server"` state entry makes to its route in the site worker (compiler.md
|
|
271
|
+
* §6.1): a `POST <serverBaseUrl>/<export>` whose JSON body is the entry's `arguments`, the answer
|
|
272
|
+
* written to the entry's state key.
|
|
273
|
+
*
|
|
274
|
+
* Shared by the client and element targets, as `emitRequestFetch` is, so one rule decides what an
|
|
275
|
+
* answer, a failure and a superseded call each do. A literal argument is sent as written. A
|
|
276
|
+
* top-level `$ref` argument is lowered by the runtime's own table (`compileOperandSource`) and read
|
|
277
|
+
* inside an `effect()`, so the call repeats when it changes. Only the latest call may write: a
|
|
278
|
+
* sequence number drops the answer to a call whose arguments have since moved on, because aborting
|
|
279
|
+
* a `POST` does not stop the function that is already running on the server. A non-2xx answer is
|
|
280
|
+
* never stored: the entry keeps its last value and the error goes to the console naming the
|
|
281
|
+
* export.
|
|
282
|
+
*
|
|
283
|
+
* @param {string} key - State key receiving the answer
|
|
284
|
+
* @param {JxServerFnDef} def - The server entry
|
|
285
|
+
* @param {{ statePrefix?: string; indent?: string; collect?: string; serverBaseUrl?: string }} [opts] -
|
|
286
|
+
* `statePrefix` is the expression the emitted code assigns through (`state` for the client module
|
|
287
|
+
* scope, `this.state` inside a custom element); `indent` prefixes every emitted line; `collect`,
|
|
288
|
+
* when given, is an array expression the effect runner is pushed onto so the caller can `stop()`
|
|
289
|
+
* it on teardown; `serverBaseUrl` is the route prefix, deployment base included.
|
|
290
|
+
* @returns {string}
|
|
291
|
+
*/
|
|
292
|
+
export function emitServerCall(
|
|
293
|
+
key: string,
|
|
294
|
+
def: JxServerFnDef,
|
|
295
|
+
opts: { statePrefix?: string; indent?: string; collect?: string; serverBaseUrl?: string } = {},
|
|
296
|
+
): string {
|
|
297
|
+
const { statePrefix = "state", indent = "", collect, serverBaseUrl = "/_jx/server" } = opts;
|
|
298
|
+
const url = JSON.stringify(`${serverBaseUrl}/${def.$export}`);
|
|
299
|
+
|
|
300
|
+
const written = Object.entries(def.arguments ?? {});
|
|
301
|
+
const args = written.map(([name, value]) => {
|
|
302
|
+
if (!isRef(value)) {
|
|
303
|
+
return `${objectKey(name)}: ${JSON.stringify(value)}`;
|
|
304
|
+
}
|
|
305
|
+
if (SERVER_CALL_REFUSED_REFS.some((prefix) => value.$ref.startsWith(prefix))) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`Server function "${key}": argument "${name}" references "${value.$ref}", which a state ` +
|
|
308
|
+
`entry cannot read. Use a #/state/, parent#/, window#/ or document#/ reference.`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
return `${objectKey(name)}: ${compileOperandSource({ $ref: value.$ref }, { statePrefix })}`;
|
|
312
|
+
});
|
|
313
|
+
// Any top-level reference makes the call reactive: it is read inside an effect, so it repeats.
|
|
314
|
+
const reactive = written.some(([, value]) => isRef(value));
|
|
315
|
+
const call = `${SERVER_CALL_HELPER}(${url}, ${args.length > 0 ? `{ ${args.join(", ")} }` : "{}"})`;
|
|
316
|
+
const target = refAccessor(statePrefix, escapeToken(key));
|
|
317
|
+
const failed = `console.error(${JSON.stringify(`Jx server function ${def.$export}:`)}, e)`;
|
|
318
|
+
|
|
319
|
+
const lines = reactive
|
|
320
|
+
? [
|
|
321
|
+
`// ${key}: server function ${def.$export}, called again when an argument changes`,
|
|
322
|
+
"{",
|
|
323
|
+
" let _jxSeq = 0;",
|
|
324
|
+
collect ? ` ${collect}.push(effect(() => {` : " effect(() => {",
|
|
325
|
+
" const _seq = ++_jxSeq;",
|
|
326
|
+
` ${call}`,
|
|
327
|
+
` .then((d) => { if (_seq === _jxSeq) ${target} = d; })`,
|
|
328
|
+
` .catch((e) => { if (_seq === _jxSeq) ${failed}; });`,
|
|
329
|
+
collect ? " }));" : " });",
|
|
330
|
+
"}",
|
|
331
|
+
]
|
|
332
|
+
: [
|
|
333
|
+
`// ${key}: server function ${def.$export}, called once`,
|
|
334
|
+
call,
|
|
335
|
+
` .then((d) => { ${target} = d; })`,
|
|
336
|
+
` .catch((e) => { ${failed}; });`,
|
|
337
|
+
];
|
|
338
|
+
return lines.map((line) => `${indent}${line}`).join("\n");
|
|
339
|
+
}
|
|
340
|
+
|
|
261
341
|
/**
|
|
262
342
|
* Rebase the bare `state.` reads in a template string onto `statePrefix`.
|
|
263
343
|
*
|
|
@@ -624,6 +704,12 @@ export function buildInitialScope(
|
|
|
624
704
|
setOwnScopeValue(scope, key, cloneValue(def.default));
|
|
625
705
|
continue;
|
|
626
706
|
}
|
|
707
|
+
if (isServerFnDef(def)) {
|
|
708
|
+
// Its value is an answer from the site worker. Storing the definition object instead would
|
|
709
|
+
// Let a template that reads it bake `{ $src, $export, timing }` into the page.
|
|
710
|
+
runtimeOnly.add(key);
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
627
713
|
if (!def.$prototype && !("$expression" in def) && !isSchemaOnlyDef(def)) {
|
|
628
714
|
setOwnScopeValue(scope, key, cloneValue(def));
|
|
629
715
|
}
|
|
@@ -1282,6 +1368,39 @@ export function attrHelperSource(): string {
|
|
|
1282
1368
|
);
|
|
1283
1369
|
}
|
|
1284
1370
|
|
|
1371
|
+
/** The name of the call helper a module holding a `timing: "server"` entry declares once. */
|
|
1372
|
+
export const SERVER_CALL_HELPER = "__jxServerCall";
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* The request a compiled server-function call makes, as source for a generated module's preamble
|
|
1376
|
+
* (compiler.md §6.1), declared once per module and called by every `emitServerCall` block in it.
|
|
1377
|
+
*
|
|
1378
|
+
* The response rule lives here, in one place: the body is read as text and parsed when it is not
|
|
1379
|
+
* empty, so a function that returns nothing answers `null`; a non-2xx answer rejects with the
|
|
1380
|
+
* parsed body's `error`, else the body text, else the status text. A body that is not JSON only
|
|
1381
|
+
* matters on success, where it is a defect worth surfacing, and a failure still reports its text.
|
|
1382
|
+
*
|
|
1383
|
+
* @returns {string} One `async function` declaration.
|
|
1384
|
+
*/
|
|
1385
|
+
export function serverCallHelperSource(): string {
|
|
1386
|
+
return [
|
|
1387
|
+
`async function ${SERVER_CALL_HELPER}(url, args) {`,
|
|
1388
|
+
" const r = await fetch(url, {",
|
|
1389
|
+
" method: 'POST',",
|
|
1390
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
1391
|
+
" body: JSON.stringify(args),",
|
|
1392
|
+
" });",
|
|
1393
|
+
" const text = await r.text();",
|
|
1394
|
+
" let body = null;",
|
|
1395
|
+
" if (text !== '') {",
|
|
1396
|
+
" try { body = JSON.parse(text); } catch (e) { if (r.ok) throw e; }",
|
|
1397
|
+
" }",
|
|
1398
|
+
" if (!r.ok) throw new Error((body && body.error) || text || r.statusText);",
|
|
1399
|
+
" return body;",
|
|
1400
|
+
"}",
|
|
1401
|
+
].join("\n");
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1285
1404
|
/** The capture helper a light-DOM component module calls before its first render. */
|
|
1286
1405
|
export const SLOT_CAPTURE_HELPER = "__jxCaptureSlotted";
|
|
1287
1406
|
|
|
@@ -1625,24 +1744,27 @@ function _walkSrc(def: JxElement | JxMutableNode | string, srcs: Set<string>) {
|
|
|
1625
1744
|
}
|
|
1626
1745
|
|
|
1627
1746
|
/**
|
|
1628
|
-
* Recursively collect all `timing: "server"` entries from the document tree
|
|
1747
|
+
* Recursively collect all `timing: "server"` entries from the document tree: one record per entry,
|
|
1748
|
+
* in walk order (the root's `state` keys, then `children` depth-first). Nothing is deduplicated, so
|
|
1749
|
+
* two keys naming one export are both listed; which of them a handler serves is the caller's rule
|
|
1750
|
+
* (compiler.md §6.3).
|
|
1629
1751
|
*
|
|
1630
1752
|
* @param {JxElement} doc
|
|
1631
1753
|
* @returns {{ key: string; exportName: string; src: string }[]}
|
|
1632
1754
|
*/
|
|
1633
1755
|
export function collectServerEntries(doc: JxElement) {
|
|
1634
|
-
const entries
|
|
1756
|
+
const entries: { key: string; exportName: string; src: string }[] = [];
|
|
1635
1757
|
_walkServerEntries(doc, entries);
|
|
1636
|
-
return
|
|
1758
|
+
return entries;
|
|
1637
1759
|
}
|
|
1638
1760
|
|
|
1639
1761
|
/**
|
|
1640
1762
|
* @param {JxElement | JxMutableNode | string} def
|
|
1641
|
-
* @param {
|
|
1763
|
+
* @param {{ key: string; exportName: string; src: string }[]} entries
|
|
1642
1764
|
*/
|
|
1643
1765
|
function _walkServerEntries(
|
|
1644
1766
|
def: JxElement | JxMutableNode | string,
|
|
1645
|
-
entries:
|
|
1767
|
+
entries: { key: string; exportName: string; src: string }[],
|
|
1646
1768
|
) {
|
|
1647
1769
|
if (!isNonNullObject(def)) {
|
|
1648
1770
|
return;
|
|
@@ -1650,7 +1772,7 @@ function _walkServerEntries(
|
|
|
1650
1772
|
if (def.state) {
|
|
1651
1773
|
for (const [key, d] of Object.entries(def.state)) {
|
|
1652
1774
|
if (isServerFnDef(d)) {
|
|
1653
|
-
entries.
|
|
1775
|
+
entries.push({ exportName: d.$export, key, src: d.$src });
|
|
1654
1776
|
}
|
|
1655
1777
|
}
|
|
1656
1778
|
}
|
|
@@ -2346,8 +2468,9 @@ function resolveSelector(prop: string, scope: string): string {
|
|
|
2346
2468
|
|
|
2347
2469
|
/** One member of {@link resolveSelector}'s list against the scope. */
|
|
2348
2470
|
function resolveSelectorMember(prop: string, scope: string): string {
|
|
2349
|
-
|
|
2350
|
-
|
|
2471
|
+
const spliced = spliceNestingSelector(prop, scope);
|
|
2472
|
+
if (spliced !== null) {
|
|
2473
|
+
return spliced;
|
|
2351
2474
|
}
|
|
2352
2475
|
if (prop.startsWith(":host")) {
|
|
2353
2476
|
const inner = /^:host\((.*)\)$/.exec(prop)?.[1];
|
|
@@ -12,11 +12,14 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
12
12
|
import { errorMessage, parseJxDocument } from "@jxsuite/schema/parse";
|
|
13
13
|
import { resolve } from "node:path";
|
|
14
14
|
import { resolveLayout as resolveLayoutWith } from "@jxsuite/site/layout";
|
|
15
|
-
import type { LayoutLoader } from "@jxsuite/site/layout";
|
|
16
15
|
import type { JxDocument, ProjectConfig } from "@jxsuite/schema/types";
|
|
17
16
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
17
|
+
/**
|
|
18
|
+
* A layout loader (`LayoutLoader` in `@jxsuite/site/layout`) over the project's own directory. It
|
|
19
|
+
* reads synchronously, which is what lets {@link layoutChain} walk a chain without awaiting each
|
|
20
|
+
* level, and a synchronous loader is still one `resolveLayout` accepts.
|
|
21
|
+
*/
|
|
22
|
+
export function nodeLayoutLoader(projectRoot: string): (layoutRef: string) => JxDocument {
|
|
20
23
|
return (layoutRef: string) => {
|
|
21
24
|
const layoutPath = resolve(projectRoot, layoutRef);
|
|
22
25
|
if (!existsSync(layoutPath)) {
|
|
@@ -47,3 +50,40 @@ export function resolveLayout(
|
|
|
47
50
|
): Promise<JxDocument> {
|
|
48
51
|
return resolveLayoutWith(pageDoc, projectConfig, nodeLayoutLoader(projectRoot));
|
|
49
52
|
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every layout a page is wrapped in, innermost first, each with the absolute path it was read from.
|
|
56
|
+
*
|
|
57
|
+
* The chain {@link resolveLayout} follows: the page's `$layout`, else `defaults.layout`, then each
|
|
58
|
+
* layout's own `$layout`. A reference that is not a non-empty string ends it, which is how
|
|
59
|
+
* `"$layout": false` opts a page out, and so does a layout already in the chain, so a layout that
|
|
60
|
+
* names itself is listed once rather than followed forever.
|
|
61
|
+
*
|
|
62
|
+
* It exists for what the merged document no longer says: which file declared what. Resolution
|
|
63
|
+
* merges state and splices children, so a relative path a layout wrote is relative to the layout's
|
|
64
|
+
* own file, and only the chain still names that file.
|
|
65
|
+
*
|
|
66
|
+
* @param {JxDocument} pageDoc - The raw page document
|
|
67
|
+
* @param {ProjectConfig} projectConfig - Site configuration (for defaults.layout)
|
|
68
|
+
* @param {string} projectRoot - Project root directory, which every `$layout` is relative to
|
|
69
|
+
* @returns {{ path: string; doc: JxDocument }[]} Each layout's absolute path and document
|
|
70
|
+
*/
|
|
71
|
+
export function layoutChain(
|
|
72
|
+
pageDoc: JxDocument,
|
|
73
|
+
projectConfig: ProjectConfig,
|
|
74
|
+
projectRoot: string,
|
|
75
|
+
): { path: string; doc: JxDocument }[] {
|
|
76
|
+
const load = nodeLayoutLoader(projectRoot);
|
|
77
|
+
const chain: { path: string; doc: JxDocument }[] = [];
|
|
78
|
+
let ref = pageDoc.$layout ?? projectConfig.defaults?.layout;
|
|
79
|
+
while (ref) {
|
|
80
|
+
const path = resolve(projectRoot, ref);
|
|
81
|
+
if (chain.some((level) => level.path === path)) {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
const doc = load(ref);
|
|
85
|
+
chain.push({ doc, path });
|
|
86
|
+
ref = doc.$layout;
|
|
87
|
+
}
|
|
88
|
+
return chain;
|
|
89
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-entries.ts — where the site worker's `timing: "server"` entries come from.
|
|
3
|
+
*
|
|
4
|
+
* Every document that can declare a server entry is read as written: the page, each layout in its
|
|
5
|
+
* chain, `project.json`'s `state` (which every page inherits) and every compiled component
|
|
6
|
+
* (compiler.md §6.3, site-architecture.md §14.1.1). The merged page cannot stand in for them,
|
|
7
|
+
* because layout resolution merges state and splices children, and after that nothing records which
|
|
8
|
+
* file a relative `$src` was written against.
|
|
9
|
+
*
|
|
10
|
+
* A relative `$src` (`./`, `../`) resolves against the directory of the document that declares it,
|
|
11
|
+
* and the worker receives it as a `./` path from the project root, because the worker bundle
|
|
12
|
+
* resolves every import from there (compiler.md §12). Any other specifier, a package export most
|
|
13
|
+
* often, is kept as written and resolves from the root unchanged.
|
|
14
|
+
*
|
|
15
|
+
* Each entry is served at `/_jx/server/<export>`, so an export name is one function across the
|
|
16
|
+
* whole site. One module that several documents declare is one route; two different modules
|
|
17
|
+
* exporting one name cannot both be served, and a page calling the one left out would silently
|
|
18
|
+
* receive the other's answer, so that is an error rather than a choice made for the author.
|
|
19
|
+
*
|
|
20
|
+
* @docs framework/site/deployment
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { dirname, relative, resolve, sep } from "node:path";
|
|
24
|
+
import { collectServerEntries } from "../shared.ts";
|
|
25
|
+
import type { JxElement } from "@jxsuite/schema/types";
|
|
26
|
+
|
|
27
|
+
/** One `timing: "server"` entry as one document declares it, before deduplication. */
|
|
28
|
+
export interface ServerEntryRecord {
|
|
29
|
+
/** The `$export`: the name the worker imports and the route it registers it under. */
|
|
30
|
+
exportName: string;
|
|
31
|
+
/** The specifier the worker imports, resolvable from the project root. */
|
|
32
|
+
src: string;
|
|
33
|
+
/** The declaring document, project-relative with `/` separators (`layouts/base.json`). */
|
|
34
|
+
declaredIn: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What {@link dedupeServerEntries} hands the site worker: its routes, and a message for each clash. */
|
|
38
|
+
export interface DedupedServerEntries {
|
|
39
|
+
/** One entry per export name, the `{ exportName, src }` pairs `compileSiteServer` imports. */
|
|
40
|
+
entries: { exportName: string; src: string }[];
|
|
41
|
+
/** One message per export name that two or more different modules claim. */
|
|
42
|
+
errors: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `path` relative to the project root, with `/` separators whatever the platform's own. */
|
|
46
|
+
function projectRelative(path: string, projectRoot: string): string {
|
|
47
|
+
return relative(projectRoot, path).split(sep).join("/");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The specifier the worker imports for a `$src` written in a document under `docDir`.
|
|
52
|
+
*
|
|
53
|
+
* A `./` or `../` path is resolved against `docDir` and returned as a `./` path from the project
|
|
54
|
+
* root, in POSIX form so the generated import is valid on every platform. Anything else is returned
|
|
55
|
+
* as written.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveServerSrc(src: string, docDir: string, projectRoot: string): string {
|
|
58
|
+
if (!src.startsWith("./") && !src.startsWith("../")) {
|
|
59
|
+
return src;
|
|
60
|
+
}
|
|
61
|
+
return `./${projectRelative(resolve(docDir, src), projectRoot)}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Every server entry one document declares, in document order, each `src` resolved against the
|
|
66
|
+
* document's own directory and each record naming the document.
|
|
67
|
+
*/
|
|
68
|
+
export function documentServerEntries(
|
|
69
|
+
doc: JxElement,
|
|
70
|
+
docPath: string,
|
|
71
|
+
projectRoot: string,
|
|
72
|
+
): ServerEntryRecord[] {
|
|
73
|
+
const docDir = dirname(docPath);
|
|
74
|
+
const declaredIn = projectRelative(docPath, projectRoot);
|
|
75
|
+
return collectServerEntries(doc).map((entry) => ({
|
|
76
|
+
exportName: entry.exportName,
|
|
77
|
+
src: resolveServerSrc(entry.src, docDir, projectRoot),
|
|
78
|
+
declaredIn,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* One entry per export name, the list the site worker serves, plus one error per export name that
|
|
84
|
+
* two or more different modules claim.
|
|
85
|
+
*
|
|
86
|
+
* Export names keep the order they were first seen in. A name declared by several documents for the
|
|
87
|
+
* same module is one entry and no message. A name two modules claim keeps the first module, so the
|
|
88
|
+
* worker can still be written, and its error names every module with the first document that
|
|
89
|
+
* declared it.
|
|
90
|
+
*/
|
|
91
|
+
export function dedupeServerEntries(records: readonly ServerEntryRecord[]): DedupedServerEntries {
|
|
92
|
+
// Export name → each distinct module claiming it → the first document declaring that module.
|
|
93
|
+
const claims = new Map<string, Map<string, string>>();
|
|
94
|
+
for (const { exportName, src, declaredIn } of records) {
|
|
95
|
+
const modules = claims.get(exportName) ?? new Map<string, string>();
|
|
96
|
+
claims.set(exportName, modules);
|
|
97
|
+
if (!modules.has(src)) {
|
|
98
|
+
modules.set(src, declaredIn);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const entries: { exportName: string; src: string }[] = [];
|
|
103
|
+
const errors: string[] = [];
|
|
104
|
+
for (const [exportName, modules] of claims) {
|
|
105
|
+
const claimed = [...modules];
|
|
106
|
+
entries.push({ exportName, src: claimed[0]![0] });
|
|
107
|
+
if (claimed.length > 1) {
|
|
108
|
+
const named = claimed.map(([src, declaredIn]) => `${src} (declared in ${declaredIn})`);
|
|
109
|
+
errors.push(
|
|
110
|
+
`Server function "${exportName}" is exported by ${claimed.length} different modules: ` +
|
|
111
|
+
`${named.join(", ")}. The site worker serves one function per export name, at ` +
|
|
112
|
+
`/_jx/server/${exportName}, so a call meant for any but the first would get the first's ` +
|
|
113
|
+
`answer. Rename the export in all but one module, and the $export that names it.`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { entries, errors };
|
|
118
|
+
}
|