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