@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
package/src/site/bundler.ts
CHANGED
|
@@ -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.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
33
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
217
|
-
function
|
|
218
|
-
return
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
235
|
-
|
|
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
|
-
/**
|
|
273
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
|
234
|
-
*
|
|
235
|
-
*
|
|
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
|
-
//
|
|
240
|
-
|
|
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;
|
package/src/site/format-host.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
119
|
+
return {
|
|
120
|
+
manifestPath: resolveFromProject(projectRoot, `${name}/${EXTENSION_MANIFEST}`),
|
|
121
|
+
project: true,
|
|
122
|
+
};
|
|
137
123
|
} catch {
|
|
138
|
-
|
|
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). */
|
package/src/site/image-cache.ts
CHANGED
|
@@ -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 —
|
|
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
|
-
|
|
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
|
-
/**
|
|
19
|
-
|
|
17
|
+
/**
|
|
18
|
+
* A layout loader (`LayoutLoader` in `@jxsuite/site/layout`) over the project's own directory. It
|
|
19
|
+
* reads synchronously, which is what lets {@link layoutChain} walk a chain without awaiting each
|
|
20
|
+
* level, and a synchronous loader is still one `resolveLayout` accepts.
|
|
21
|
+
*/
|
|
22
|
+
export function nodeLayoutLoader(projectRoot: string): (layoutRef: string) => JxDocument {
|
|
20
23
|
return (layoutRef: string) => {
|
|
21
24
|
const layoutPath = resolve(projectRoot, layoutRef);
|
|
22
25
|
if (!existsSync(layoutPath)) {
|
|
@@ -47,3 +50,40 @@ export function resolveLayout(
|
|
|
47
50
|
): Promise<JxDocument> {
|
|
48
51
|
return resolveLayoutWith(pageDoc, projectConfig, nodeLayoutLoader(projectRoot));
|
|
49
52
|
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every layout a page is wrapped in, innermost first, each with the absolute path it was read from.
|
|
56
|
+
*
|
|
57
|
+
* The chain {@link resolveLayout} follows: the page's `$layout`, else `defaults.layout`, then each
|
|
58
|
+
* layout's own `$layout`. A reference that is not a non-empty string ends it, which is how
|
|
59
|
+
* `"$layout": false` opts a page out, and so does a layout already in the chain, so a layout that
|
|
60
|
+
* names itself is listed once rather than followed forever.
|
|
61
|
+
*
|
|
62
|
+
* It exists for what the merged document no longer says: which file declared what. Resolution
|
|
63
|
+
* merges state and splices children, so a relative path a layout wrote is relative to the layout's
|
|
64
|
+
* own file, and only the chain still names that file.
|
|
65
|
+
*
|
|
66
|
+
* @param {JxDocument} pageDoc - The raw page document
|
|
67
|
+
* @param {ProjectConfig} projectConfig - Site configuration (for defaults.layout)
|
|
68
|
+
* @param {string} projectRoot - Project root directory, which every `$layout` is relative to
|
|
69
|
+
* @returns {{ path: string; doc: JxDocument }[]} Each layout's absolute path and document
|
|
70
|
+
*/
|
|
71
|
+
export function layoutChain(
|
|
72
|
+
pageDoc: JxDocument,
|
|
73
|
+
projectConfig: ProjectConfig,
|
|
74
|
+
projectRoot: string,
|
|
75
|
+
): { path: string; doc: JxDocument }[] {
|
|
76
|
+
const load = nodeLayoutLoader(projectRoot);
|
|
77
|
+
const chain: { path: string; doc: JxDocument }[] = [];
|
|
78
|
+
let ref = pageDoc.$layout ?? projectConfig.defaults?.layout;
|
|
79
|
+
while (ref) {
|
|
80
|
+
const path = resolve(projectRoot, ref);
|
|
81
|
+
if (chain.some((level) => level.path === path)) {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
const doc = load(ref);
|
|
85
|
+
chain.push({ doc, path });
|
|
86
|
+
ref = doc.$layout;
|
|
87
|
+
}
|
|
88
|
+
return chain;
|
|
89
|
+
}
|