@jxsuite/compiler 0.35.0 → 1.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 +182 -161
- package/dist/compiler.js +116 -112
- package/package.json +11 -7
- package/src/cli.ts +102 -9
- package/src/site/context-injection.ts +0 -6
- package/src/site/db-push.ts +178 -0
- package/src/site/format-host.ts +128 -13
- package/src/site/pages-discovery.ts +61 -49
- package/src/site/project-sections.ts +48 -0
- package/src/site/prototype-resolver.ts +55 -11
- package/src/site/schema-command.ts +242 -0
- package/src/site/site-build.ts +162 -26
- package/src/site/site-loader.ts +3 -5
- package/src/targets/compile-server.ts +83 -5
- package/src/site/content-loader.ts +0 -385
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Scans the pages/ directory and builds a route table mapping URL paths to their source files,
|
|
5
5
|
* layouts, and metadata. `.json` pages are native; any other extension must be claimed by a format
|
|
6
|
-
* class (with the `parse` capability and a "page" document kind)
|
|
7
|
-
* imports map.
|
|
6
|
+
* class (with the `parse` capability and a "page" document kind) provided by an enabled extension.
|
|
8
7
|
*
|
|
9
8
|
* Conventions (per site-architecture spec §4): pages/index.json → / pages/about.json → /about
|
|
10
9
|
* pages/about/index.json → /about pages/blog/[slug].json → /blog/:slug (dynamic)
|
|
@@ -15,9 +14,9 @@
|
|
|
15
14
|
import { readFileSync, readdirSync } from "node:fs";
|
|
16
15
|
import { parseJxDocument } from "@jxsuite/schema/parse";
|
|
17
16
|
import { extname, join, relative, resolve } from "node:path";
|
|
17
|
+
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
18
18
|
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
19
|
-
import type { JxDocument, JxPathsDef } from "@jxsuite/schema/types";
|
|
20
|
-
import type { ContentLoaderEntry } from "@jxsuite/parser/types";
|
|
19
|
+
import type { JxDocument, JxPathsDef, ProjectConfig } from "@jxsuite/schema/types";
|
|
21
20
|
|
|
22
21
|
interface Route {
|
|
23
22
|
urlPattern: string; // URL pattern (e.g. "/blog/:slug")
|
|
@@ -50,9 +49,9 @@ export async function readPageDocument(
|
|
|
50
49
|
const entry = registry?.byExtension(ext, "parse");
|
|
51
50
|
if (!entry) {
|
|
52
51
|
throw new Error(
|
|
53
|
-
`No format class
|
|
54
|
-
`
|
|
55
|
-
`
|
|
52
|
+
`No format class registered for "${ext}" (${filePath}). ` +
|
|
53
|
+
`Enable an extension providing this format in project.json "extensions", ` +
|
|
54
|
+
`e.g. "@jxsuite/parser".`,
|
|
56
55
|
);
|
|
57
56
|
}
|
|
58
57
|
return (await entry.call("parse", source)) as JxDocument;
|
|
@@ -208,23 +207,25 @@ async function fileToRoute(relativePath: string, absolutePath: string, registry?
|
|
|
208
207
|
/**
|
|
209
208
|
* Expand dynamic routes by resolving $paths from each dynamic page.
|
|
210
209
|
*
|
|
211
|
-
* Supports
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
210
|
+
* Supports these $paths shapes (per spec §4.3): 1. Explicit values: { values: ["en", "fr"], param:
|
|
211
|
+
* "lang" } 2. Data file ref: { "$ref": "./data/products.json", param: "id", field: "sku" } 3.
|
|
212
|
+
* Legacy array: [{ slug: "hello" }, { slug: "world" }] 4. Extension-discriminated: any object
|
|
213
|
+
* carrying a key an enabled extension registered as its `resolvePaths` discriminator (e.g. the
|
|
214
|
+
* parser's { contentType: "blog", param: "slug" }).
|
|
215
215
|
*
|
|
216
216
|
* @param {Route[]} routes - Discovered route table
|
|
217
217
|
* @param {string} projectRoot - Project root for resolving $ref paths
|
|
218
|
-
* @param {
|
|
219
|
-
*
|
|
220
|
-
* @param {
|
|
218
|
+
* @param {Record<string, unknown>} [sections] - Loaded project sections (projectData results)
|
|
219
|
+
* @param {ExtensionRegistry} [registry] - Extension registry (formats view reads non-JSON pages)
|
|
220
|
+
* @param {ProjectConfig} [projectConfig] - Already-loaded project config
|
|
221
221
|
* @returns {Promise<Route[]>} Expanded routes with concrete paths
|
|
222
222
|
*/
|
|
223
223
|
export async function expandDynamicRoutes(
|
|
224
224
|
routes: Route[],
|
|
225
225
|
projectRoot: string,
|
|
226
|
-
|
|
227
|
-
registry?:
|
|
226
|
+
sections: Record<string, unknown> = {},
|
|
227
|
+
registry?: ExtensionRegistry,
|
|
228
|
+
projectConfig?: ProjectConfig,
|
|
228
229
|
) {
|
|
229
230
|
const expanded: Route[] = [];
|
|
230
231
|
|
|
@@ -238,7 +239,7 @@ export async function expandDynamicRoutes(
|
|
|
238
239
|
/** @type {Record<string, unknown>} */
|
|
239
240
|
let raw;
|
|
240
241
|
try {
|
|
241
|
-
raw = await readPageDocument(route.sourcePath, registry);
|
|
242
|
+
raw = await readPageDocument(route.sourcePath, registry?.formats);
|
|
242
243
|
} catch {
|
|
243
244
|
expanded.push(route);
|
|
244
245
|
continue;
|
|
@@ -249,7 +250,13 @@ export async function expandDynamicRoutes(
|
|
|
249
250
|
continue;
|
|
250
251
|
}
|
|
251
252
|
|
|
252
|
-
const pathEntries = resolvePathEntries(
|
|
253
|
+
const pathEntries = await resolvePathEntries(
|
|
254
|
+
raw.$paths,
|
|
255
|
+
projectRoot,
|
|
256
|
+
sections,
|
|
257
|
+
registry,
|
|
258
|
+
projectConfig,
|
|
259
|
+
);
|
|
253
260
|
|
|
254
261
|
for (const pathEntry of pathEntries) {
|
|
255
262
|
let concreteUrl = route.urlPattern;
|
|
@@ -277,67 +284,72 @@ export async function expandDynamicRoutes(
|
|
|
277
284
|
/**
|
|
278
285
|
* Resolve $paths into an array of param objects.
|
|
279
286
|
*
|
|
287
|
+
* Core shapes (array / `values` / `$ref`) are handled inline; any other object dispatches through
|
|
288
|
+
* the extension registry — the first key with a registered `resolvePaths` discriminator routes the
|
|
289
|
+
* whole $paths value to the owning class, with its section's loaded data as context.
|
|
290
|
+
*
|
|
280
291
|
* @param {import("@jxsuite/schema/types").JxPathsDef} $paths - The $paths declaration
|
|
281
292
|
* @param {string} projectRoot
|
|
282
|
-
* @param {
|
|
283
|
-
* @
|
|
293
|
+
* @param {Record<string, unknown>} sections - Loaded project sections keyed by section key
|
|
294
|
+
* @param {ExtensionRegistry} [registry]
|
|
295
|
+
* @param {ProjectConfig} [projectConfig]
|
|
296
|
+
* @returns {Promise<Record<string, unknown>[]>} Array of { paramName: value } objects
|
|
284
297
|
*/
|
|
285
|
-
function resolvePathEntries(
|
|
298
|
+
async function resolvePathEntries(
|
|
286
299
|
$paths: JxPathsDef,
|
|
287
300
|
projectRoot: string,
|
|
288
|
-
|
|
289
|
-
|
|
301
|
+
sections: Record<string, unknown>,
|
|
302
|
+
registry?: ExtensionRegistry,
|
|
303
|
+
projectConfig?: ProjectConfig,
|
|
304
|
+
): Promise<Record<string, unknown>[]> {
|
|
290
305
|
// Legacy: array of param objects
|
|
291
306
|
if (Array.isArray($paths)) {
|
|
292
307
|
return $paths;
|
|
293
308
|
}
|
|
294
309
|
|
|
295
|
-
// Content type-based: { contentType: "blog", param: "slug", field: "id" }
|
|
296
|
-
if ("contentType" in $paths && $paths.contentType) {
|
|
297
|
-
const entries = contentTypes.get($paths.contentType);
|
|
298
|
-
if (!entries || entries.length === 0) {
|
|
299
|
-
console.warn(
|
|
300
|
-
`Warning: $paths references content type "${$paths.contentType}" but it has no entries`,
|
|
301
|
-
);
|
|
302
|
-
return [];
|
|
303
|
-
}
|
|
304
|
-
const param = $paths.param ?? "slug";
|
|
305
|
-
const field = $paths.field ?? "id";
|
|
306
|
-
return entries
|
|
307
|
-
.map((entry: ContentLoaderEntry) => ({
|
|
308
|
-
[param]: field === "id" ? entry.id : (entry.data[field] ?? entry.id),
|
|
309
|
-
}))
|
|
310
|
-
.filter((p: Record<string, unknown>) => p[param]);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
310
|
// Explicit values: { values: ["en", "fr"], param: "lang" }
|
|
314
311
|
if ("values" in $paths && Array.isArray($paths.values)) {
|
|
315
|
-
const param = $paths.param
|
|
316
|
-
return $paths.values.map((v) => ({ [param]: v }));
|
|
312
|
+
const param = typeof $paths.param === "string" ? $paths.param : "value";
|
|
313
|
+
return ($paths.values as unknown[]).map((v) => ({ [param]: v }));
|
|
317
314
|
}
|
|
318
315
|
|
|
319
316
|
// Data file ref: { "$ref": "./data/products.json", param: "id", field: "sku" }
|
|
320
|
-
if ("$ref" in $paths && $paths.$ref) {
|
|
321
|
-
const
|
|
317
|
+
if ("$ref" in $paths && typeof $paths.$ref === "string" && $paths.$ref) {
|
|
318
|
+
const refPath = $paths.$ref;
|
|
319
|
+
const filePath = resolve(projectRoot, refPath);
|
|
322
320
|
let data: unknown;
|
|
323
321
|
try {
|
|
324
322
|
data = JSON.parse(readFileSync(filePath, "utf8")) as unknown;
|
|
325
323
|
} catch (error) {
|
|
326
324
|
const err = error as Error;
|
|
327
|
-
console.warn(`Warning: $paths.$ref could not load "${
|
|
325
|
+
console.warn(`Warning: $paths.$ref could not load "${refPath}": ${err.message}`);
|
|
328
326
|
return [];
|
|
329
327
|
}
|
|
330
328
|
if (!Array.isArray(data)) {
|
|
331
|
-
console.warn(`Warning: $paths.$ref "${
|
|
329
|
+
console.warn(`Warning: $paths.$ref "${refPath}" must be a JSON array`);
|
|
332
330
|
return [];
|
|
333
331
|
}
|
|
334
|
-
const param = $paths.param
|
|
335
|
-
const field = $paths.field
|
|
332
|
+
const param = typeof $paths.param === "string" ? $paths.param : "id";
|
|
333
|
+
const field = typeof $paths.field === "string" ? $paths.field : "id";
|
|
336
334
|
return (data as Record<string, unknown>[]).map((item: Record<string, unknown>) => ({
|
|
337
335
|
[param]: item[field] ?? item.id ?? String(item),
|
|
338
336
|
}));
|
|
339
337
|
}
|
|
340
338
|
|
|
339
|
+
// Extension-discriminated: the first key with a registered resolvePaths discriminator routes
|
|
340
|
+
// The whole value to the owning class (specs/extensions.md §8).
|
|
341
|
+
for (const key of Object.keys($paths)) {
|
|
342
|
+
const entry = registry?.byPathsDiscriminator(key);
|
|
343
|
+
if (!entry?.project) {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
return (await entry.call("resolvePaths", $paths, {
|
|
347
|
+
data: sections[entry.project.key],
|
|
348
|
+
projectConfig,
|
|
349
|
+
root: projectRoot,
|
|
350
|
+
})) as Record<string, unknown>[];
|
|
351
|
+
}
|
|
352
|
+
|
|
341
353
|
console.warn(`Warning: unrecognized $paths shape — skipping`);
|
|
342
354
|
return [];
|
|
343
355
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-sections — generic section orchestration (specs/extensions.md §8, §9)
|
|
3
|
+
*
|
|
4
|
+
* Loads every extension-contributed project.json section through the owning class's `projectData`
|
|
5
|
+
* capability and returns the results keyed by section key. Hosts thread the record through the
|
|
6
|
+
* build (route expansion, prototype resolution) without knowing any section's shape.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createNodeFormatIO } from "./format-host.ts";
|
|
10
|
+
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
11
|
+
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Load all extension-contributed project sections.
|
|
15
|
+
*
|
|
16
|
+
* A section is loaded only when its key is present in the project config: `projectData` on an
|
|
17
|
+
* absent section would only manufacture empty data (the parser's returns an empty Map), so skipping
|
|
18
|
+
* keeps `_project` free of keys the author never declared — and is the cheaper rule.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} projectRoot - Absolute path to the project root
|
|
21
|
+
* @param {ProjectConfig | undefined} projectConfig - Already-loaded project config
|
|
22
|
+
* @param {ExtensionRegistry} registry - The project's extension registry
|
|
23
|
+
* @returns {Promise<Record<string, unknown>>} Section key → projectData result
|
|
24
|
+
*/
|
|
25
|
+
export async function loadProjectSections(
|
|
26
|
+
projectRoot: string,
|
|
27
|
+
projectConfig: ProjectConfig | undefined,
|
|
28
|
+
registry: ExtensionRegistry,
|
|
29
|
+
): Promise<Record<string, unknown>> {
|
|
30
|
+
const sections: Record<string, unknown> = {};
|
|
31
|
+
const io = createNodeFormatIO(projectRoot);
|
|
32
|
+
for (const entry of registry.projectContributions()) {
|
|
33
|
+
const key = entry.project?.key;
|
|
34
|
+
if (!key || !entry.capabilities.projectData) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (!projectConfig || !(key in projectConfig)) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
sections[key] = await entry.call("projectData", projectConfig[key], {
|
|
41
|
+
io,
|
|
42
|
+
projectConfig,
|
|
43
|
+
registry,
|
|
44
|
+
root: projectRoot,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return sections;
|
|
48
|
+
}
|
|
@@ -20,12 +20,14 @@ import { createRequire } from "node:module";
|
|
|
20
20
|
import { importImplementation } from "./format-host.ts";
|
|
21
21
|
import { isPrototypeDef } from "@jxsuite/schema/guards";
|
|
22
22
|
import { errorMessage, parseClassDef } from "@jxsuite/schema/parse";
|
|
23
|
+
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
23
24
|
import type {
|
|
24
25
|
JsonObject,
|
|
25
26
|
JsonValue,
|
|
26
27
|
JxDocument,
|
|
27
28
|
JxMutableNode,
|
|
28
29
|
JxPrototypeDef,
|
|
30
|
+
JxStateDefinition,
|
|
29
31
|
} from "@jxsuite/schema/types";
|
|
30
32
|
|
|
31
33
|
/**
|
|
@@ -60,8 +62,14 @@ const RESERVED_KEYS = new Set([
|
|
|
60
62
|
* @param {JxMutableNode | JxDocument} doc - The page document (mutated in place)
|
|
61
63
|
* @param {{ sourcePath?: string; _pathParams?: Record<string, string> }} route - Route info
|
|
62
64
|
* @param {string} projectRoot - Absolute path to the project root
|
|
63
|
-
* @param {{
|
|
64
|
-
*
|
|
65
|
+
* @param {{
|
|
66
|
+
* config?: Record<string, unknown>;
|
|
67
|
+
* sections?: Record<string, unknown>;
|
|
68
|
+
* registry?: ExtensionRegistry;
|
|
69
|
+
* }} [projectContext]
|
|
70
|
+
* - Project-level context injected into all classes; `sections` holds the extension-loaded project
|
|
71
|
+
* sections keyed by section key (spread into `_project`); `registry` enables manifest class
|
|
72
|
+
* resolution and the generic `lower` capability
|
|
65
73
|
*/
|
|
66
74
|
export async function resolvePrototypes(
|
|
67
75
|
doc: JxMutableNode | JxDocument,
|
|
@@ -69,7 +77,8 @@ export async function resolvePrototypes(
|
|
|
69
77
|
projectRoot: string,
|
|
70
78
|
projectContext: {
|
|
71
79
|
config?: Record<string, unknown>;
|
|
72
|
-
|
|
80
|
+
sections?: Record<string, unknown>;
|
|
81
|
+
registry?: ExtensionRegistry;
|
|
73
82
|
} = {},
|
|
74
83
|
) {
|
|
75
84
|
const imports = doc.imports ?? {};
|
|
@@ -85,15 +94,33 @@ export async function resolvePrototypes(
|
|
|
85
94
|
if (SKIP_PROTOTYPES.has(def.$prototype)) {
|
|
86
95
|
continue;
|
|
87
96
|
}
|
|
88
|
-
// Only resolve timing: "compiler" (or unset timing with a
|
|
89
|
-
//
|
|
97
|
+
// Only resolve timing: "compiler" (or unset timing with a class mapping). Timing: "server"
|
|
98
|
+
// And timing: "client" stay for their pipelines — but a registry class with a `lower`
|
|
99
|
+
// Capability compiles away here into a core-shape def (specs/extensions.md §8.3): dynamic
|
|
100
|
+
// Data becomes a plain reactive fetch with no extension code shipped to the browser.
|
|
90
101
|
if (def.timing && def.timing !== "compiler") {
|
|
102
|
+
const entry = projectContext.registry?.byName(def.$prototype);
|
|
103
|
+
if (entry?.capabilities.lower) {
|
|
104
|
+
try {
|
|
105
|
+
const lowered = await entry.call("lower", def, {
|
|
106
|
+
projectConfig: projectContext.config,
|
|
107
|
+
root: projectRoot,
|
|
108
|
+
route,
|
|
109
|
+
});
|
|
110
|
+
state[key] = lowered as JxStateDefinition;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.warn(
|
|
113
|
+
`prototype-resolver: failed to lower "${key}" ($prototype: "${def.$prototype}"):`,
|
|
114
|
+
errorMessage(error),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
91
118
|
continue;
|
|
92
119
|
}
|
|
93
120
|
|
|
94
|
-
// Look up in imports
|
|
121
|
+
// Look up in imports (project-local wins), then the extension registry's manifest classes.
|
|
95
122
|
if (!def.$src) {
|
|
96
|
-
const mapped = imports[def.$prototype];
|
|
123
|
+
const mapped = imports[def.$prototype] ?? registryClassPath(projectContext, def.$prototype);
|
|
97
124
|
if (!mapped) {
|
|
98
125
|
continue;
|
|
99
126
|
}
|
|
@@ -116,6 +143,22 @@ export async function resolvePrototypes(
|
|
|
116
143
|
}
|
|
117
144
|
}
|
|
118
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Descriptor path of a manifest class from the extension registry, when one is enabled. Lets
|
|
148
|
+
* `timing: "compiler"` defs name extension classes (TableQuery, ContentCollection, …) without a
|
|
149
|
+
* project-local imports entry.
|
|
150
|
+
*
|
|
151
|
+
* @param {{ registry?: ExtensionRegistry }} projectContext
|
|
152
|
+
* @param {string} prototypeName
|
|
153
|
+
* @returns {string | undefined}
|
|
154
|
+
*/
|
|
155
|
+
function registryClassPath(
|
|
156
|
+
projectContext: { registry?: ExtensionRegistry },
|
|
157
|
+
prototypeName: string,
|
|
158
|
+
): string | undefined {
|
|
159
|
+
return projectContext.registry?.byName(prototypeName)?.classPath;
|
|
160
|
+
}
|
|
161
|
+
|
|
119
162
|
/**
|
|
120
163
|
* Resolve a single $prototype entry via its .class.json.
|
|
121
164
|
*
|
|
@@ -123,7 +166,7 @@ export async function resolvePrototypes(
|
|
|
123
166
|
* @param {{ sourcePath?: string; _pathParams?: Record<string, string> }} route
|
|
124
167
|
* @param {string} projectRoot
|
|
125
168
|
* @param {Record<string, unknown>} state - The full page state object
|
|
126
|
-
* @param {{ config?: Record<string, unknown>;
|
|
169
|
+
* @param {{ config?: Record<string, unknown>; sections?: Record<string, unknown> }} projectContext
|
|
127
170
|
* @returns {Promise<JsonValue>} The resolved value
|
|
128
171
|
*/
|
|
129
172
|
async function resolveClassPrototype(
|
|
@@ -133,7 +176,7 @@ async function resolveClassPrototype(
|
|
|
133
176
|
state: Record<string, unknown>,
|
|
134
177
|
projectContext: {
|
|
135
178
|
config?: Record<string, unknown>;
|
|
136
|
-
|
|
179
|
+
sections?: Record<string, unknown>;
|
|
137
180
|
},
|
|
138
181
|
) {
|
|
139
182
|
const src = def.$src;
|
|
@@ -191,11 +234,12 @@ async function resolveClassPrototype(
|
|
|
191
234
|
config.basePath = dirname(route.sourcePath);
|
|
192
235
|
}
|
|
193
236
|
|
|
194
|
-
// Inject universal context — available to all classes
|
|
237
|
+
// Inject universal context — available to all classes. Extension-loaded sections land under
|
|
238
|
+
// Their section keys (e.g. _project.content for the parser's content collections).
|
|
195
239
|
config._project = {
|
|
196
240
|
config: projectContext.config ?? null,
|
|
197
|
-
contentTypes: projectContext.contentTypes ?? null,
|
|
198
241
|
root: projectRoot,
|
|
242
|
+
...projectContext.sections,
|
|
199
243
|
};
|
|
200
244
|
config._document = { route, state };
|
|
201
245
|
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema-command — `jx schema` implementation (specs/extensions.md §5.2)
|
|
3
|
+
*
|
|
4
|
+
* Composes the per-project entry documents (project.schema.json and document.schema.json) from the
|
|
5
|
+
* core schemas and each enabled extension's shipped fragments, then writes them into the project
|
|
6
|
+
* root as committed artifacts. All fragment refs are PROJECT-RELATIVE, POSIX-style `./` paths:
|
|
7
|
+
* paths that resolve inside the project are emitted as-is; bare-specifier packages resolving
|
|
8
|
+
* through workspace symlinks (outside the root) fall back to the conventional
|
|
9
|
+
* `./node_modules/<package>/<subpath>` form, which resolves once dependencies are installed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
bundleSchema,
|
|
17
|
+
emitDocumentSchema,
|
|
18
|
+
emitProjectSchema,
|
|
19
|
+
} from "@jxsuite/schema/project-schemas";
|
|
20
|
+
import { buildProjectExtensionRegistry } from "./format-host.ts";
|
|
21
|
+
import { loadProjectConfig } from "./site-loader.ts";
|
|
22
|
+
import type { ExtensionInfo } from "@jxsuite/schema/extension-registry";
|
|
23
|
+
import type { SchemaJsonLoader } from "@jxsuite/schema/project-schemas";
|
|
24
|
+
|
|
25
|
+
const CORE_PROJECT_SPECIFIER = "@jxsuite/schema/schemas/project.core.schema.json";
|
|
26
|
+
const CORE_DOCUMENT_SPECIFIER = "@jxsuite/schema/schema.json";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Generate and write `<root>/project.schema.json` and `<root>/document.schema.json`.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} projectRoot - Absolute path to the project directory
|
|
32
|
+
* @returns {Promise<{ projectSchemaPath: string; documentSchemaPath: string }>} Written paths
|
|
33
|
+
*/
|
|
34
|
+
export async function writeProjectSchemas(projectRoot: string) {
|
|
35
|
+
const { config } = loadProjectConfig(projectRoot);
|
|
36
|
+
const registry = await buildProjectExtensionRegistry(projectRoot, config);
|
|
37
|
+
|
|
38
|
+
const corePath = coreSchemaRef(projectRoot, CORE_PROJECT_SPECIFIER);
|
|
39
|
+
const fragments: string[] = [];
|
|
40
|
+
const pathsValueRefs: string[] = [];
|
|
41
|
+
const fieldSchemaRefs: string[] = [];
|
|
42
|
+
for (const ext of registry.extensions) {
|
|
43
|
+
const projectFragment = ext.schemas.project;
|
|
44
|
+
if (projectFragment) {
|
|
45
|
+
fragments.push(fragmentRef(projectRoot, ext, projectFragment));
|
|
46
|
+
}
|
|
47
|
+
const documentFragment = ext.schemas.document;
|
|
48
|
+
if (documentFragment) {
|
|
49
|
+
pathsValueRefs.push(...fragmentDefsRefs(documentFragment));
|
|
50
|
+
}
|
|
51
|
+
// The `schemas.fields` manifest convention: a fragment whose $defs members are extension
|
|
52
|
+
// Field extras, unioned into the per-project field resource (specs/extensions.md §5.3).
|
|
53
|
+
const fieldsFragment = ext.schemas.fields;
|
|
54
|
+
if (fieldsFragment) {
|
|
55
|
+
fieldSchemaRefs.push(...fragmentDefsRefs(fieldsFragment));
|
|
56
|
+
fragments.push(fragmentRef(projectRoot, ext, fieldsFragment));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const projectSchema = emitProjectSchema({ corePath, fieldSchemaRefs, fragments });
|
|
61
|
+
const documentSchema = emitDocumentSchema({
|
|
62
|
+
corePath: coreSchemaRef(projectRoot, CORE_DOCUMENT_SPECIFIER),
|
|
63
|
+
pathsValueRefs,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const projectSchemaPath = resolve(projectRoot, "project.schema.json");
|
|
67
|
+
const documentSchemaPath = resolve(projectRoot, "document.schema.json");
|
|
68
|
+
writeFileSync(projectSchemaPath, `${JSON.stringify(projectSchema, null, 2)}\n`, "utf8");
|
|
69
|
+
writeFileSync(documentSchemaPath, `${JSON.stringify(documentSchema, null, 2)}\n`, "utf8");
|
|
70
|
+
return { documentSchemaPath, projectSchemaPath };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read the project's entry documents as PRE-BUNDLED, self-contained schemas — the payload behind
|
|
75
|
+
* the studio's `fetchProjectSchemas` PAL member (dev server route and desktop RPC). Missing or
|
|
76
|
+
* stale entry documents (older than project.json) are regenerated first via
|
|
77
|
+
* {@link writeProjectSchemas}; each is then bundled with a loader restricted to the project root,
|
|
78
|
+
* falling back to host resolution for `./node_modules/...` refs the way `jx validate` does
|
|
79
|
+
* (workspace hoisting leaves in-repo projects without their own node_modules).
|
|
80
|
+
*
|
|
81
|
+
* @param {string} projectRoot - Absolute path to the project directory
|
|
82
|
+
* @returns {Promise<{ project: Record<string, unknown>; document: Record<string, unknown> }>}
|
|
83
|
+
*/
|
|
84
|
+
export async function readBundledProjectSchemas(projectRoot: string) {
|
|
85
|
+
const root = resolve(projectRoot);
|
|
86
|
+
const projectJsonPath = resolve(root, "project.json");
|
|
87
|
+
const projectSchemaPath = resolve(root, "project.schema.json");
|
|
88
|
+
const documentSchemaPath = resolve(root, "document.schema.json");
|
|
89
|
+
|
|
90
|
+
const stale = (path: string): boolean => {
|
|
91
|
+
if (!existsSync(path)) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
return statSync(projectJsonPath).mtimeMs > statSync(path).mtimeMs;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
if (stale(projectSchemaPath) || stale(documentSchemaPath)) {
|
|
101
|
+
await writeProjectSchemas(root);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const loadJson = restrictedSchemaLoader(root);
|
|
105
|
+
const project = await bundleSchema(
|
|
106
|
+
JSON.parse(readFileSync(projectSchemaPath, "utf8")) as Record<string, unknown>,
|
|
107
|
+
loadJson,
|
|
108
|
+
root,
|
|
109
|
+
);
|
|
110
|
+
const document = await bundleSchema(
|
|
111
|
+
JSON.parse(readFileSync(documentSchemaPath, "utf8")) as Record<string, unknown>,
|
|
112
|
+
loadJson,
|
|
113
|
+
root,
|
|
114
|
+
);
|
|
115
|
+
return { document, project };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Bundler loader restricted to the project root, with the validate-project host fallback: a missing
|
|
120
|
+
* `<root>/node_modules/<specifier>` file resolves the specifier project-first, then through the
|
|
121
|
+
* host (workspace symlinks resolve transparently).
|
|
122
|
+
*
|
|
123
|
+
* @param {string} root - Absolute project root
|
|
124
|
+
* @returns {SchemaJsonLoader}
|
|
125
|
+
*/
|
|
126
|
+
function restrictedSchemaLoader(root: string): SchemaJsonLoader {
|
|
127
|
+
return async (path) => {
|
|
128
|
+
const abs = resolve(path);
|
|
129
|
+
const rel = relative(root, abs);
|
|
130
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
131
|
+
throw new Error(`Schema ref "${path}" escapes the project root ${root}`);
|
|
132
|
+
}
|
|
133
|
+
if (existsSync(abs)) {
|
|
134
|
+
return JSON.parse(readFileSync(abs, "utf8")) as Record<string, unknown>;
|
|
135
|
+
}
|
|
136
|
+
const posixRel = rel.split(sep).join("/");
|
|
137
|
+
const marker = "node_modules/";
|
|
138
|
+
const markerIndex = posixRel.lastIndexOf(marker);
|
|
139
|
+
if (markerIndex === -1) {
|
|
140
|
+
throw new Error(`Schema ref "${path}" does not exist`);
|
|
141
|
+
}
|
|
142
|
+
const specifier = posixRel.slice(markerIndex + marker.length);
|
|
143
|
+
let resolved: string;
|
|
144
|
+
try {
|
|
145
|
+
const projRequire = createRequire(resolve(root, "package.json"));
|
|
146
|
+
resolved = projRequire.resolve(specifier);
|
|
147
|
+
} catch {
|
|
148
|
+
try {
|
|
149
|
+
const selfRequire = createRequire(import.meta.url);
|
|
150
|
+
resolved = selfRequire.resolve(specifier);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`Schema ref "${path}" does not exist and "${specifier}" is not resolvable from the ` +
|
|
154
|
+
`project or the host`,
|
|
155
|
+
{ cause: error },
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return JSON.parse(readFileSync(resolved, "utf8")) as Record<string, unknown>;
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Project-relative ref for a core schema file: resolve the specifier project-first (then host) and
|
|
165
|
+
* relativize; escape from the root (workspace symlinks) falls back to the conventional
|
|
166
|
+
* `./node_modules/...` path.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} projectRoot
|
|
169
|
+
* @param {string} specifier - Bare specifier of the shipped core schema file
|
|
170
|
+
* @returns {string}
|
|
171
|
+
*/
|
|
172
|
+
function coreSchemaRef(projectRoot: string, specifier: string): string {
|
|
173
|
+
let resolved: string | null = null;
|
|
174
|
+
try {
|
|
175
|
+
const projRequire = createRequire(resolve(projectRoot, "package.json"));
|
|
176
|
+
resolved = projRequire.resolve(specifier);
|
|
177
|
+
} catch {
|
|
178
|
+
try {
|
|
179
|
+
const selfRequire = createRequire(import.meta.url);
|
|
180
|
+
resolved = selfRequire.resolve(specifier);
|
|
181
|
+
} catch {
|
|
182
|
+
resolved = null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const conventional = `./node_modules/${specifier}`;
|
|
186
|
+
return resolved ? projectRelativeRef(projectRoot, resolved, conventional) : conventional;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Project-relative ref for an extension's fragment file. Local (relative-path) extensions live
|
|
191
|
+
* inside the project; bare-specifier packages use the conventional node_modules path built from the
|
|
192
|
+
* specifier and the fragment's manifest-relative location.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} projectRoot
|
|
195
|
+
* @param {ExtensionInfo} ext
|
|
196
|
+
* @param {string} fragmentPath - Resolved absolute fragment path
|
|
197
|
+
* @returns {string}
|
|
198
|
+
*/
|
|
199
|
+
function fragmentRef(projectRoot: string, ext: ExtensionInfo, fragmentPath: string): string {
|
|
200
|
+
const manifestRel = relative(dirname(ext.manifestPath), fragmentPath).split(sep).join("/");
|
|
201
|
+
const conventional = `./node_modules/${ext.specifier}/${manifestRel}`;
|
|
202
|
+
return projectRelativeRef(projectRoot, fragmentPath, conventional);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Relativize an absolute path against the project root as a POSIX `./` ref, or fall back to the
|
|
207
|
+
* given conventional path when it escapes the root.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} projectRoot
|
|
210
|
+
* @param {string} absPath
|
|
211
|
+
* @param {string} conventional
|
|
212
|
+
* @returns {string}
|
|
213
|
+
*/
|
|
214
|
+
function projectRelativeRef(projectRoot: string, absPath: string, conventional: string): string {
|
|
215
|
+
const rel = relative(projectRoot, absPath).split(sep).join("/");
|
|
216
|
+
if (rel.startsWith("..")) {
|
|
217
|
+
return conventional;
|
|
218
|
+
}
|
|
219
|
+
return rel.startsWith("./") ? rel : `./${rel}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Canonical "uri#/pointer" refs into a fragment's `$defs` shapes: one per `$defs` entry, addressed
|
|
224
|
+
* by the fragment's own `$id` (refs inside the entry document's embeds resolve against canonical
|
|
225
|
+
* URIs, never file locations). Serves both the document paths union and the fields-extras union.
|
|
226
|
+
*
|
|
227
|
+
* @param {string} fragmentPath - Resolved absolute fragment path
|
|
228
|
+
* @returns {string[]}
|
|
229
|
+
*/
|
|
230
|
+
function fragmentDefsRefs(fragmentPath: string): string[] {
|
|
231
|
+
let fragment: { $id?: unknown; $defs?: Record<string, unknown> };
|
|
232
|
+
try {
|
|
233
|
+
fragment = JSON.parse(readFileSync(fragmentPath, "utf8")) as typeof fragment;
|
|
234
|
+
} catch {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
const { $id } = fragment;
|
|
238
|
+
if (typeof $id !== "string" || !fragment.$defs) {
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
return Object.keys(fragment.$defs).map((name) => `${$id}#/$defs/${name}`);
|
|
242
|
+
}
|