@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.
@@ -2,20 +2,30 @@
2
2
  * Bundler.js — timing-aware module bundling for compiled sites
3
3
  *
4
4
  * One backend abstraction serving every bundling need of the build (spec.md §12, compiler.md):
5
- * client-timing `$src` sidecars today, the server worker in a follow-up. Backend selection is
6
- * runtime-adaptive: `Bun.build` when the build runs under Bun, esbuild (dynamically imported) under
7
- * plain Node. Options are kept minimal and identical across backends so output stays as stable as
8
- * possible; set `JX_BUNDLER=esbuild` to force the fallback (e.g. for parity checks).
5
+ * client-timing `$src` sidecars today, the server worker in a follow-up. The backend is chosen at
6
+ * resolution time by the `#bundler-backend` import (`bundler-backend.ts`): `Bun.build` under Bun,
7
+ * esbuild everywhere else, so a Node build of this module carries no Bun code. Options are kept
8
+ * minimal and identical across backends so output stays as stable as possible; set
9
+ * `JX_BUNDLER=esbuild` to force esbuild under Bun (e.g. for parity checks).
9
10
  *
10
11
  * `timing: "compiler"` code is never bundled — it executes in the build host via the extension
11
12
  * registry's `importImplementation` path.
12
13
  */
13
14
 
14
15
  import { rmSync, writeFileSync } from "node:fs";
15
- import { createRequire } from "node:module";
16
16
  import { tmpdir } from "node:os";
17
17
  import { basename, join, resolve } from "node:path";
18
18
  import { isNpmSpecifier, NPM_SPECIFIER_PREFIX } from "@jxsuite/schema/asset-paths";
19
+ import * as backend from "#bundler-backend";
20
+ import type {
21
+ BackendBuild,
22
+ BundlePlugin,
23
+ BundleTargetName,
24
+ PluginBuild,
25
+ } from "./bundler-backend.ts";
26
+ import { TRANSIENT_ENTRY_PREFIX } from "./bundler-backend.ts";
27
+
28
+ export type { BundleTargetName } from "./bundler-backend.ts";
19
29
 
20
30
  /** Import specifiers left external in client bundles — provided by the page importmap. */
21
31
  export const CLIENT_EXTERNALS = ["@vue/reactivity", "lit-html"];
@@ -29,11 +39,11 @@ export function isBundleableSrc(specifier: string): boolean {
29
39
  }
30
40
 
31
41
  /**
32
- * True when the Node code path (createRequire resolution, esbuild backend) should be used: always
33
- * under plain Node, or when `JX_BUNDLER=esbuild` forces it for parity checks.
42
+ * True when the esbuild backend is the one bundling: always outside Bun, or when
43
+ * `JX_BUNDLER=esbuild` forces it for parity checks.
34
44
  */
35
45
  export function useNodeFallback(): boolean {
36
- return typeof Bun === "undefined" || process.env.JX_BUNDLER === "esbuild";
46
+ return backend.backendName() === "esbuild";
37
47
  }
38
48
 
