@jxsuite/compiler 1.3.0 → 1.5.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 +167 -168
- package/dist/compiler.js +105 -107
- package/package.json +8 -7
- package/src/shared.ts +236 -8
- package/src/site/asset-mounts.ts +140 -0
- package/src/site/image-transform.ts +25 -7
- package/src/site/schema-command.ts +37 -77
- package/src/site/site-build.ts +96 -15
- package/src/site/validate-command.ts +133 -37
- package/src/targets/compile-client.ts +63 -67
- package/src/targets/compile-element.ts +246 -31
- package/src/targets/compile-static.ts +8 -2
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Composes the per-project entry documents (project.schema.json and document.schema.json) from the
|
|
5
5
|
* core schemas and each enabled extension's shipped fragments, then writes them into the project
|
|
6
|
-
* root as committed, SELF-CONTAINED
|
|
7
|
-
* {@link bundleSchema}
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* root as committed, SELF-CONTAINED, SINGLE-RESOURCE schemas: every fragment ref is resolved
|
|
7
|
+
* through {@link bundleSchema} (embedding the core + fragment resources under `$defs`) and then
|
|
8
|
+
* collapsed by {@link flattenSchema}, which drops the embedded `$id`s and rewrites every ref to a
|
|
9
|
+
* root-relative JSON Pointer. Both passes are needed for one resolution behavior across all
|
|
10
|
+
* consumers: relative `./node_modules/...` refs only resolve through Node module resolution, which
|
|
11
|
+
* editors do not perform (hoisted workspaces leave projects without their own node_modules), while
|
|
12
|
+
* `$id`-scoped refs inside a compound document resolve only in a fully compliant validator — VS
|
|
13
|
+
* Code's JSON language service and Monaco resolve `#/...` against the document root and fetch
|
|
14
|
+
* anything with a URI before the `#` over the network. Root pointers are what ajv, the language
|
|
15
|
+
* service, and Monaco all agree on.
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
18
|
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -16,8 +20,8 @@ import { createRequire } from "node:module";
|
|
|
16
20
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
17
21
|
import {
|
|
18
22
|
bundleSchema,
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
composeProjectSchemas,
|
|
24
|
+
flattenSchema,
|
|
21
25
|
} from "@jxsuite/schema/project-schemas";
|
|
22
26
|
import { buildProjectExtensionRegistry } from "./format-host.ts";
|
|
23
27
|
import { loadProjectConfig } from "./site-loader.ts";
|
|
@@ -37,44 +41,22 @@ export async function writeProjectSchemas(projectRoot: string) {
|
|
|
37
41
|
const { config } = loadProjectConfig(projectRoot);
|
|
38
42
|
const registry = await buildProjectExtensionRegistry(projectRoot, config);
|
|
39
43
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
for (const ext of registry.extensions) {
|
|
45
|
-
const projectFragment = ext.schemas.project;
|
|
46
|
-
if (projectFragment) {
|
|
47
|
-
fragments.push(fragmentRef(projectRoot, ext, projectFragment));
|
|
48
|
-
}
|
|
49
|
-
const documentFragment = ext.schemas.document;
|
|
50
|
-
if (documentFragment) {
|
|
51
|
-
pathsValueRefs.push(...fragmentDefsRefs(documentFragment));
|
|
52
|
-
}
|
|
53
|
-
// The `schemas.fields` manifest convention: a fragment whose $defs members are extension
|
|
54
|
-
// Field extras, unioned into the per-project field resource (specs/extensions.md §5.3).
|
|
55
|
-
const fieldsFragment = ext.schemas.fields;
|
|
56
|
-
if (fieldsFragment) {
|
|
57
|
-
fieldSchemaRefs.push(...fragmentDefsRefs(fieldsFragment));
|
|
58
|
-
fragments.push(fragmentRef(projectRoot, ext, fieldsFragment));
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Bundle before committing: the emitted relative refs are an intermediate form only.
|
|
44
|
+
/* Composition itself is host-agnostic (@jxsuite/schema `composeProjectSchemas`) — the filesystem
|
|
45
|
+
appears only in what this host contributes: project-relative refs for the fragments the
|
|
46
|
+
registry resolved, and a loader restricted to the project root. The cloud session composes the
|
|
47
|
+
same two documents from bundled artifacts through the same function. */
|
|
63
48
|
const root = resolve(projectRoot);
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
loadJson,
|
|
76
|
-
root,
|
|
77
|
-
);
|
|
49
|
+
const { document: documentSchema, project: projectSchema } = await composeProjectSchemas({
|
|
50
|
+
baseDir: root,
|
|
51
|
+
coreDocumentRef: coreSchemaRef(projectRoot, CORE_DOCUMENT_SPECIFIER),
|
|
52
|
+
coreProjectRef: coreSchemaRef(projectRoot, CORE_PROJECT_SPECIFIER),
|
|
53
|
+
extensions: registry.extensions.map((ext) => ({
|
|
54
|
+
document: ext.schemas.document && fragmentRef(projectRoot, ext, ext.schemas.document),
|
|
55
|
+
fields: ext.schemas.fields && fragmentRef(projectRoot, ext, ext.schemas.fields),
|
|
56
|
+
project: ext.schemas.project && fragmentRef(projectRoot, ext, ext.schemas.project),
|
|
57
|
+
})),
|
|
58
|
+
loadJson: restrictedSchemaLoader(root),
|
|
59
|
+
});
|
|
78
60
|
|
|
79
61
|
const projectSchemaPath = resolve(projectRoot, "project.schema.json");
|
|
80
62
|
const documentSchemaPath = resolve(projectRoot, "document.schema.json");
|
|
@@ -115,16 +97,16 @@ export async function readBundledProjectSchemas(projectRoot: string) {
|
|
|
115
97
|
}
|
|
116
98
|
|
|
117
99
|
const loadJson = restrictedSchemaLoader(root);
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
);
|
|
100
|
+
const projectFile = JSON.parse(readFileSync(projectSchemaPath, "utf8")) as Record<
|
|
101
|
+
string,
|
|
102
|
+
unknown
|
|
103
|
+
>;
|
|
104
|
+
const documentFile = JSON.parse(readFileSync(documentSchemaPath, "utf8")) as Record<
|
|
105
|
+
string,
|
|
106
|
+
unknown
|
|
107
|
+
>;
|
|
108
|
+
const project = flattenSchema(await bundleSchema(projectFile, loadJson, root));
|
|
109
|
+
const document = flattenSchema(await bundleSchema(documentFile, loadJson, root));
|
|
128
110
|
return { document, project };
|
|
129
111
|
}
|
|
130
112
|
|
|
@@ -231,25 +213,3 @@ function projectRelativeRef(projectRoot: string, absPath: string, conventional:
|
|
|
231
213
|
}
|
|
232
214
|
return rel.startsWith("./") ? rel : `./${rel}`;
|
|
233
215
|
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Canonical "uri#/pointer" refs into a fragment's `$defs` shapes: one per `$defs` entry, addressed
|
|
237
|
-
* by the fragment's own `$id` (refs inside the entry document's embeds resolve against canonical
|
|
238
|
-
* URIs, never file locations). Serves both the document paths union and the fields-extras union.
|
|
239
|
-
*
|
|
240
|
-
* @param {string} fragmentPath - Resolved absolute fragment path
|
|
241
|
-
* @returns {string[]}
|
|
242
|
-
*/
|
|
243
|
-
function fragmentDefsRefs(fragmentPath: string): string[] {
|
|
244
|
-
let fragment: { $id?: unknown; $defs?: Record<string, unknown> };
|
|
245
|
-
try {
|
|
246
|
-
fragment = JSON.parse(readFileSync(fragmentPath, "utf8")) as typeof fragment;
|
|
247
|
-
} catch {
|
|
248
|
-
return [];
|
|
249
|
-
}
|
|
250
|
-
const { $id } = fragment;
|
|
251
|
-
if (typeof $id !== "string" || !fragment.$defs) {
|
|
252
|
-
return [];
|
|
253
|
-
}
|
|
254
|
-
return Object.keys(fragment.$defs).map((name) => `${$id}#/$defs/${name}`);
|
|
255
|
-
}
|
package/src/site/site-build.ts
CHANGED
|
@@ -32,6 +32,8 @@ import { loadProjectConfig } from "./site-loader.ts";
|
|
|
32
32
|
import { discoverPages, expandDynamicRoutes, readPageDocument } from "./pages-discovery.ts";
|
|
33
33
|
import { buildProjectExtensionRegistry } from "./format-host.ts";
|
|
34
34
|
import { loadProjectSections } from "./project-sections.ts";
|
|
35
|
+
import { collectAssetRefs, copyMountedAssets, loadAssetMounts } from "./asset-mounts.ts";
|
|
36
|
+
import type { AssetMount } from "@jxsuite/schema/asset-paths";
|
|
35
37
|
import type { FormatEntry, FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
36
38
|
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
37
39
|
import { resolveLayout } from "./layout-resolver.ts";
|
|
@@ -208,6 +210,26 @@ export async function buildSite(
|
|
|
208
210
|
log(` Loaded ${sectionKeys.length} section(s): ${sectionKeys.join(", ")}`);
|
|
209
211
|
}
|
|
210
212
|
|
|
213
|
+
// ── 3c. Extension asset mounts (extensions.md §8.5) ─────────────────────
|
|
214
|
+
// Directories published at a site URL — typically an external content source's co-located
|
|
215
|
+
// Images. They resolve for image optimization below, and the files pages actually reference
|
|
216
|
+
// Are copied into dist in step 7c.
|
|
217
|
+
const { errors: mountErrors, mounts: assetMounts } = await loadAssetMounts(
|
|
218
|
+
registry,
|
|
219
|
+
projectConfig,
|
|
220
|
+
projectRoot,
|
|
221
|
+
);
|
|
222
|
+
errors.push(...mountErrors);
|
|
223
|
+
for (const message of mountErrors) {
|
|
224
|
+
console.error(message);
|
|
225
|
+
}
|
|
226
|
+
if (assetMounts.length > 0) {
|
|
227
|
+
log(
|
|
228
|
+
` Mounted ${assetMounts.length} asset dir(s): ${assetMounts.map((m) => m.urlPrefix).join(", ")}`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
const assetRefs = new Set<string>();
|
|
232
|
+
|
|
211
233
|
// ── 4. Expand dynamic routes ────────────────────────────────────────────
|
|
212
234
|
const routes = await expandDynamicRoutes(
|
|
213
235
|
staticRoutes,
|
|
@@ -234,20 +256,44 @@ export async function buildSite(
|
|
|
234
256
|
const componentOutDir = resolve(outDir, "components");
|
|
235
257
|
mkdirSync(componentOutDir, { recursive: true });
|
|
236
258
|
|
|
259
|
+
// Emitted modules are named after the component's `tagName` — the name the page's loader
|
|
260
|
+
// `<script>` and the CSS sidecar both use. So a component whose source basename differs from
|
|
261
|
+
// Its tag needs its `$elements` imports renamed to match, and those get resolved before the
|
|
262
|
+
// Dependency itself is compiled. Hence: read every component's tag up front.
|
|
263
|
+
const componentTagByPath = new Map<string, string>();
|
|
264
|
+
for (const file of componentFiles) {
|
|
265
|
+
const componentPath = resolve(componentsDir, file);
|
|
266
|
+
try {
|
|
267
|
+
const { tagName: depTag } = await readPageDocument(componentPath, formatRegistry);
|
|
268
|
+
if (depTag) {
|
|
269
|
+
componentTagByPath.set(componentPath, depTag);
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
// Left to the compile pass below, which reports the parse failure with its file context.
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
237
276
|
for (const file of componentFiles) {
|
|
238
277
|
try {
|
|
239
278
|
const componentPath = resolve(componentsDir, file);
|
|
240
279
|
const result = await compileElement(componentPath, {
|
|
241
280
|
...(projectConfig.$media ? { $media: projectConfig.$media } : {}),
|
|
242
281
|
formats: formatRegistry,
|
|
282
|
+
resolveElementPath: (refPath: string, currentDir: string | null) => {
|
|
283
|
+
const depPath = currentDir ? resolve(currentDir, refPath) : resolve(refPath);
|
|
284
|
+
const depTag = componentTagByPath.get(depPath);
|
|
285
|
+
return depTag ? `./${depTag}.js` : refPath.replace(/\.json$/, ".js");
|
|
286
|
+
},
|
|
243
287
|
rewriteSrc: rewriteSidecarSrc,
|
|
244
288
|
});
|
|
245
289
|
for (const f of result.files) {
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
290
|
+
// Name the output after the tag. `injectComponentScripts` emits
|
|
291
|
+
// `<script src="/components/<tag>.js">` and the CSS sidecar is written as `<tag>.css`, so
|
|
292
|
+
// A source basename here left the loader pointing at a file that was never written.
|
|
293
|
+
// Basename is the fallback, and handles both / and \ — a forward-slash-only split left
|
|
294
|
+
// The full drive path on Windows, so resolve() then wrote the sidecar back into the
|
|
295
|
+
// Source tree instead of dist.
|
|
296
|
+
const outName = f.tagName ? `${f.tagName}.js` : basename(f.path);
|
|
251
297
|
writeFileSync(resolve(componentOutDir, outName), f.content, "utf8");
|
|
252
298
|
if (f.tagName) {
|
|
253
299
|
compiledComponentTags.push(f.tagName);
|
|
@@ -262,6 +308,7 @@ export async function buildSite(
|
|
|
262
308
|
const css = buildComponentCSS(doc.tagName, doc.style, doc, projectConfig.$media ?? {});
|
|
263
309
|
if (css) {
|
|
264
310
|
componentCSS.set(doc.tagName, css);
|
|
311
|
+
collectAssetRefs(css, assetMounts, assetRefs);
|
|
265
312
|
writeFileSync(resolve(componentOutDir, `${doc.tagName}.css`), css, "utf8");
|
|
266
313
|
fileCount += 1;
|
|
267
314
|
}
|
|
@@ -325,6 +372,7 @@ export async function buildSite(
|
|
|
325
372
|
formatRegistry,
|
|
326
373
|
registry,
|
|
327
374
|
rewriteSidecarSrc,
|
|
375
|
+
assetMounts,
|
|
328
376
|
);
|
|
329
377
|
|
|
330
378
|
// Determine which component tags are fully static (for script omission)
|
|
@@ -342,9 +390,14 @@ export async function buildSite(
|
|
|
342
390
|
compiledComponentTags,
|
|
343
391
|
componentCSS,
|
|
344
392
|
staticTags,
|
|
393
|
+
result.files.map((f: { content: string }) => f.content).join("\n"),
|
|
345
394
|
);
|
|
346
395
|
}
|
|
347
396
|
|
|
397
|
+
// Mounted assets this page actually references — scanned from the finished HTML so a
|
|
398
|
+
// Reference is caught wherever it came from (markdown image, hand-authored page, $head).
|
|
399
|
+
collectAssetRefs(result.html, assetMounts, assetRefs);
|
|
400
|
+
|
|
348
401
|
// Determine output path
|
|
349
402
|
const outPath = routeToOutputPath(route.urlPattern, outDir, trailingSlash);
|
|
350
403
|
mkdirSync(dirname(outPath), { recursive: true });
|
|
@@ -568,7 +621,19 @@ export async function buildSite(
|
|
|
568
621
|
console.warn("sitemap.xml skipped — set `url` in project.json to enable sitemap generation.");
|
|
569
622
|
}
|
|
570
623
|
|
|
571
|
-
// ── 7c. Copy
|
|
624
|
+
// ── 7c. Copy referenced mounted assets (extensions.md §8.5) ─────────────
|
|
625
|
+
// Only files the compiled output references are copied, so a content source's entry files
|
|
626
|
+
// Never land in dist. Runs before the public/ copy, which keeps shadowing everything.
|
|
627
|
+
if (assetRefs.size > 0) {
|
|
628
|
+
const { copied, missing } = copyMountedAssets(assetRefs, assetMounts, outDir);
|
|
629
|
+
fileCount += copied;
|
|
630
|
+
log(`Copying ${copied} mounted asset(s)...`);
|
|
631
|
+
for (const url of missing) {
|
|
632
|
+
console.warn(`Referenced asset not found: ${url}`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ── 7c(ii). Copy public/ assets ─────────────────────────────────────────
|
|
572
637
|
if (existsSync(publicDir)) {
|
|
573
638
|
log("Copying public/ assets...");
|
|
574
639
|
cpSync(publicDir, outDir, { recursive: true });
|
|
@@ -630,6 +695,7 @@ async function compilePage(
|
|
|
630
695
|
formatRegistry?: FormatRegistry,
|
|
631
696
|
registry?: ExtensionRegistry,
|
|
632
697
|
rewriteSidecarSrc?: (specifier: string, docDir: string | null) => string,
|
|
698
|
+
assetMounts: readonly AssetMount[] = [],
|
|
633
699
|
) {
|
|
634
700
|
// Load the raw page document (.json natively, other formats via the registry)
|
|
635
701
|
const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
|
|
@@ -744,6 +810,7 @@ async function compilePage(
|
|
|
744
810
|
projectRoot,
|
|
745
811
|
imageCache,
|
|
746
812
|
imageMetaCache ?? undefined,
|
|
813
|
+
assetMounts,
|
|
747
814
|
);
|
|
748
815
|
}
|
|
749
816
|
|
|
@@ -1109,9 +1176,14 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
|
|
|
1109
1176
|
}
|
|
1110
1177
|
const rawChildren = node.children;
|
|
1111
1178
|
// Legacy whole-children repeater: expand the items into the node's static children.
|
|
1179
|
+
// Only when the expansion actually produced nodes. `expandMappedArrayStatic` returns `null` when
|
|
1180
|
+
// `items` cannot be resolved at build time and `[]` when it resolves to an empty array — and `[]`
|
|
1181
|
+
// Is truthy, so an empty build-time list used to replace the repeater with nothing, discarding the
|
|
1182
|
+
// Definition the client needed. There is no prerendered output to preserve in that case, so the
|
|
1183
|
+
// Repeater is left in place for the client to bind (a page containing one is never zero-JS).
|
|
1112
1184
|
if (isMappedArray(rawChildren)) {
|
|
1113
1185
|
const expanded = expandMappedArrayStatic(rawChildren, scope);
|
|
1114
|
-
if (expanded) {
|
|
1186
|
+
if (expanded && expanded.length > 0) {
|
|
1115
1187
|
node.children = expanded;
|
|
1116
1188
|
return;
|
|
1117
1189
|
}
|
|
@@ -1141,10 +1213,11 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
|
|
|
1141
1213
|
continue;
|
|
1142
1214
|
}
|
|
1143
1215
|
}
|
|
1144
|
-
// Array pseudo-element among siblings: expand its items in place
|
|
1216
|
+
// Array pseudo-element among siblings: expand its items in place, on the same
|
|
1217
|
+
// Produced-nothing rule as the whole-children branch above.
|
|
1145
1218
|
if (isMappedArray(child)) {
|
|
1146
1219
|
const expanded = expandMappedArrayStatic(child, scope);
|
|
1147
|
-
if (expanded) {
|
|
1220
|
+
if (expanded && expanded.length > 0) {
|
|
1148
1221
|
node.children.splice(i, 1, ...expanded);
|
|
1149
1222
|
i += expanded.length;
|
|
1150
1223
|
continue;
|
|
@@ -1309,6 +1382,7 @@ function expandComponents(
|
|
|
1309
1382
|
* @param {string[]} allComponentTags - All compiled component tag names
|
|
1310
1383
|
* @param {Map<string, string>} [cssMap] - TagName → CSS text (for link injection)
|
|
1311
1384
|
* @param {Set<string>} [staticTags] - Tags where ALL instances are fully static (skip JS)
|
|
1385
|
+
* @param {string} [islandSource] - Concatenated island modules emitted for this page
|
|
1312
1386
|
* @returns {string}
|
|
1313
1387
|
*/
|
|
1314
1388
|
function injectComponentScripts(
|
|
@@ -1316,11 +1390,15 @@ function injectComponentScripts(
|
|
|
1316
1390
|
allComponentTags: string[],
|
|
1317
1391
|
cssMap = new Map<string, string>(),
|
|
1318
1392
|
staticTags = new Set<string>(),
|
|
1393
|
+
islandSource = "",
|
|
1319
1394
|
) {
|
|
1320
|
-
//
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1395
|
+
// A component reaches a page two ways: as a literal tag in the static HTML, or referenced only
|
|
1396
|
+
// From inside an island's client template — in which case the tag exists solely in the island's
|
|
1397
|
+
// Compiled JS and never in the HTML the page ships. Searching the HTML alone missed the second
|
|
1398
|
+
// Kind entirely, so its module was never loaded and the element sat in the DOM un-upgraded.
|
|
1399
|
+
const inHtml = (tag: string) => html.includes(`<${tag}`); // Matches <tag> and <tag ...>
|
|
1400
|
+
const inIsland = (tag: string) => islandSource.includes(`<${tag}`);
|
|
1401
|
+
const usedTags = allComponentTags.filter((tag: string) => inHtml(tag) || inIsland(tag));
|
|
1324
1402
|
if (usedTags.length === 0) {
|
|
1325
1403
|
return html;
|
|
1326
1404
|
}
|
|
@@ -1335,8 +1413,11 @@ function injectComponentScripts(
|
|
|
1335
1413
|
result = result.replace("</head>", ` ${cssLinks}\n</head>`);
|
|
1336
1414
|
}
|
|
1337
1415
|
|
|
1338
|
-
// Only inject JS for components that have non-static instances
|
|
1339
|
-
|
|
1416
|
+
// Only inject JS for components that have non-static instances. An island-rendered instance is
|
|
1417
|
+
// Never prerendered, so it needs its module even when the component itself is fully static —
|
|
1418
|
+
// That module is what fills the element in client-side. This holds when the tag ALSO appears in
|
|
1419
|
+
// The static HTML: those instances are prerendered, but the ones the island creates are not.
|
|
1420
|
+
const jsTags = usedTags.filter((tag: string) => inIsland(tag) || !staticTags.has(tag));
|
|
1340
1421
|
if (jsTags.length === 0) {
|
|
1341
1422
|
return result;
|
|
1342
1423
|
}
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Extends the original project.json check into the full compliance walk:
|
|
5
5
|
*
|
|
6
|
-
* 1.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* 1. The committed entry documents are SELF-CONTAINED and SINGLE-RESOURCE: every `$ref` is a
|
|
7
|
+
* root-relative JSON Pointer that resolves inside the file — the editor-resolution guarantee of
|
|
8
|
+
* the committed form (specs/extensions.md §5.4). Checked first and fatal, since walks 2–4
|
|
9
|
+
* compile those documents;
|
|
10
|
+
* 2. `project.json` against the generated entry schema (validateProjectFile);
|
|
9
11
|
* 3. Every document under `components/`, `pages/`, and `layouts/` against the bundled document schema
|
|
10
12
|
* (core + extension `$paths` union — the same schema the studio consumes);
|
|
11
13
|
* 4. Every project-local `*.class.json` against the generated class schema;
|
|
@@ -94,33 +96,39 @@ export async function validateProjectTree(
|
|
|
94
96
|
const issues: ProjectTreeIssue[] = [];
|
|
95
97
|
let checked = 0;
|
|
96
98
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
issues.push({ errors: projectResult.errors ?? [], file: "project.json" });
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// 2. Committed entry documents must be self-contained (the editor-resolution guarantee).
|
|
99
|
+
/* 1. Committed entry documents must be self-contained (the editor-resolution guarantee). This
|
|
100
|
+
runs FIRST because every later walk compiles one of them: an unresolvable ref surfaces from ajv
|
|
101
|
+
as a thrown MissingRefError, so diagnosing it here turns a stack trace into "regenerate with
|
|
102
|
+
`jx schema`". Nothing downstream is meaningful until it holds, hence the early return. */
|
|
105
103
|
for (const name of ["project.schema.json", "document.schema.json"]) {
|
|
106
104
|
const path = resolve(root, name);
|
|
107
105
|
if (!existsSync(path)) {
|
|
108
106
|
continue;
|
|
109
107
|
}
|
|
110
108
|
checked += 1;
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
if (
|
|
109
|
+
const entry = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
110
|
+
const unresolvable = collectUnresolvableRefs(entry);
|
|
111
|
+
if (unresolvable.length > 0) {
|
|
114
112
|
issues.push({
|
|
115
|
-
errors:
|
|
113
|
+
errors: unresolvable.map(({ reason, ref }) => ({
|
|
116
114
|
message:
|
|
117
|
-
|
|
118
|
-
`regenerate with \`jx schema\``,
|
|
115
|
+
`$ref "${ref}" ${reason} — committed entry documents must be self-contained ` +
|
|
116
|
+
`single-resource schemas; regenerate with \`jx schema\``,
|
|
119
117
|
})),
|
|
120
118
|
file: name,
|
|
121
119
|
});
|
|
122
120
|
}
|
|
123
121
|
}
|
|
122
|
+
if (issues.length > 0) {
|
|
123
|
+
return { checked, issues, valid: false };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 2. project.json against the generated entry schema.
|
|
127
|
+
const projectResult = await validateProjectFile(root);
|
|
128
|
+
checked += 1;
|
|
129
|
+
if (!projectResult.valid) {
|
|
130
|
+
issues.push({ errors: projectResult.errors ?? [], file: "project.json" });
|
|
131
|
+
}
|
|
124
132
|
|
|
125
133
|
const { addFormats, Ajv } = await loadAjv();
|
|
126
134
|
|
|
@@ -186,30 +194,118 @@ export async function validateProjectTree(
|
|
|
186
194
|
return { checked, issues, valid: issues.length === 0 };
|
|
187
195
|
}
|
|
188
196
|
|
|
189
|
-
/**
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
197
|
+
/**
|
|
198
|
+
* Every `$ref` a committed entry document may carry must be a root-relative JSON Pointer that
|
|
199
|
+
* resolves inside the same file. Three ways that fails, all of which break editor resolution: a
|
|
200
|
+
* relative path (needs Node module resolution), a URI (VS Code fetches it over the network instead
|
|
201
|
+
* of matching an embedded `$id`), and a pointer with no node at the other end.
|
|
202
|
+
*
|
|
203
|
+
* Walks only subschema keyword positions, since `examples`/`default`/`const`/`enum` hold instance
|
|
204
|
+
* data and Jx document examples legitimately contain `$ref` strings of their own.
|
|
205
|
+
*
|
|
206
|
+
* @param {Record<string, unknown>} entry - Parsed committed entry document
|
|
207
|
+
* @returns {{ ref: string; reason: string }[]} One entry per offending `$ref`
|
|
208
|
+
*/
|
|
209
|
+
function collectUnresolvableRefs(
|
|
210
|
+
entry: Record<string, unknown>,
|
|
211
|
+
): { ref: string; reason: string }[] {
|
|
212
|
+
const out: { ref: string; reason: string }[] = [];
|
|
213
|
+
const seen = new Set<Record<string, unknown>>();
|
|
214
|
+
|
|
215
|
+
const resolvePointer = (pointer: string): boolean => {
|
|
216
|
+
let current: unknown = entry;
|
|
217
|
+
for (const raw of pointer.slice(1).split("/")) {
|
|
218
|
+
if (raw === "") {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const segment = raw.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
222
|
+
if (typeof current !== "object" || current === null) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
current = (current as Record<string, unknown>)[segment];
|
|
226
|
+
if (current === undefined) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return true;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const walk = (node: unknown): void => {
|
|
234
|
+
if (!isRecord(node) || seen.has(node)) {
|
|
235
|
+
return;
|
|
194
236
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
!
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
out.push(value);
|
|
237
|
+
seen.add(node);
|
|
238
|
+
const ref = node.$ref;
|
|
239
|
+
if (typeof ref === "string") {
|
|
240
|
+
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(ref)) {
|
|
241
|
+
out.push({ reason: "is an absolute URI editors resolve over the network", ref });
|
|
242
|
+
} else if (!ref.startsWith("#")) {
|
|
243
|
+
out.push({ reason: "is a relative path only Node module resolution can follow", ref });
|
|
244
|
+
} else if (ref.length > 1 && !ref.startsWith("#/")) {
|
|
245
|
+
out.push({ reason: "is an $anchor reference, which has no root-pointer form", ref });
|
|
246
|
+
} else if (ref.length > 1 && !resolvePointer(ref.slice(1))) {
|
|
247
|
+
out.push({ reason: "points at no node in this document", ref });
|
|
248
|
+
}
|
|
208
249
|
}
|
|
209
|
-
|
|
210
|
-
|
|
250
|
+
for (const [keyword, value] of Object.entries(node)) {
|
|
251
|
+
if (!SUBSCHEMA_KEYWORDS.has(keyword)) {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (Array.isArray(value)) {
|
|
255
|
+
for (const item of value) {
|
|
256
|
+
walk(item);
|
|
257
|
+
}
|
|
258
|
+
} else if (isRecord(value)) {
|
|
259
|
+
// Map-valued keywords hold subschemas one level down; single-valued ones are the schema.
|
|
260
|
+
walk(value);
|
|
261
|
+
if (MAP_SUBSCHEMA_KEYWORDS.has(keyword)) {
|
|
262
|
+
for (const item of Object.values(value)) {
|
|
263
|
+
walk(item);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
walk(entry);
|
|
270
|
+
return out;
|
|
211
271
|
}
|
|
212
272
|
|
|
273
|
+
/** True for a plain object (a candidate subschema node). */
|
|
274
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
275
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Keywords whose values map names to subschemas. */
|
|
279
|
+
const MAP_SUBSCHEMA_KEYWORDS = new Set([
|
|
280
|
+
"$defs",
|
|
281
|
+
"definitions",
|
|
282
|
+
"dependencies",
|
|
283
|
+
"dependentSchemas",
|
|
284
|
+
"patternProperties",
|
|
285
|
+
"properties",
|
|
286
|
+
]);
|
|
287
|
+
|
|
288
|
+
/** Every keyword position a subschema can occupy (see {@link MAP_SUBSCHEMA_KEYWORDS} for maps). */
|
|
289
|
+
const SUBSCHEMA_KEYWORDS = new Set([
|
|
290
|
+
...MAP_SUBSCHEMA_KEYWORDS,
|
|
291
|
+
"additionalItems",
|
|
292
|
+
"additionalProperties",
|
|
293
|
+
"allOf",
|
|
294
|
+
"anyOf",
|
|
295
|
+
"contains",
|
|
296
|
+
"contentSchema",
|
|
297
|
+
"else",
|
|
298
|
+
"if",
|
|
299
|
+
"items",
|
|
300
|
+
"not",
|
|
301
|
+
"oneOf",
|
|
302
|
+
"prefixItems",
|
|
303
|
+
"propertyNames",
|
|
304
|
+
"then",
|
|
305
|
+
"unevaluatedItems",
|
|
306
|
+
"unevaluatedProperties",
|
|
307
|
+
]);
|
|
308
|
+
|
|
213
309
|
/** Recursively list `.json` files under a directory (absent directories yield nothing). */
|
|
214
310
|
function walkJsonFiles(dir: string): string[] {
|
|
215
311
|
if (!existsSync(dir)) {
|