@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.
@@ -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
- /** Packages whose schemas define the Jx document contract itself, never a project's own content. */
160
- const FIRST_PARTY_SCHEMA_SCOPE = "@jxsuite/";
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 a project-local fragment?
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 isFirstPartySchema(specifier: string): boolean {
169
- return specifier.startsWith(FIRST_PARTY_SCHEMA_SCOPE) && specifier.endsWith(".json");
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 a first-party schema from the HOST — the workspace running the generator.
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 first-party schema "${specifier}", which must resolve from ` +
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, with the validate-project host fallback: a missing
199
- * `<root>/node_modules/<specifier>` file resolves the specifier project-first, then through the
200
- * host (workspace symlinks resolve transparently).
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
- // A FIRST-PARTY core comes from the host, always — before the project-local file is even
221
- // Looked at. A project may legitimately carry its own fragments, which is what the rest of this
222
- // Loader is for; what it may not do is answer for `@jxsuite/schema` itself.
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 && isFirstPartySchema(specifier)) {
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
- const projRequire = createRequire(resolve(root, "package.json"));
245
- resolved = projRequire.resolve(specifier);
246
- } catch {
247
- try {
248
- const selfRequire = createRequire(import.meta.url);
249
- resolved = selfRequire.resolve(specifier);
250
- } catch (error) {
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
+ }
@@ -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, compileServer, compileSiteServer } from "../compiler.ts";
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 — set ` +
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 from components (for site-wide bundling) ──
565
- const siteServerEntries: { exportName: string; src: string }[] = [];
566
- if (projectConfig.build.adapter) {
567
- for (const [, doc] of componentDefs) {
568
- const entries = collectServerEntries(doc);
569
- for (const entry of entries) {
570
- const resolvedSrc = `./components/${entry.src.replace(/^\.\//, "")}`;
571
- siteServerEntries.push({
572
- exportName: entry.exportName,
573
- src: resolvedSrc,
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
- const deduped = new Map<string, { exportName: string; src: string }>();
917
- for (const entry of siteServerEntries) {
918
- if (!deduped.has(entry.exportName)) {
919
- deduped.set(entry.exportName, entry);
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 = adapter === "cloudflare-pages" && deduped.size === 0 && mounts.length === 0;
988
+ const skipWorker =
989
+ adapter === "cloudflare-pages" && serverEntries.length === 0 && mounts.length === 0;
936
990
  const workerSource = skipWorker
937
991
  ? null
938
- : compileSiteServer([...deduped.values()], {
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, ${deduped.size} server function(s))`);
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
- // `/` joins the include list only when there is a locale to negotiate: Pages invokes
970
- // The worker for included paths only, so an uninvoked middleware is an inert one.
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
- * serverHandler: string | null;
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
- serverHandler,
1707
+ serverEntries,
1646
1708
  };
1647
1709
  }
1648
1710