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