@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "6.1.0",
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
- "@apidevtools/json-schema-ref-parser": "^16.0.5",
49
- "@jxsuite/create": "^1.4.0",
50
- "@jxsuite/runtime": "^5.2.0",
51
- "@jxsuite/schema": "^4.0.0",
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.0",
61
- "@jxsuite/parser": "^4.1.0",
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 { compileServer, compileSiteServer } from "./targets/compile-server.ts";
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(raw, className, [], opts.rewriteSrc);
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
- return compileClient(raw, {
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 [result, server] = await Promise.all([compile(src), compileServer(src)]);
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 { evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
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 = new Map<string, { key: string; exportName: string; src: string }>();
1756
+ const entries: { key: string; exportName: string; src: string }[] = [];
1635
1757
  _walkServerEntries(doc, entries);
1636
- return [...entries.values()];
1758
+ return entries;
1637
1759
  }
1638
1760
 
1639
1761
  /**
1640
1762
  * @param {JxElement | JxMutableNode | string} def
1641
- * @param {Map<string, { key: string; exportName: string; src: string }>} entries
1763
+ * @param {{ key: string; exportName: string; src: string }[]} entries
1642
1764
  */
1643
1765
  function _walkServerEntries(
1644
1766
  def: JxElement | JxMutableNode | string,
1645
- entries: Map<string, { key: string; exportName: string; src: string }>,
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.set(d.$export, { exportName: d.$export, key, src: d.$src });
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
- if (prop.startsWith("&")) {
2350
- return prop.replaceAll("&", scope);
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
- /** A {@link LayoutLoader} over the project's own directory. */
19
- export function nodeLayoutLoader(projectRoot: string): LayoutLoader {
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
+ }