39
49
  /**
@@ -47,15 +57,19 @@ export function resolveSidecarEntry(
47
57
  projectRoot: string,
48
58
  ): string {
49
59
  if (isNpmSpecifier(specifier)) {
50
- const bare = specifier.slice(NPM_SPECIFIER_PREFIX.length);
51
- if (useNodeFallback()) {
52
- return createRequire(join(projectRoot, "package.json")).resolve(bare);
53
- }
54
- return Bun.resolveSync(bare, projectRoot);
60
+ return backend.resolveBare(specifier.slice(NPM_SPECIFIER_PREFIX.length), projectRoot);
55
61
  }
56
62
  return resolve(docDir, specifier);
57
63
  }
58
64
 
65
+ /**
66
+ * Resolve a bare specifier as an import from a module in `fromDir`, through the active backend's
67
+ * resolver. Throws when it does not resolve.
68
+ */
69
+ export function resolveBareSpecifier(specifier: string, fromDir: string): string {
70
+ return backend.resolveBare(specifier, fromDir);
71
+ }
72
+
59
73
  export interface BundleRequest {
60
74
  /** Absolute path of the module to bundle. */
61
75
  entryPath: string;
@@ -63,9 +77,6 @@ export interface BundleRequest {
63
77
  outfile: string;
64
78
  }
65
79
 
66
- /** Resolution/runtime targets: client sidecars use "browser"; the site worker uses the rest. */
67
- export type BundleTargetName = "browser" | "node" | "bun" | "workerd";
68
-
69
80
  /**
70
81
  * A resolution hook, in the one shape both backends accept.
71
82
  *
@@ -110,26 +121,6 @@ export interface BundleOptions {
110
121
  onFileLoaded?: (path: string) => void;
111
122
  }
112
123
 
113
- /**
114
- * The part of a plugin builder the hooks use, typed so that both Bun's `PluginBuilder` and
115
- * esbuild's `PluginBuild` are assignable to it: a plugin written against it is a plugin for either
116
- * backend.
117
- */
118
- interface PluginBuild {
119
- onResolve: (
120
- options: { filter: RegExp },
121
- callback: (args: { path: string; importer: string }) => ReturnType<BundleResolver["resolve"]>,
122
- ) => void;
123
- /** An `undefined` result hands the file back to the backend's own loader. */
124
- onLoad: (options: { filter: RegExp }, callback: (args: { path: string }) => undefined) => void;
125
- }
126
-
127
- /** A plugin in the one shape both `Bun.build` and `esbuild.build` accept. */
128
- interface BundlePlugin {
129
- name: string;
130
- setup: (build: PluginBuild) => void;
131
- }
132
-
133
124
  /** Wrap the hooks as the one-plugin array both backends take. */
134
125
  function hookPlugins(opts: BundleOptions): BundlePlugin[] {
135
126
  const { onFileLoaded, resolver } = opts;
@@ -213,17 +204,15 @@ function sharedResolution(opts: BundleOptions): SharedResolution {
213
204
  return shared;
214
205
  }
215
206
 
216
- /** Map a bundle target to esbuild's platform (conditions carry the workerd specifics). */
217
- function esbuildPlatform(target: BundleTargetName): "browser" | "node" {
218
- return target === "node" || target === "bun" ? "node" : "browser";
219
- }
220
-
221
- /** Map a bundle target to Bun.build's target. */
222
- function bunTarget(target: BundleTargetName): "browser" | "node" | "bun" {
223
- if (target === "workerd") {
224
- return "browser";
225
- }
226
- return target;
207
+ /** Every option {@link BundleOptions} decides, in the shape a backend takes. */
208
+ function backendOptions(opts: BundleOptions): Omit<BackendBuild, "entry"> {
209
+ return {
210
+ ...sharedResolution(opts),
211
+ external: opts.external ?? [],
212
+ minify: shouldMinify(opts),
213
+ plugins: hookPlugins(opts),
214
+ target: opts.target,
215
+ };
227
216
  }
228
217
 
229
218
  /**
@@ -231,46 +220,19 @@ function bunTarget(target: BundleTargetName): "browser" | "node" | "bun" {
231
220
  * diagnostics on failure.
232
221
  */
233
222
  export async function bundleEntry(request: BundleRequest, opts: BundleOptions): Promise<void> {
234
- const external = opts.external ?? [];
235
- const plugins = hookPlugins(opts);
236
-
237
- if (useNodeFallback()) {
238
- // Dynamic import keeps esbuild (a native-binary dependency) out of Bun-run builds entirely.
239
- const esbuild = await import("esbuild");
240
- await esbuild.build({
241
- bundle: true,
242
- ...sharedResolution(opts),
243
- entryPoints: [request.entryPath],
244
- external,
245
- format: "esm",
246
- logLevel: "silent",
247
- minify: shouldMinify(opts),
248
- outfile: request.outfile,
249
- platform: esbuildPlatform(opts.target),
250
- plugins,
251
- });
252
- return;
253
- }
254
-
255
- const result = await Bun.build({
256
- ...sharedResolution(opts),
257
- entrypoints: [request.entryPath],
258
- external,
259
- format: "esm",
260
- minify: shouldMinify(opts),
261
- plugins,
262
- target: bunTarget(opts.target),
263
- throw: false,
264
- });
265
- if (!result.success || result.outputs.length === 0) {
266
- const logs = result.logs.map(String).join("\n");
267
- throw new Error(`Bun.build failed for ${request.entryPath}:\n${logs}`);
268
- }
269
- writeFileSync(request.outfile, await result.outputs[0]!.text(), "utf8");
223
+ const code = await backend.build({ entry: { path: request.entryPath }, ...backendOptions(opts) });
224
+ writeFileSync(request.outfile, code, "utf8");
270
225
  }
271
226
 
272
- /** Basename prefix of the throwaway entry {@link bundleSource} writes for the Bun backend. */
273
- const TRANSIENT_ENTRY_PREFIX = ".jx-entry-";
227
+ /**
228
+ * Bundle one entry module and return the code rather than writing it.
229
+ *
230
+ * For a host serving modules on demand (the dev server's and the desktop project server's bare
231
+ * specifier routes, the live preview's runtime): what it needs is the text for a response body.
232
+ */
233
+ export async function bundleToString(entryPath: string, opts: BundleOptions): Promise<string> {
234
+ return backend.build({ entry: { path: entryPath }, ...backendOptions(opts) });
235
+ }
274
236
 
275
237
  /**
276
238
  * True for the throwaway entry a {@link bundleSource} call is bundling, under either backend.
@@ -328,31 +290,8 @@ export async function bundleSource(
328
290
  { resolveDir, outfile }: { resolveDir: string; outfile: string },
329
291
  opts: BundleOptions,
330
292
  ): Promise<void> {
331
- if (useNodeFallback()) {
332
- const esbuild = await import("esbuild");
333
- await esbuild.build({
334
- bundle: true,
335
- ...sharedResolution(opts),
336
- external: opts.external ?? [],
337
- format: "esm",
338
- logLevel: "silent",
339
- minify: shouldMinify(opts),
340
- outfile,
341
- platform: esbuildPlatform(opts.target),
342
- stdin: { contents: source, loader: "js", resolveDir, sourcefile: "jx-entry.js" },
343
- });
344
- return;
345
- }
346
-
347
- // Bun.build has no stdin input — write a transient entry in `resolveDir` so resolution matches
348
- // The stdin/resolveDir behavior, and always remove it.
349
- const entryPath = join(resolveDir, `${TRANSIENT_ENTRY_PREFIX}${process.pid}.mjs`);
350
- writeFileSync(entryPath, source, "utf8");
351
- try {
352
- await bundleEntry({ entryPath, outfile }, opts);
353
- } finally {
354
- rmSync(entryPath, { force: true });
355
- }
293
+ const code = await backend.build({ entry: { resolveDir, source }, ...backendOptions(opts) });
294
+ writeFileSync(outfile, code, "utf8");
356
295
  }
357
296
 
358
297
  /**
@@ -16,14 +16,13 @@
16
16
  */
17
17
 
18
18
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
19
- import { createRequire } from "node:module";
20
19
  import { dirname, join, relative, resolve } from "node:path";
21
20
  import type { BundleResolver } from "./bundler.ts";
22
21
  import {
23
22
  bundleSource,
24
23
  isTransientEntry,
24
+ resolveBareSpecifier,
25
25
  resolveThroughBundler,
26
- useNodeFallback,
27
26
  } from "./bundler.ts";
28
27
  import { DEFAULT_LIT_HTML_SRC, DEFAULT_REACTIVITY_SRC } from "../shared.ts";
29
28
  import { isJsonObject, isNonNullObject, isString } from "@jxsuite/schema/guards";
@@ -230,18 +229,14 @@ export function renderImportMap(imports: Record<string, string>): string {
230
229
  /**
231
230
  * True when a bare specifier resolves from `resolveDir` under either backend.
232
231
  *
233
- * The backend choice is `bundler.ts`'s to make, not a second inline `typeof Bun` check: resolution
234
- * and bundling must agree about which one they are on, or the import map promises a module the
235
- * bundle then fails to produce.
232
+ * The backend's own resolver answers, not a second inline choice: resolution and bundling must
233
+ * agree about which one they are on, or the import map promises a module the bundle then fails to
234
+ * produce.
236
235
  */
237
236
  function canResolve(specifier: string, resolveDir: string): boolean {
238
237
  try {
239
- // Both resolvers throw when the specifier does not resolve, and return its path when it does.
240
- if (useNodeFallback()) {
241
- createRequire(join(resolveDir, "x.js")).resolve(specifier);
242
- } else {
243
- Bun.resolveSync(specifier, resolveDir);
244
- }
238
+ // The resolver throws when the specifier does not resolve, and returns its path when it does.
239
+ resolveBareSpecifier(specifier, resolveDir);
245
240
  return true;
246
241
  } catch {
247
242
  return false;
@@ -62,7 +62,23 @@ export async function importImplementation(path: string): Promise<Implementation
62
62
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
63
63
  }
64
64
 
65
- /** Create a node FormatHostIO rooted at a project (bare specifiers resolve via node_modules). */
65
+ /**
66
+ * Resolve a bare specifier from the PROJECT's own node_modules, and nowhere else.
67
+ *
68
+ * There is deliberately no host fallback (specs/extensions.md §3). Extensions, first-party ones
69
+ * included, reach a project only through its own install, the way a third-party extension must: a
70
+ * host that answered for `@jxsuite/parser` would let a project missing that dependency edit on one
71
+ * machine and fail to build on the next.
72
+ *
73
+ * @param {string} projectRoot
74
+ * @param {string} specifier - Bare specifier, e.g. "@jxsuite/parser/jx-extension.json"
75
+ * @returns {string} Absolute resolved path
76
+ */
77
+ export function resolveFromProject(projectRoot: string, specifier: string): string {
78
+ return createRequire(resolve(projectRoot, "package.json")).resolve(specifier);
79
+ }
80
+
81
+ /** Create a node FormatHostIO rooted at a project (bare specifiers resolve from its node_modules). */
66
82
  export function createNodeFormatIO(projectRoot: string): FormatHostIO {
67
83
  return {
68
84
  importModule: (path) => importImplementation(path),
@@ -74,75 +90,39 @@ export function createNodeFormatIO(projectRoot: string): FormatHostIO {
74
90
  if (isAbsolute(ref)) {
75
91
  return ref;
76
92
  }
77
- // Bare specifier — prefer the project's own node_modules, then fall back to the host's
78
- // Resolution (the desktop app and dev server ship @jxsuite/parser, so format classes work
79
- // Even when the project's dependencies are not installed).
80
- const projRequire = createRequire(resolve(projectRoot, "package.json"));
81
- try {
82
- return projRequire.resolve(ref);
83
- } catch {
84
- const selfRequire = createRequire(import.meta.url);
85
- return selfRequire.resolve(ref);
86
- }
93
+ return resolveFromProject(projectRoot, ref);
87
94
  },
88
95
  };
89
96
  }
90
97
 
91
- /**
92
- * Where an extension package's manifest resolves from — the two answers `createNodeFormatIO`
93
- * merges.
94
- */
98
+ /** Where an extension package's manifest resolves from the project, if it does. */
95
99
  export interface ExtensionResolution {
96
- /** Resolvable from the PROJECT's own node_modules. */
100
+ /** Resolvable from the PROJECT's own node_modules — the only place an extension resolves from. */
97
101
  project: boolean;
98
- /** Resolvable from the HOST's module graph — `createNodeFormatIO`'s fallback branch. */
99
- host: boolean;
100
- /** The manifest path from whichever probe answered; the project wins, matching resolution order. */
102
+ /** The resolved manifest path, when `project` is true. */
101
103
  manifestPath?: string;
102
104
  }
103
105
 
104
106
  /**
105
- * Split `createNodeFormatIO`'s two-branch resolution into its two ANSWERS.
106
- *
107
- * The IO deliberately collapses them (project first, then host) because a caller loading a class
108
- * only needs the file. A catalogue needs them apart: "the project installed this" and "this host
109
- * ships it" are different affordances — install-then-enable versus enable — and a surface that
110
- * cannot tell them apart offers the wrong one.
107
+ * Probe whether a project resolves an extension package, without loading it.
111
108
  *
112
109
  * Probes the MANIFEST rather than the package directory, because the manifest is what the registry
113
110
  * resolves (see `loadExtension`): a package present in node_modules whose `exports` map omits
114
111
  * `./jx-extension.json` is one whose enablement would throw, so it must not read as resolvable.
115
112
  *
116
- * @param {string} projectRoot - The project whose node_modules is tried first
113
+ * @param {string} projectRoot - The project whose node_modules is probed
117
114
  * @param {string} name - Bare package specifier, e.g. "@jxsuite/parser"
118
115
  * @returns {ExtensionResolution}
119
116
  */
120
117
  export function probeExtension(projectRoot: string, name: string): ExtensionResolution {
121
- const ref = `${name}/${EXTENSION_MANIFEST}`;
122
- let manifestPath: string | undefined;
123
-
124
- let project = false;
125
- try {
126
- manifestPath = createRequire(resolve(projectRoot, "package.json")).resolve(ref);
127
- project = true;
128
- } catch {
129
- // Not installed in the project. The host probe below decides whether it resolves at all.
130
- }
131
-
132
- let host = false;
133
118
  try {
134
- const fromHost = createRequire(import.meta.url).resolve(ref);
135
- host = true;
136
- manifestPath ??= fromHost;
119
+ return {
120
+ manifestPath: resolveFromProject(projectRoot, `${name}/${EXTENSION_MANIFEST}`),
121
+ project: true,
122
+ };
137
123
  } catch {
138
- // Not shipped by this host either.
124
+ return { project: false };
139
125
  }
140
-
141
- const resolution: ExtensionResolution = { host, project };
142
- if (manifestPath !== undefined) {
143
- resolution.manifestPath = manifestPath;
144
- }
145
- return resolution;
146
126
  }
147
127
 
148
128
  /** Build the extension registry for a project from its project.json `extensions` array. */
@@ -157,6 +137,76 @@ export async function buildProjectExtensionRegistry(
157
137
  );
158
138
  }
159
139
 
140
+ /** A registry of what a project has installed, and the enabled packages it has not. */
141
+ export interface InstalledExtensionRegistry {
142
+ registry: ExtensionRegistry;
143
+ /** Bare specifiers `project.json` enables whose manifest the project does not resolve. */
144
+ missing: string[];
145
+ }
146
+
147
+ /**
148
+ * Build the registry an EDITOR needs: every enabled extension the project resolves, skipping the
149
+ * package ones it has not installed yet.
150
+ *
151
+ * A build must fail on a missing extension, and does, through {@link buildProjectExtensionRegistry}.
152
+ * A studio must not: a project opened before its install has run resolves none of its package
153
+ * extensions, and the format list, the extensions panel and the class picker should answer with
154
+ * what is there rather than with an error. `missing` says what was skipped, so the host can say so
155
+ * once instead of logging a resolution stack per request. Relative (local) extensions are never
156
+ * skipped, because no install produces them.
157
+ *
158
+ * @param {string} projectRoot
159
+ * @param {ProjectConfig} [projectConfig]
160
+ * @returns {Promise<InstalledExtensionRegistry>}
161
+ */
162
+ export async function buildInstalledExtensionRegistry(
163
+ projectRoot: string,
164
+ projectConfig?: ProjectConfig,
165
+ ): Promise<InstalledExtensionRegistry> {
166
+ const missing: string[] = [];
167
+ const present: string[] = [];
168
+ for (const specifier of projectConfig?.extensions ?? []) {
169
+ const local = specifier.startsWith("./") || specifier.startsWith("../");
170
+ if (local || probeExtension(projectRoot, specifier).project) {
171
+ present.push(specifier);
172
+ } else {
173
+ missing.push(specifier);
174
+ }
175
+ }
176
+ const registry = await buildExtensionRegistry(
177
+ present,
178
+ createNodeFormatIO(projectRoot),
179
+ resolve(projectRoot, "project.json"),
180
+ );
181
+ return { missing, registry };
182
+ }
183
+
184
+ /**
185
+ * Whether any of a previous build's `missing` packages resolves now — the cache check a host runs
186
+ * so that a registry built before an install does not outlive it.
187
+ *
188
+ * @param {string} projectRoot
189
+ * @param {string[]} missing
190
+ * @returns {boolean}
191
+ */
192
+ export function anyNowInstalled(projectRoot: string, missing: string[]): boolean {
193
+ return missing.some((specifier) => probeExtension(projectRoot, specifier).project);
194
+ }
195
+
196
+ /**
197
+ * The one line a host logs for the packages {@link buildInstalledExtensionRegistry} skipped.
198
+ *
199
+ * @param {string[]} missing
200
+ * @returns {string}
201
+ */
202
+ export function missingExtensionsNotice(missing: string[]): string {
203
+ const names = missing.map((m) => `"${m}"`).join(", ");
204
+ return (
205
+ `project.json enables ${names}, which this project has not installed, so ` +
206
+ `${missing.length === 1 ? "it is" : "they are"} not loaded. Install the project's dependencies.`
207
+ );
208
+ }
209
+
160
210
  // ─── Studio extensions payload ────────────────────────────────────────────────
161
211
 
162
212
  /** One project-section contribution on the studio wire (specs/extensions.md §9/§9.1). */
@@ -52,30 +52,56 @@ export function _testSetNpmCacheBase(value: string | null) {
52
52
  _npmCacheBase = value;
53
53
  }
54
54
 
55
+ /**
56
+ * How long the npm probe may run before the build stops waiting and caches in the project.
57
+ *
58
+ * `npm config get cache` answers in a fraction of a second, but nothing bounded it, and the call is
59
+ * synchronous: when npm hung, the first build of the process hung with it. On a CI runner it
60
+ * stalled past both of the dev server's 5 s and 20 s test timeouts with nothing to show for it but
61
+ * "killed 1 dangling process". Missing the npm cache only costs persistence across builds.
62
+ */
63
+ const NPM_PROBE_TIMEOUT_MS = 3000;
64
+
65
+ /**
66
+ * Where npm keeps its cache, or null when npm cannot say.
67
+ *
68
+ * Npm reads its configuration from the environment as well as from `.npmrc`, and the environment
69
+ * wins, so a value there is npm's own answer and no process is needed to ask for it. A script run
70
+ * by a package manager usually has it there.
71
+ */
72
+ function npmCacheBase(): string | null {
73
+ const fromEnv = process.env.npm_config_cache ?? process.env.NPM_CONFIG_CACHE;
74
+ if (fromEnv) {
75
+ return fromEnv;
76
+ }
77
+ try {
78
+ const result = execSync("npm config get cache", {
79
+ cwd: tmpdir(), // Avoid ENOWORKSPACES when cwd is inside an npm workspace
80
+ encoding: "utf8",
81
+ stdio: ["ignore", "pipe", "ignore"],
82
+ timeout: NPM_PROBE_TIMEOUT_MS,
83
+ }).trim();
84
+ return result && result !== "undefined" ? result : null;
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
55
90
  /**
56
91
  * Return the image cache directory for a project.
57
92
  *
58
93
  * Prefers the npm global cache (`npm config get cache`) so CI environments that cache ~/.npm (e.g.
59
94
  * Cloudflare Pages) automatically persist optimized images across builds. Falls back to
60
- * .cache/images inside the project root when npm is unavailable.
95
+ * .cache/images inside the project root when npm is unavailable or does not answer in time.
61
96
  *
62
- * Result is memoized — `npm config get cache` is only spawned once per process.
97
+ * Result is memoized — npm is asked at most once per process.
63
98
  *
64
99
  * @param {string} projectRoot
65
100
  * @returns {string}
66
101
  */
67
102
  export function getImageCacheDir(projectRoot: string): string {
68
103
  if (_npmCacheBase === undefined) {
69
- try {
70
- const result = execSync("npm config get cache", {
71
- cwd: tmpdir(), // Avoid ENOWORKSPACES when cwd is inside an npm workspace
72
- encoding: "utf8",
73
- stdio: ["ignore", "pipe", "ignore"],
74
- }).trim();
75
- _npmCacheBase = result && result !== "undefined" ? result : null;
76
- } catch {
77
- _npmCacheBase = null;
78
- }
104
+ _npmCacheBase = npmCacheBase();
79
105
  }
80
106
  if (_npmCacheBase) {
81
107
  return resolve(_npmCacheBase, "jxsuite-images", basename(projectRoot));
@@ -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
+ }