@jxsuite/compiler 1.5.0 → 2.0.2
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 +567 -232
- package/dist/compiler.js +335 -132
- package/package.json +11 -9
- package/src/compiler.ts +1 -1
- package/src/shadow.ts +65 -0
- package/src/shared.ts +234 -52
- package/src/site/bundler.ts +149 -18
- package/src/site/client-runtime.ts +374 -0
- package/src/site/context-injection.ts +45 -2
- package/src/site/csp.ts +224 -0
- package/src/site/head-merger.ts +57 -13
- package/src/site/headers-emitter.ts +232 -0
- package/src/site/i18n.ts +365 -0
- package/src/site/image-optimizer.ts +7 -0
- package/src/site/image-transform.ts +269 -118
- package/src/site/img-loading.ts +55 -0
- package/src/site/link-relations.ts +251 -0
- package/src/site/locale-negotiation.ts +258 -0
- package/src/site/pages-discovery.ts +149 -0
- package/src/site/preview-server.ts +9 -1
- package/src/site/schema-command.ts +109 -18
- package/src/site/service-worker.ts +294 -0
- package/src/site/site-build.ts +903 -95
- package/src/site/site-loader.ts +1 -0
- package/src/site/validate-command.ts +1 -1
- package/src/site/well-known.ts +221 -0
- package/src/targets/compile-client.ts +85 -18
- package/src/targets/compile-element.ts +393 -46
- package/src/targets/compile-server.ts +19 -2
- package/src/targets/compile-static.ts +4 -1
- package/src/types.ts +2 -0
|
@@ -17,6 +17,8 @@ import { extname, join, relative, resolve } from "node:path";
|
|
|
17
17
|
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
18
18
|
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
19
19
|
import type { JxDocument, JxPathsDef, ProjectConfig } from "@jxsuite/schema/types";
|
|
20
|
+
import { localeOfRoute } from "./i18n.ts";
|
|
21
|
+
import type { ResolvedI18n } from "./i18n.ts";
|
|
20
22
|
|
|
21
23
|
interface Route {
|
|
22
24
|
urlPattern: string; // URL pattern (e.g. "/blog/:slug")
|
|
@@ -27,6 +29,13 @@ interface Route {
|
|
|
27
29
|
params: string[]; // Parameter names (e.g. ["slug"])
|
|
28
30
|
$layout: string | null; // Layout override from page frontmatter, if any
|
|
29
31
|
_pathParams?: Record<string, string>; // Resolved path parameters
|
|
32
|
+
/**
|
|
33
|
+
* The timestamp of the thing this concrete route was generated FROM, when that is not the
|
|
34
|
+
* template file. A collection route's source is an entry; `sourcePath` still points at the
|
|
35
|
+
* `[slug].json` that rendered it, so without this every post in an archive reports the template's
|
|
36
|
+
* mtime and the whole collection looks edited whenever the template is.
|
|
37
|
+
*/
|
|
38
|
+
sourceMtime?: string;
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
/**
|
|
@@ -57,6 +66,104 @@ export async function readPageDocument(
|
|
|
57
66
|
return (await entry.call("parse", source)) as JxDocument;
|
|
58
67
|
}
|
|
59
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The `$translationKey` each route's document declares, for the routes that declare one.
|
|
71
|
+
*
|
|
72
|
+
* A **pre-pass**, because a page's alternates depend on the whole route table (§13.5): the set has
|
|
73
|
+
* to be complete before the first page is compiled, so a document cannot tell the build what its
|
|
74
|
+
* key is while it is being compiled.
|
|
75
|
+
*
|
|
76
|
+
* The file is read and the **parse is skipped** unless the text mentions the key. Most pages of a
|
|
77
|
+
* multilingual site never declare one — their paths are parallel and the derivation is right — so
|
|
78
|
+
* paying a second full parse for every page to find a key that is usually absent would be a cost
|
|
79
|
+
* with nothing behind it. Documents sharing a source (a `$paths` template's expansions) are read
|
|
80
|
+
* once.
|
|
81
|
+
*
|
|
82
|
+
* A document that fails to parse is skipped rather than thrown from here. It will fail again a
|
|
83
|
+
* moment later while being compiled, where the error names the page and the rest of the site still
|
|
84
|
+
* builds; failing in a pre-pass would turn one bad page into no site at all.
|
|
85
|
+
*
|
|
86
|
+
* **A key may name its route's parameters**, as `${slug}`, and that is what makes a collection's
|
|
87
|
+
* localized URLs work: one `[slug]` template expands to one route per entry, so a key that could
|
|
88
|
+
* not vary per entry would claim a single identity for the whole collection and the build would
|
|
89
|
+
* report it as a duplicate. `pages/fr-ca/expositions/[slug].json` declaring `"exhibitions/${slug}"`
|
|
90
|
+
* pairs each French post with the English one it translates, because two translations of an entry
|
|
91
|
+
* share an id (§13.3) and the id is what the parameter carries.
|
|
92
|
+
*
|
|
93
|
+
* @param {readonly {
|
|
94
|
+
* sourcePath: string;
|
|
95
|
+
* urlPattern: string;
|
|
96
|
+
* _pathParams?: Record<string, string>;
|
|
97
|
+
* }[]} routes
|
|
98
|
+
* - Concrete routes
|
|
99
|
+
* @param {FormatRegistry} [registry]
|
|
100
|
+
* @returns {Promise<Map<string, string>>} Keyed by `urlPattern`
|
|
101
|
+
*/
|
|
102
|
+
export async function readTranslationKeys(
|
|
103
|
+
routes: readonly {
|
|
104
|
+
sourcePath: string;
|
|
105
|
+
urlPattern: string;
|
|
106
|
+
_pathParams?: Record<string, string> | undefined;
|
|
107
|
+
}[],
|
|
108
|
+
registry?: FormatRegistry,
|
|
109
|
+
): Promise<Map<string, string>> {
|
|
110
|
+
const out = new Map<string, string>();
|
|
111
|
+
const byFile = new Map<string, string | null>();
|
|
112
|
+
for (const route of routes) {
|
|
113
|
+
if (!byFile.has(route.sourcePath)) {
|
|
114
|
+
byFile.set(route.sourcePath, await declaredTranslationKey(route.sourcePath, registry));
|
|
115
|
+
}
|
|
116
|
+
const key = byFile.get(route.sourcePath);
|
|
117
|
+
if (key !== null && key !== undefined) {
|
|
118
|
+
out.set(route.urlPattern, withRouteParams(key, route._pathParams));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Substitute a route's own parameters into a declared key.
|
|
126
|
+
*
|
|
127
|
+
* A parameter with no value is left as it was written rather than blanked: a key that quietly
|
|
128
|
+
* became `exhibitions/` would pair every entry in the collection with every other, which is a
|
|
129
|
+
* duplicate the build reports — and the report naming `${slug}` says what is actually wrong.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} key
|
|
132
|
+
* @param {Record<string, string> | undefined} params
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
function withRouteParams(key: string, params: Record<string, string> | undefined): string {
|
|
136
|
+
if (!key.includes("${")) {
|
|
137
|
+
return key;
|
|
138
|
+
}
|
|
139
|
+
return key.replaceAll(/\$\{(\w+)\}/g, (whole, name: string) => params?.[name] ?? whole);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* One document's `$translationKey`, or null when it declares none or cannot be read.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} sourcePath
|
|
146
|
+
* @param {FormatRegistry} [registry]
|
|
147
|
+
* @returns {Promise<string | null>}
|
|
148
|
+
*/
|
|
149
|
+
async function declaredTranslationKey(
|
|
150
|
+
sourcePath: string,
|
|
151
|
+
registry?: FormatRegistry,
|
|
152
|
+
): Promise<string | null> {
|
|
153
|
+
if (!readFileSync(sourcePath, "utf8").includes("$translationKey")) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
let doc: JxDocument;
|
|
157
|
+
try {
|
|
158
|
+
doc = await readPageDocument(sourcePath, registry);
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
return typeof doc.$translationKey === "string" && doc.$translationKey !== ""
|
|
163
|
+
? doc.$translationKey
|
|
164
|
+
: null;
|
|
165
|
+
}
|
|
166
|
+
|
|
60
167
|
/**
|
|
61
168
|
* Discover all routable pages in a pages/ directory.
|
|
62
169
|
*
|
|
@@ -204,6 +311,35 @@ async function fileToRoute(relativePath: string, absolutePath: string, registry?
|
|
|
204
311
|
};
|
|
205
312
|
}
|
|
206
313
|
|
|
314
|
+
/**
|
|
315
|
+
* The reserved key a `resolvePaths` result may carry alongside its route parameters.
|
|
316
|
+
*
|
|
317
|
+
* A route parameter comes from a `[bracket]` segment in a filename, so this name cannot collide
|
|
318
|
+
* with one by accident, and it matches what the content loader already calls the same data on an
|
|
319
|
+
* entry (`parser.md` §9.3) — so a fact about an entry keeps one name from the file it was read out
|
|
320
|
+
* of all the way to the sitemap.
|
|
321
|
+
*/
|
|
322
|
+
const PATH_ENTRY_META = "_meta";
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The entry timestamp carried on a `$paths` result, or null when there is none.
|
|
326
|
+
*
|
|
327
|
+
* Null is ordinary: the core `$paths` shapes (`values`, `$ref`, a literal array) describe route
|
|
328
|
+
* parameters and nothing else, and a route with no entry of its own correctly falls back to its
|
|
329
|
+
* template's own modification time.
|
|
330
|
+
*
|
|
331
|
+
* @param {Record<string, unknown>} pathEntry
|
|
332
|
+
* @returns {string | null}
|
|
333
|
+
*/
|
|
334
|
+
function entryMtime(pathEntry: Record<string, unknown>): string | null {
|
|
335
|
+
const meta = pathEntry[PATH_ENTRY_META];
|
|
336
|
+
if (meta === null || typeof meta !== "object") {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
const { mtime } = meta as { mtime?: unknown };
|
|
340
|
+
return typeof mtime === "string" && mtime !== "" ? mtime : null;
|
|
341
|
+
}
|
|
342
|
+
|
|
207
343
|
/**
|
|
208
344
|
* Expand dynamic routes by resolving $paths from each dynamic page.
|
|
209
345
|
*
|
|
@@ -226,6 +362,7 @@ export async function expandDynamicRoutes(
|
|
|
226
362
|
sections: Record<string, unknown> = {},
|
|
227
363
|
registry?: ExtensionRegistry,
|
|
228
364
|
projectConfig?: ProjectConfig,
|
|
365
|
+
i18n?: ResolvedI18n | null,
|
|
229
366
|
) {
|
|
230
367
|
const expanded: Route[] = [];
|
|
231
368
|
|
|
@@ -256,23 +393,33 @@ export async function expandDynamicRoutes(
|
|
|
256
393
|
sections,
|
|
257
394
|
registry,
|
|
258
395
|
projectConfig,
|
|
396
|
+
// The template's OWN prefix, read before expansion: `/fr/blog/:slug` is a French route
|
|
397
|
+
// Whatever its entries turn out to be called, and that is what scopes a localized collection.
|
|
398
|
+
localeOfRoute(route.urlPattern, i18n ?? null),
|
|
259
399
|
);
|
|
260
400
|
|
|
261
401
|
for (const pathEntry of pathEntries) {
|
|
262
402
|
let concreteUrl = route.urlPattern;
|
|
263
403
|
const params: Record<string, string> = {};
|
|
264
404
|
for (const [param, value] of Object.entries(pathEntry)) {
|
|
405
|
+
// `_meta` is the reserved carrier for facts about the source ENTRY (extensions.md §8).
|
|
406
|
+
// It is not a route parameter and must never reach substitution.
|
|
407
|
+
if (param === PATH_ENTRY_META) {
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
265
410
|
params[param] = String(value);
|
|
266
411
|
concreteUrl = concreteUrl.replace(`:${param}`, params[param]);
|
|
267
412
|
concreteUrl = concreteUrl.replace("*", params[param]);
|
|
268
413
|
}
|
|
269
414
|
|
|
415
|
+
const mtime = entryMtime(pathEntry);
|
|
270
416
|
expanded.push({
|
|
271
417
|
...route,
|
|
272
418
|
_pathParams: params,
|
|
273
419
|
isCatchAll: false,
|
|
274
420
|
isDynamic: false,
|
|
275
421
|
params: [],
|
|
422
|
+
...(mtime === null ? {} : { sourceMtime: mtime }),
|
|
276
423
|
urlPattern: concreteUrl,
|
|
277
424
|
});
|
|
278
425
|
}
|
|
@@ -301,6 +448,7 @@ async function resolvePathEntries(
|
|
|
301
448
|
sections: Record<string, unknown>,
|
|
302
449
|
registry?: ExtensionRegistry,
|
|
303
450
|
projectConfig?: ProjectConfig,
|
|
451
|
+
locale?: string | null,
|
|
304
452
|
): Promise<Record<string, unknown>[]> {
|
|
305
453
|
// Legacy: array of param objects
|
|
306
454
|
if (Array.isArray($paths)) {
|
|
@@ -345,6 +493,7 @@ async function resolvePathEntries(
|
|
|
345
493
|
}
|
|
346
494
|
return (await entry.call("resolvePaths", $paths, {
|
|
347
495
|
data: sections[entry.project.key],
|
|
496
|
+
locale,
|
|
348
497
|
projectConfig,
|
|
349
498
|
root: projectRoot,
|
|
350
499
|
})) as Record<string, unknown>[];
|
|
@@ -12,7 +12,15 @@ import { createServer } from "node:http";
|
|
|
12
12
|
import type { Server } from "node:http";
|
|
13
13
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
14
14
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
15
|
+
import { MEDIA_TYPE_BY_EXTENSION } from "@jxsuite/schema/media-type";
|
|
15
16
|
|
|
17
|
+
/*
|
|
18
|
+
* The extensions this server can be asked for. `MEDIA_TYPE_BY_EXTENSION` is spread in **last** so
|
|
19
|
+
* the registered spellings win: `.md` carries the `variant` that says which markdown it is, and
|
|
20
|
+
* `.yaml`/`.yml` are `application/yaml` rather than an `application/octet-stream` download prompt.
|
|
21
|
+
* Everything else is this table's own, because a shared table for `image/png` would be a second
|
|
22
|
+
* source of truth for something no standard is ambiguous about.
|
|
23
|
+
*/
|
|
16
24
|
const MIME_TYPES: Record<string, string> = {
|
|
17
25
|
".avif": "image/avif",
|
|
18
26
|
".css": "text/css; charset=utf-8",
|
|
@@ -23,13 +31,13 @@ const MIME_TYPES: Record<string, string> = {
|
|
|
23
31
|
".jpg": "image/jpeg",
|
|
24
32
|
".js": "text/javascript; charset=utf-8",
|
|
25
33
|
".json": "application/json; charset=utf-8",
|
|
26
|
-
".md": "text/markdown; charset=utf-8",
|
|
27
34
|
".png": "image/png",
|
|
28
35
|
".svg": "image/svg+xml",
|
|
29
36
|
".txt": "text/plain; charset=utf-8",
|
|
30
37
|
".webp": "image/webp",
|
|
31
38
|
".woff2": "font/woff2",
|
|
32
39
|
".xml": "application/xml; charset=utf-8",
|
|
40
|
+
...MEDIA_TYPE_BY_EXTENSION,
|
|
33
41
|
};
|
|
34
42
|
|
|
35
43
|
/**
|
|
@@ -37,7 +37,7 @@ const CORE_DOCUMENT_SPECIFIER = "@jxsuite/schema/schema.json";
|
|
|
37
37
|
* @param {string} projectRoot - Absolute path to the project directory
|
|
38
38
|
* @returns {Promise<{ projectSchemaPath: string; documentSchemaPath: string }>} Written paths
|
|
39
39
|
*/
|
|
40
|
-
export async function
|
|
40
|
+
export async function composeEntryDocuments(projectRoot: string) {
|
|
41
41
|
const { config } = loadProjectConfig(projectRoot);
|
|
42
42
|
const registry = await buildProjectExtensionRegistry(projectRoot, config);
|
|
43
43
|
|
|
@@ -58,6 +58,17 @@ export async function writeProjectSchemas(projectRoot: string) {
|
|
|
58
58
|
loadJson: restrictedSchemaLoader(root),
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
+
return { documentSchema, projectSchema };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Compose the entry documents and WRITE them to the project root.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} projectRoot - Absolute path to the project directory
|
|
68
|
+
* @returns {Promise<{ projectSchemaPath: string; documentSchemaPath: string }>} Written paths
|
|
69
|
+
*/
|
|
70
|
+
export async function writeProjectSchemas(projectRoot: string) {
|
|
71
|
+
const { documentSchema, projectSchema } = await composeEntryDocuments(projectRoot);
|
|
61
72
|
const projectSchemaPath = resolve(projectRoot, "project.schema.json");
|
|
62
73
|
const documentSchemaPath = resolve(projectRoot, "document.schema.json");
|
|
63
74
|
writeFileSync(projectSchemaPath, `${JSON.stringify(projectSchema, null, 2)}\n`, "utf8");
|
|
@@ -76,7 +87,11 @@ export async function writeProjectSchemas(projectRoot: string) {
|
|
|
76
87
|
* @param {string} projectRoot - Absolute path to the project directory
|
|
77
88
|
* @returns {Promise<{ project: Record<string, unknown>; document: Record<string, unknown> }>}
|
|
78
89
|
*/
|
|
79
|
-
export async function readBundledProjectSchemas(
|
|
90
|
+
export async function readBundledProjectSchemas(
|
|
91
|
+
projectRoot: string,
|
|
92
|
+
options: { write?: boolean } = {},
|
|
93
|
+
) {
|
|
94
|
+
const { write = true } = options;
|
|
80
95
|
const root = resolve(projectRoot);
|
|
81
96
|
const projectJsonPath = resolve(root, "project.json");
|
|
82
97
|
const projectSchemaPath = resolve(root, "project.schema.json");
|
|
@@ -92,49 +107,125 @@ export async function readBundledProjectSchemas(projectRoot: string) {
|
|
|
92
107
|
return false;
|
|
93
108
|
}
|
|
94
109
|
};
|
|
95
|
-
|
|
96
|
-
|
|
110
|
+
const isStale = stale(projectSchemaPath) || stale(documentSchemaPath);
|
|
111
|
+
// `write: false` is what a VALIDATOR passes. A stale entry document is regenerated in memory and
|
|
112
|
+
// The committed file is left exactly as it is: `jx validate` reports on the repository, and a
|
|
113
|
+
// Checker that edits what it is checking is not a checker. This mattered — running
|
|
114
|
+
// `schema:validate-all` on a machine with a shadowed core silently rewrote the committed
|
|
115
|
+
// Artifact with a narrower one, and it was green either way, because it validated against
|
|
116
|
+
// Whatever it had just written.
|
|
117
|
+
const composed = isStale ? await composeEntryDocuments(root) : null;
|
|
118
|
+
if (composed && write) {
|
|
119
|
+
writeFileSync(
|
|
120
|
+
projectSchemaPath,
|
|
121
|
+
`${JSON.stringify(composed.projectSchema, null, 2)}\n`,
|
|
122
|
+
"utf8",
|
|
123
|
+
);
|
|
124
|
+
writeFileSync(
|
|
125
|
+
documentSchemaPath,
|
|
126
|
+
`${JSON.stringify(composed.documentSchema, null, 2)}\n`,
|
|
127
|
+
"utf8",
|
|
128
|
+
);
|
|
97
129
|
}
|
|
98
130
|
|
|
99
131
|
const loadJson = restrictedSchemaLoader(root);
|
|
100
|
-
const projectFile =
|
|
101
|
-
|
|
102
|
-
unknown
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
string,
|
|
106
|
-
unknown
|
|
107
|
-
>;
|
|
132
|
+
const projectFile =
|
|
133
|
+
composed?.projectSchema ??
|
|
134
|
+
(JSON.parse(readFileSync(projectSchemaPath, "utf8")) as Record<string, unknown>);
|
|
135
|
+
const documentFile =
|
|
136
|
+
composed?.documentSchema ??
|
|
137
|
+
(JSON.parse(readFileSync(documentSchemaPath, "utf8")) as Record<string, unknown>);
|
|
108
138
|
const project = flattenSchema(await bundleSchema(projectFile, loadJson, root));
|
|
109
139
|
const document = flattenSchema(await bundleSchema(documentFile, loadJson, root));
|
|
110
140
|
return { document, project };
|
|
111
141
|
}
|
|
112
142
|
|
|
143
|
+
/** Packages whose schemas define the Jx document contract itself, never a project's own content. */
|
|
144
|
+
const FIRST_PARTY_SCHEMA_SCOPE = "@jxsuite/";
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Is this specifier one of the shipped core schemas, as opposed to a project-local fragment?
|
|
148
|
+
*
|
|
149
|
+
* @param {string} specifier - Bare specifier, e.g. `@jxsuite/schema/schema.json`
|
|
150
|
+
* @returns {boolean}
|
|
151
|
+
*/
|
|
152
|
+
export function isFirstPartySchema(specifier: string): boolean {
|
|
153
|
+
return specifier.startsWith(FIRST_PARTY_SCHEMA_SCOPE) && specifier.endsWith(".json");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Resolve a first-party schema from the HOST — the workspace running the generator.
|
|
158
|
+
*
|
|
159
|
+
* Throws rather than falling back to the project, because a fallback is exactly the behaviour that
|
|
160
|
+
* let a stale published core answer for the workspace one. A generator that cannot find its own
|
|
161
|
+
* schema must stop, not improvise.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} specifier
|
|
164
|
+
* @param {string} path - The original ref, for the error message
|
|
165
|
+
* @returns {string} Absolute path to the resolved schema file
|
|
166
|
+
*/
|
|
167
|
+
function hostResolve(specifier: string, path: string): string {
|
|
168
|
+
try {
|
|
169
|
+
return createRequire(import.meta.url).resolve(specifier);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Schema ref "${path}" names the first-party schema "${specifier}", which must resolve from ` +
|
|
173
|
+
`the host workspace and does not. The project's own copy is deliberately not consulted, so ` +
|
|
174
|
+
`the host install is missing "${specifier.split("/").slice(0, 2).join("/")}" — check that ` +
|
|
175
|
+
`the package ships with this build`,
|
|
176
|
+
{ cause: error },
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
113
181
|
/**
|
|
114
182
|
* Bundler loader restricted to the project root, with the validate-project host fallback: a missing
|
|
115
183
|
* `<root>/node_modules/<specifier>` file resolves the specifier project-first, then through the
|
|
116
184
|
* host (workspace symlinks resolve transparently).
|
|
117
185
|
*
|
|
186
|
+
* Exported for test: both refusals below are reachable only through a project whose `node_modules`
|
|
187
|
+
* has been arranged to provoke them, and a fixture that arranges an extension registry as well says
|
|
188
|
+
* less about the rule than a call that states the path directly.
|
|
189
|
+
*
|
|
118
190
|
* @param {string} root - Absolute project root
|
|
119
191
|
* @returns {SchemaJsonLoader}
|
|
120
192
|
*/
|
|
121
|
-
function restrictedSchemaLoader(root: string): SchemaJsonLoader {
|
|
193
|
+
export function restrictedSchemaLoader(root: string): SchemaJsonLoader {
|
|
122
194
|
return async (path) => {
|
|
123
195
|
const abs = resolve(path);
|
|
124
196
|
const rel = relative(root, abs);
|
|
125
197
|
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
126
198
|
throw new Error(`Schema ref "${path}" escapes the project root ${root}`);
|
|
127
199
|
}
|
|
128
|
-
if (existsSync(abs)) {
|
|
129
|
-
return JSON.parse(readFileSync(abs, "utf8")) as Record<string, unknown>;
|
|
130
|
-
}
|
|
131
200
|
const posixRel = rel.split(sep).join("/");
|
|
132
201
|
const marker = "node_modules/";
|
|
133
202
|
const markerIndex = posixRel.lastIndexOf(marker);
|
|
134
|
-
|
|
203
|
+
const specifier = markerIndex === -1 ? null : posixRel.slice(markerIndex + marker.length);
|
|
204
|
+
// A FIRST-PARTY core comes from the host, always — before the project-local file is even
|
|
205
|
+
// Looked at. A project may legitimately carry its own fragments, which is what the rest of this
|
|
206
|
+
// Loader is for; what it may not do is answer for `@jxsuite/schema` itself.
|
|
207
|
+
//
|
|
208
|
+
// This is not hypothetical. A stray `bun install` in packages/starters/sites/shop left a
|
|
209
|
+
// Gitignored node_modules holding @jxsuite/schema@0.35.0 beside a 1.5.0 workspace, and BOTH the
|
|
210
|
+
// `existsSync` shortcut below and the project-first `createRequire` resolved to it. The
|
|
211
|
+
// Generator then emitted a shop document schema missing Statement, StatementList, PathsValue
|
|
212
|
+
// And the three admission blocks, with ClassMethodDef.role down from 16 members to 6 and
|
|
213
|
+
// ChildrenValue.oneOf down from 3 to 2 — dropping the computed-children branch, which made
|
|
214
|
+
// Shop's own pages/products/[sku].json invalid against its own committed schema. Nothing
|
|
215
|
+
// Noticed for six weeks, because the artifact was only ever regenerated on the machine that
|
|
216
|
+
// Had the shadow.
|
|
217
|
+
if (specifier !== null && isFirstPartySchema(specifier)) {
|
|
218
|
+
return JSON.parse(readFileSync(hostResolve(specifier, path), "utf8")) as Record<
|
|
219
|
+
string,
|
|
220
|
+
unknown
|
|
221
|
+
>;
|
|
222
|
+
}
|
|
223
|
+
if (existsSync(abs)) {
|
|
224
|
+
return JSON.parse(readFileSync(abs, "utf8")) as Record<string, unknown>;
|
|
225
|
+
}
|
|
226
|
+
if (specifier === null) {
|
|
135
227
|
throw new Error(`Schema ref "${path}" does not exist`);
|
|
136
228
|
}
|
|
137
|
-
const specifier = posixRel.slice(markerIndex + marker.length);
|
|
138
229
|
let resolved: string;
|
|
139
230
|
try {
|
|
140
231
|
const projRequire = createRequire(resolve(root, "package.json"));
|