@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
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
flattenSchema,
|
|
25
25
|
} from "@jxsuite/schema/project-schemas";
|
|
26
26
|
import { isJsonObject } from "@jxsuite/schema/guards";
|
|
27
|
-
import { buildProjectExtensionRegistry } from "./format-host.ts";
|
|
27
|
+
import { buildProjectExtensionRegistry, resolveFromProject } from "./format-host.ts";
|
|
28
28
|
import { loadProjectConfig } from "./site-loader.ts";
|
|
29
29
|
import type { ExtensionInfo } from "@jxsuite/schema/extension-registry";
|
|
30
30
|
import type { SchemaJsonLoader } from "@jxsuite/schema/project-schemas";
|
|
@@ -156,21 +156,24 @@ function readSchemaFile(path: string): JsonObject {
|
|
|
156
156
|
return parsed;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
/**
|
|
160
|
-
|
|
159
|
+
/**
|
|
160
|
+
* The package whose schemas define the Jx document contract itself. Only the core: an extension's
|
|
161
|
+
* fragments, first-party ones included, are the project's (specs/extensions.md §5.4).
|
|
162
|
+
*/
|
|
163
|
+
const CORE_SCHEMA_SCOPE = "@jxsuite/schema/";
|
|
161
164
|
|
|
162
165
|
/**
|
|
163
|
-
* Is this specifier one of the shipped core schemas, as opposed to
|
|
166
|
+
* Is this specifier one of the shipped core schemas, as opposed to an extension's fragment?
|
|
164
167
|
*
|
|
165
168
|
* @param {string} specifier - Bare specifier, e.g. `@jxsuite/schema/schema.json`
|
|
166
169
|
* @returns {boolean}
|
|
167
170
|
*/
|
|
168
|
-
export function
|
|
169
|
-
return specifier.startsWith(
|
|
171
|
+
export function isCoreSchema(specifier: string): boolean {
|
|
172
|
+
return specifier.startsWith(CORE_SCHEMA_SCOPE) && specifier.endsWith(".json");
|
|
170
173
|
}
|
|
171
174
|
|
|
172
175
|
/**
|
|
173
|
-
* Resolve
|
|
176
|
+
* Resolve the core schema from the HOST — the workspace running the generator.
|
|
174
177
|
*
|
|
175
178
|
* Throws rather than falling back to the project, because a fallback is exactly the behaviour that
|
|
176
179
|
* let a stale published core answer for the workspace one. A generator that cannot find its own
|
|
@@ -185,7 +188,7 @@ function hostResolve(specifier: string, path: string): string {
|
|
|
185
188
|
return createRequire(import.meta.url).resolve(specifier);
|
|
186
189
|
} catch (error) {
|
|
187
190
|
throw new Error(
|
|
188
|
-
`Schema ref "${path}" names the
|
|
191
|
+
`Schema ref "${path}" names the core schema "${specifier}", which must resolve from ` +
|
|
189
192
|
`the host workspace and does not. The project's own copy is deliberately not consulted, so ` +
|
|
190
193
|
`the host install is missing "${specifier.split("/").slice(0, 2).join("/")}" — check that ` +
|
|
191
194
|
`the package ships with this build`,
|
|
@@ -195,9 +198,10 @@ function hostResolve(specifier: string, path: string): string {
|
|
|
195
198
|
}
|
|
196
199
|
|
|
197
200
|
/**
|
|
198
|
-
* Bundler loader restricted to the project root
|
|
199
|
-
* `<root>/node_modules/<specifier>`
|
|
200
|
-
*
|
|
201
|
+
* Bundler loader restricted to the project root. The core schema comes from the host; every other
|
|
202
|
+
* `<root>/node_modules/<specifier>` ref that is not on disk at that path resolves through the
|
|
203
|
+
* project's own module resolution (workspace symlinks and hoisted installs resolve transparently)
|
|
204
|
+
* and never through the host's, because an extension's fragments are the project's to supply.
|
|
201
205
|
*
|
|
202
206
|
* Exported for test: both refusals below are reachable only through a project whose `node_modules`
|
|
203
207
|
* has been arranged to provoke them, and a fixture that arranges an extension registry as well says
|
|
@@ -217,9 +221,9 @@ export function restrictedSchemaLoader(root: string): SchemaJsonLoader {
|
|
|
217
221
|
const marker = "node_modules/";
|
|
218
222
|
const markerIndex = posixRel.lastIndexOf(marker);
|
|
219
223
|
const specifier = markerIndex === -1 ? null : posixRel.slice(markerIndex + marker.length);
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
224
|
+
// The core comes from the host, always — before the project-local file is even looked at. A
|
|
225
|
+
// Project supplies every extension's fragments, first-party ones included, which is what the
|
|
226
|
+
// Rest of this loader is for; what it may not do is answer for `@jxsuite/schema` itself.
|
|
223
227
|
//
|
|
224
228
|
// This is not hypothetical. A stray `bun install` in packages/starters/sites/shop left a
|
|
225
229
|
// Gitignored node_modules holding @jxsuite/schema@0.35.0 beside a 1.5.0 workspace, and BOTH the
|
|
@@ -230,7 +234,7 @@ export function restrictedSchemaLoader(root: string): SchemaJsonLoader {
|
|
|
230
234
|
// Shop's own pages/products/[sku].json invalid against its own committed schema. Nothing
|
|
231
235
|
// Noticed for six weeks, because the artifact was only ever regenerated on the machine that
|
|
232
236
|
// Had the shadow.
|
|
233
|
-
if (specifier !== null &&
|
|
237
|
+
if (specifier !== null && isCoreSchema(specifier)) {
|
|
234
238
|
return readSchemaFile(hostResolve(specifier, path));
|
|
235
239
|
}
|
|
236
240
|
if (existsSync(abs)) {
|
|
@@ -241,19 +245,13 @@ export function restrictedSchemaLoader(root: string): SchemaJsonLoader {
|
|
|
241
245
|
}
|
|
242
246
|
let resolved: string;
|
|
243
247
|
try {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
throw new Error(
|
|
252
|
-
`Schema ref "${path}" does not exist and "${specifier}" is not resolvable from the ` +
|
|
253
|
-
`project or the host`,
|
|
254
|
-
{ cause: error },
|
|
255
|
-
);
|
|
256
|
-
}
|
|
248
|
+
resolved = resolveFromProject(root, specifier);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`Schema ref "${path}" does not exist and "${specifier}" is not resolvable from the ` +
|
|
252
|
+
`project — is the package installed?`,
|
|
253
|
+
{ cause: error },
|
|
254
|
+
);
|
|
257
255
|
}
|
|
258
256
|
return readSchemaFile(resolved);
|
|
259
257
|
};
|
|
@@ -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
|
+
}
|
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
|
|