@jxsuite/compiler 5.1.1 → 5.1.3
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 +224 -355
- package/dist/compiler.js +126 -257
- package/package.json +8 -8
- package/src/cli.ts +12 -14
- package/src/compiler.ts +45 -23
- package/src/shadow.ts +9 -8
- package/src/shared.ts +350 -296
- package/src/site/asset-mounts.ts +22 -12
- package/src/site/bundler.ts +38 -12
- package/src/site/client-runtime.ts +31 -17
- package/src/site/csp.ts +20 -11
- package/src/site/db-push.ts +38 -24
- package/src/site/dev-command.ts +3 -3
- package/src/site/format-host.ts +56 -45
- package/src/site/headers-emitter.ts +7 -4
- package/src/site/i18n.ts +20 -19
- package/src/site/image-cache.ts +16 -3
- package/src/site/image-optimizer.ts +19 -5
- package/src/site/image-transform.ts +56 -60
- package/src/site/img-loading.ts +11 -5
- package/src/site/layout-resolver.ts +7 -6
- package/src/site/link-relations.ts +10 -6
- package/src/site/locale-negotiation.ts +1 -1
- package/src/site/pages-discovery.ts +65 -47
- package/src/site/preview-server.ts +22 -19
- package/src/site/project-sections.ts +12 -3
- package/src/site/prototype-resolver.ts +94 -42
- package/src/site/schema-command.ts +26 -13
- package/src/site/service-worker.ts +2 -2
- package/src/site/site-build.ts +379 -262
- package/src/site/site-loader.ts +6 -3
- package/src/site/static-host.ts +3 -3
- package/src/site/validate-command.ts +64 -34
- package/src/site/well-known.ts +57 -12
- package/src/targets/compile-class.ts +17 -5
- package/src/targets/compile-client.ts +141 -106
- package/src/targets/compile-element.ts +157 -140
- package/src/targets/compile-server.ts +11 -5
- package/src/targets/compile-static.ts +57 -63
- package/src/types.ts +5 -6
|
@@ -17,14 +17,20 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { readFileSync, readdirSync } from "node:fs";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
20
|
+
import { isJsonObject, isString } from "@jxsuite/schema/guards";
|
|
21
|
+
import { errorMessage, parseJxDocument } from "@jxsuite/schema/parse";
|
|
22
|
+
import { compareRoutes, fileToRoute as routeFromFileName } from "@jxsuite/site/routes";
|
|
22
23
|
import { extname, join, relative, resolve } from "node:path";
|
|
23
24
|
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
24
|
-
import type {
|
|
25
|
-
|
|
25
|
+
import type {
|
|
26
|
+
FormatRegistry,
|
|
27
|
+
PathParams,
|
|
28
|
+
ResolvePathsContext,
|
|
29
|
+
} from "@jxsuite/schema/format-registry";
|
|
30
|
+
import type { JsonValue, JxDocument, JxPathsDef, ProjectConfig } from "@jxsuite/schema/types";
|
|
26
31
|
import { localeOfRoute } from "./i18n.ts";
|
|
27
32
|
import type { ResolvedI18n } from "./i18n.ts";
|
|
33
|
+
import type { ProjectSections } from "./project-sections.ts";
|
|
28
34
|
|
|
29
35
|
interface Route {
|
|
30
36
|
urlPattern: string; // URL pattern (e.g. "/blog/:slug")
|
|
@@ -69,7 +75,7 @@ export async function readPageDocument(
|
|
|
69
75
|
`e.g. "@jxsuite/parser".`,
|
|
70
76
|
);
|
|
71
77
|
}
|
|
72
|
-
return
|
|
78
|
+
return await entry.parseDocument(source);
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
/**
|
|
@@ -165,9 +171,7 @@ async function declaredTranslationKey(
|
|
|
165
171
|
} catch {
|
|
166
172
|
return null;
|
|
167
173
|
}
|
|
168
|
-
return
|
|
169
|
-
? doc.$translationKey
|
|
170
|
-
: null;
|
|
174
|
+
return isString(doc.$translationKey) && doc.$translationKey !== "" ? doc.$translationKey : null;
|
|
171
175
|
}
|
|
172
176
|
|
|
173
177
|
/**
|
|
@@ -230,10 +234,7 @@ async function walkDir(
|
|
|
230
234
|
}
|
|
231
235
|
|
|
232
236
|
const relativePath = relative(pagesRoot, fullPath);
|
|
233
|
-
|
|
234
|
-
if (route) {
|
|
235
|
-
routes.push(route);
|
|
236
|
-
}
|
|
237
|
+
routes.push(await fileToRoute(relativePath, fullPath, registry));
|
|
237
238
|
}
|
|
238
239
|
}
|
|
239
240
|
|
|
@@ -246,14 +247,14 @@ async function walkDir(
|
|
|
246
247
|
* @returns {Promise<Route>}
|
|
247
248
|
*/
|
|
248
249
|
async function fileToRoute(relativePath: string, absolutePath: string, registry?: FormatRegistry) {
|
|
249
|
-
const { isCatchAll, isDynamic, params, urlPattern } =
|
|
250
|
+
const { isCatchAll, isDynamic, params, urlPattern } = routeFromFileName(relativePath);
|
|
250
251
|
|
|
251
252
|
// Peek at the page to extract $layout if present
|
|
252
253
|
let $layout: string | null = null;
|
|
253
254
|
try {
|
|
254
255
|
const doc = await readPageDocument(absolutePath, registry);
|
|
255
256
|
const layout = doc.$layout;
|
|
256
|
-
if (
|
|
257
|
+
if (isString(layout)) {
|
|
257
258
|
$layout = layout;
|
|
258
259
|
}
|
|
259
260
|
} catch {
|
|
@@ -288,16 +289,16 @@ const PATH_ENTRY_META = "_meta";
|
|
|
288
289
|
* parameters and nothing else, and a route with no entry of its own correctly falls back to its
|
|
289
290
|
* template's own modification time.
|
|
290
291
|
*
|
|
291
|
-
* @param {
|
|
292
|
+
* @param {PathParams} pathEntry
|
|
292
293
|
* @returns {string | null}
|
|
293
294
|
*/
|
|
294
|
-
function entryMtime(pathEntry:
|
|
295
|
+
function entryMtime(pathEntry: PathParams): string | null {
|
|
295
296
|
const meta = pathEntry[PATH_ENTRY_META];
|
|
296
|
-
if (meta
|
|
297
|
+
if (!isJsonObject(meta)) {
|
|
297
298
|
return null;
|
|
298
299
|
}
|
|
299
|
-
const { mtime } = meta
|
|
300
|
-
return
|
|
300
|
+
const { mtime } = meta;
|
|
301
|
+
return isString(mtime) && mtime !== "" ? mtime : null;
|
|
301
302
|
}
|
|
302
303
|
|
|
303
304
|
/**
|
|
@@ -333,7 +334,7 @@ function traversalIn(url: string): string | null {
|
|
|
333
334
|
*
|
|
334
335
|
* @param {Route[]} routes - Discovered route table
|
|
335
336
|
* @param {string} projectRoot - Project root for resolving $ref paths
|
|
336
|
-
* @param {
|
|
337
|
+
* @param {ProjectSections} [sections] - Loaded project sections (projectData results)
|
|
337
338
|
* @param {ExtensionRegistry} [registry] - Extension registry (formats view reads non-JSON pages)
|
|
338
339
|
* @param {ProjectConfig} [projectConfig] - Already-loaded project config
|
|
339
340
|
* @returns {Promise<Route[]>} Expanded routes with concrete paths
|
|
@@ -341,7 +342,7 @@ function traversalIn(url: string): string | null {
|
|
|
341
342
|
export async function expandDynamicRoutes(
|
|
342
343
|
routes: Route[],
|
|
343
344
|
projectRoot: string,
|
|
344
|
-
sections:
|
|
345
|
+
sections: ProjectSections = {},
|
|
345
346
|
registry?: ExtensionRegistry,
|
|
346
347
|
projectConfig?: ProjectConfig,
|
|
347
348
|
i18n?: ResolvedI18n | null,
|
|
@@ -351,7 +352,7 @@ export async function expandDynamicRoutes(
|
|
|
351
352
|
* Every dynamic page's pattern, so an extension can tell a route no page produces from one a
|
|
352
353
|
* different page does.
|
|
353
354
|
*/
|
|
354
|
-
const dynamicPatterns = routes.
|
|
355
|
+
const dynamicPatterns = routes.flatMap((r) => (r.isDynamic ? [r.urlPattern] : []));
|
|
355
356
|
|
|
356
357
|
for (const route of routes) {
|
|
357
358
|
if (!route.isDynamic) {
|
|
@@ -360,8 +361,7 @@ export async function expandDynamicRoutes(
|
|
|
360
361
|
}
|
|
361
362
|
|
|
362
363
|
// Read the page to look for $paths
|
|
363
|
-
|
|
364
|
-
let raw;
|
|
364
|
+
let raw: JxDocument;
|
|
365
365
|
try {
|
|
366
366
|
raw = await readPageDocument(route.sourcePath, registry?.formats);
|
|
367
367
|
} catch {
|
|
@@ -413,15 +413,20 @@ export async function expandDynamicRoutes(
|
|
|
413
413
|
}
|
|
414
414
|
|
|
415
415
|
const mtime = entryMtime(pathEntry);
|
|
416
|
-
|
|
416
|
+
/* `urlPattern` is already one of `route`'s own keys, so the spread fixes its position and
|
|
417
|
+
adding `sourceMtime` last leaves every key where the expanded route always had it. */
|
|
418
|
+
const concrete: Route = {
|
|
417
419
|
...route,
|
|
418
420
|
_pathParams: params,
|
|
419
421
|
isCatchAll: false,
|
|
420
422
|
isDynamic: false,
|
|
421
423
|
params: [],
|
|
422
|
-
...(mtime === null ? {} : { sourceMtime: mtime }),
|
|
423
424
|
urlPattern: concreteUrl,
|
|
424
|
-
}
|
|
425
|
+
};
|
|
426
|
+
if (mtime !== null) {
|
|
427
|
+
concrete.sourceMtime = mtime;
|
|
428
|
+
}
|
|
429
|
+
expanded.push(concrete);
|
|
425
430
|
}
|
|
426
431
|
}
|
|
427
432
|
|
|
@@ -437,25 +442,25 @@ export async function expandDynamicRoutes(
|
|
|
437
442
|
*
|
|
438
443
|
* @param {import("@jxsuite/schema/types").JxPathsDef} $paths - The $paths declaration
|
|
439
444
|
* @param {string} projectRoot
|
|
440
|
-
* @param {
|
|
445
|
+
* @param {ProjectSections} sections - Loaded project sections keyed by section key
|
|
441
446
|
* @param {ExtensionRegistry} [registry]
|
|
442
447
|
* @param {ProjectConfig} [projectConfig]
|
|
443
448
|
* @param {string | null} [locale] - The locale the route being expanded belongs to
|
|
444
449
|
* @param {Route} [route] - The dynamic page being expanded; its pattern and parameter names reach
|
|
445
450
|
* an extension's `resolvePaths` so it can produce exactly the parameters this page needs
|
|
446
451
|
* @param {readonly string[]} [patterns] - The pattern of every dynamic page in the project
|
|
447
|
-
* @returns {Promise<
|
|
452
|
+
* @returns {Promise<PathParams[]>} Array of { paramName: value } objects
|
|
448
453
|
*/
|
|
449
454
|
async function resolvePathEntries(
|
|
450
455
|
$paths: JxPathsDef,
|
|
451
456
|
projectRoot: string,
|
|
452
|
-
sections:
|
|
457
|
+
sections: ProjectSections,
|
|
453
458
|
registry?: ExtensionRegistry,
|
|
454
459
|
projectConfig?: ProjectConfig,
|
|
455
460
|
locale?: string | null,
|
|
456
461
|
route?: Pick<Route, "urlPattern" | "params">,
|
|
457
462
|
patterns?: readonly string[],
|
|
458
|
-
): Promise<
|
|
463
|
+
): Promise<PathParams[]> {
|
|
459
464
|
// Legacy: array of param objects
|
|
460
465
|
if (Array.isArray($paths)) {
|
|
461
466
|
return $paths;
|
|
@@ -463,31 +468,38 @@ async function resolvePathEntries(
|
|
|
463
468
|
|
|
464
469
|
// Explicit values: { values: ["en", "fr"], param: "lang" }
|
|
465
470
|
if ("values" in $paths && Array.isArray($paths.values)) {
|
|
466
|
-
const param =
|
|
467
|
-
return
|
|
471
|
+
const param = isString($paths.param) ? $paths.param : "value";
|
|
472
|
+
return $paths.values.map((v) => ({ [param]: v }));
|
|
468
473
|
}
|
|
469
474
|
|
|
470
475
|
// Data file ref: { "$ref": "./data/products.json", param: "id", field: "sku" }
|
|
471
|
-
if ("$ref" in $paths &&
|
|
476
|
+
if ("$ref" in $paths && isString($paths.$ref) && $paths.$ref) {
|
|
472
477
|
const refPath = $paths.$ref;
|
|
473
478
|
const filePath = resolve(projectRoot, refPath);
|
|
474
|
-
let data:
|
|
479
|
+
let data: JsonValue;
|
|
475
480
|
try {
|
|
476
|
-
|
|
481
|
+
// SAFETY: JSON.parse only ever yields JSON values; its `any` return type says nothing more.
|
|
482
|
+
data = JSON.parse(readFileSync(filePath, "utf8")) as JsonValue;
|
|
477
483
|
} catch (error) {
|
|
478
|
-
|
|
479
|
-
console.warn(`Warning: $paths.$ref could not load "${refPath}": ${err.message}`);
|
|
484
|
+
console.warn(`Warning: $paths.$ref could not load "${refPath}": ${errorMessage(error)}`);
|
|
480
485
|
return [];
|
|
481
486
|
}
|
|
482
487
|
if (!Array.isArray(data)) {
|
|
483
488
|
console.warn(`Warning: $paths.$ref "${refPath}" must be a JSON array`);
|
|
484
489
|
return [];
|
|
485
490
|
}
|
|
486
|
-
const param =
|
|
487
|
-
const field =
|
|
488
|
-
return
|
|
489
|
-
|
|
490
|
-
|
|
491
|
+
const param = isString($paths.param) ? $paths.param : "id";
|
|
492
|
+
const field = isString($paths.field) ? $paths.field : "id";
|
|
493
|
+
return data.map((item) => {
|
|
494
|
+
/* A null row has no field to read and no meaningful string form: fail the build rather than
|
|
495
|
+
emit a route whose parameter is the literal "null". */
|
|
496
|
+
if (item === null) {
|
|
497
|
+
throw new TypeError(`$paths.$ref "${refPath}" contains a null entry`);
|
|
498
|
+
}
|
|
499
|
+
return {
|
|
500
|
+
[param]: (isJsonObject(item) ? (item[field] ?? item.id) : undefined) ?? String(item),
|
|
501
|
+
};
|
|
502
|
+
});
|
|
491
503
|
}
|
|
492
504
|
|
|
493
505
|
// Extension-discriminated: the first key with a registered resolvePaths discriminator routes
|
|
@@ -497,14 +509,20 @@ async function resolvePathEntries(
|
|
|
497
509
|
if (!entry?.project) {
|
|
498
510
|
continue;
|
|
499
511
|
}
|
|
500
|
-
|
|
512
|
+
const context: ResolvePathsContext = {
|
|
501
513
|
data: sections[entry.project.key],
|
|
502
514
|
locale,
|
|
503
515
|
projectConfig,
|
|
504
516
|
root: projectRoot,
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
517
|
+
};
|
|
518
|
+
if (route !== undefined) {
|
|
519
|
+
context.params = route.params;
|
|
520
|
+
context.urlPattern = route.urlPattern;
|
|
521
|
+
}
|
|
522
|
+
if (patterns !== undefined) {
|
|
523
|
+
context.patterns = patterns;
|
|
524
|
+
}
|
|
525
|
+
return await entry.call("resolvePaths", $paths, context);
|
|
508
526
|
}
|
|
509
527
|
|
|
510
528
|
console.warn(`Warning: unrecognized $paths shape — skipping`);
|
|
@@ -21,24 +21,26 @@ import { MEDIA_TYPE_BY_EXTENSION } from "@jxsuite/schema/media-type";
|
|
|
21
21
|
* Everything else is this table's own, because a shared table for `image/png` would be a second
|
|
22
22
|
* source of truth for something no standard is ambiguous about.
|
|
23
23
|
*/
|
|
24
|
-
const MIME_TYPES
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
24
|
+
const MIME_TYPES = new Map(
|
|
25
|
+
Object.entries({
|
|
26
|
+
".avif": "image/avif",
|
|
27
|
+
".css": "text/css; charset=utf-8",
|
|
28
|
+
".gif": "image/gif",
|
|
29
|
+
".html": "text/html; charset=utf-8",
|
|
30
|
+
".ico": "image/x-icon",
|
|
31
|
+
".jpeg": "image/jpeg",
|
|
32
|
+
".jpg": "image/jpeg",
|
|
33
|
+
".js": "text/javascript; charset=utf-8",
|
|
34
|
+
".json": "application/json; charset=utf-8",
|
|
35
|
+
".png": "image/png",
|
|
36
|
+
".svg": "image/svg+xml",
|
|
37
|
+
".txt": "text/plain; charset=utf-8",
|
|
38
|
+
".webp": "image/webp",
|
|
39
|
+
".woff2": "font/woff2",
|
|
40
|
+
".xml": "application/xml; charset=utf-8",
|
|
41
|
+
...MEDIA_TYPE_BY_EXTENSION,
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
46
|
* Resolve a request pathname to a file inside distDir, or null. Directory URLs (with or without a
|
|
@@ -81,7 +83,8 @@ export function startPreviewServer(distDir: string, port: number): Server {
|
|
|
81
83
|
|
|
82
84
|
if (filePath) {
|
|
83
85
|
res.writeHead(200, {
|
|
84
|
-
"Content-Type":
|
|
86
|
+
"Content-Type":
|
|
87
|
+
MIME_TYPES.get(extname(filePath).toLowerCase()) ?? "application/octet-stream",
|
|
85
88
|
});
|
|
86
89
|
res.end(readFileSync(filePath));
|
|
87
90
|
return;
|
|
@@ -8,8 +8,17 @@
|
|
|
8
8
|
|
|
9
9
|
import { createNodeFormatIO } from "./format-host.ts";
|
|
10
10
|
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
11
|
+
import type { ProjectSectionData } from "@jxsuite/schema/format-registry";
|
|
11
12
|
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
12
13
|
|
|
14
|
+
/* One section's `projectData` result. The host never reads inside it: it hands it to classes as
|
|
15
|
+
`_project[key]` and back to the owner's own `resolvePaths` as `data`, so the capability contract
|
|
16
|
+
in @jxsuite/schema names it and this module re-exports that name for the hosts built on it. */
|
|
17
|
+
export type { ProjectSectionData } from "@jxsuite/schema/format-registry";
|
|
18
|
+
|
|
19
|
+
/** Every loaded project section, keyed by its section key (`content`, `auth`, ...). */
|
|
20
|
+
export type ProjectSections = Record<string, ProjectSectionData>;
|
|
21
|
+
|
|
13
22
|
/**
|
|
14
23
|
* Load all extension-contributed project sections.
|
|
15
24
|
*
|
|
@@ -20,14 +29,14 @@ import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
|
20
29
|
* @param {string} projectRoot - Absolute path to the project root
|
|
21
30
|
* @param {ProjectConfig | undefined} projectConfig - Already-loaded project config
|
|
22
31
|
* @param {ExtensionRegistry} registry - The project's extension registry
|
|
23
|
-
* @returns {Promise<
|
|
32
|
+
* @returns {Promise<ProjectSections>} Section key → projectData result
|
|
24
33
|
*/
|
|
25
34
|
export async function loadProjectSections(
|
|
26
35
|
projectRoot: string,
|
|
27
36
|
projectConfig: ProjectConfig | undefined,
|
|
28
37
|
registry: ExtensionRegistry,
|
|
29
|
-
): Promise<
|
|
30
|
-
const sections:
|
|
38
|
+
): Promise<ProjectSections> {
|
|
39
|
+
const sections: ProjectSections = {};
|
|
31
40
|
const io = createNodeFormatIO(projectRoot);
|
|
32
41
|
for (const entry of registry.projectContributions()) {
|
|
33
42
|
const key = entry.project?.key;
|
|
@@ -18,17 +18,20 @@ import { readFileSync } from "node:fs";
|
|
|
18
18
|
import { dirname, resolve } from "node:path";
|
|
19
19
|
import { createRequire } from "node:module";
|
|
20
20
|
import { importImplementation } from "./format-host.ts";
|
|
21
|
-
import { isPrototypeDef } from "@jxsuite/schema/guards";
|
|
21
|
+
import { isFunction, isJsonObject, isPrototypeDef, isString } from "@jxsuite/schema/guards";
|
|
22
22
|
import { errorMessage, parseClassDef } from "@jxsuite/schema/parse";
|
|
23
23
|
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
24
24
|
import type {
|
|
25
25
|
JsonObject,
|
|
26
26
|
JsonValue,
|
|
27
27
|
JxDocument,
|
|
28
|
+
JxDocumentValue,
|
|
28
29
|
JxMutableNode,
|
|
29
30
|
JxPrototypeDef,
|
|
30
31
|
JxStateDefinition,
|
|
32
|
+
ProjectConfig,
|
|
31
33
|
} from "@jxsuite/schema/types";
|
|
34
|
+
import type { ProjectSectionData, ProjectSections } from "./project-sections.ts";
|
|
32
35
|
|
|
33
36
|
/**
|
|
34
37
|
* Prototype names handled elsewhere (builtins + legacy content system). These are skipped by the
|
|
@@ -49,6 +52,66 @@ const RESERVED_KEYS = new Set([
|
|
|
49
52
|
"description",
|
|
50
53
|
]);
|
|
51
54
|
|
|
55
|
+
/** The route a document is being resolved for, as far as classes and `lower` read it. */
|
|
56
|
+
interface PrototypeRoute {
|
|
57
|
+
sourcePath?: string;
|
|
58
|
+
_pathParams?: Record<string, string>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Project-level context the build threads into every resolved class. */
|
|
62
|
+
interface PrototypeProjectContext {
|
|
63
|
+
config?: ProjectConfig;
|
|
64
|
+
sections?: ProjectSections;
|
|
65
|
+
registry?: ExtensionRegistry;
|
|
66
|
+
registerBundle?: (specifier: string) => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `config._project`: the project config and root, plus every loaded section under its key. */
|
|
70
|
+
interface ClassProjectContext {
|
|
71
|
+
config: ProjectConfig | null;
|
|
72
|
+
root: string;
|
|
73
|
+
[sectionKey: string]: ProjectSectionData | ProjectConfig | string | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `config._document`: the route being built and the page's whole state. */
|
|
77
|
+
interface ClassDocumentContext {
|
|
78
|
+
route: PrototypeRoute;
|
|
79
|
+
state: Record<string, JxStateDefinition>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The config an external class is constructed with: the def's own non-reserved keys, plus the
|
|
84
|
+
* universal `_project` and `_document` context.
|
|
85
|
+
*/
|
|
86
|
+
type ExternalClassConfig = Record<
|
|
87
|
+
string,
|
|
88
|
+
JxDocumentValue | ClassProjectContext | ClassDocumentContext | undefined
|
|
89
|
+
>;
|
|
90
|
+
|
|
91
|
+
/** A class instance that computes its value: `resolve()` answers with the JSON to bake in. */
|
|
92
|
+
interface ResolvingInstance {
|
|
93
|
+
resolve: () => JsonValue | Promise<JsonValue>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What an external class constructs (specs/extensions.md §8): either an instance that resolves its
|
|
98
|
+
* value, or an instance whose own fields ARE the value.
|
|
99
|
+
*/
|
|
100
|
+
type ExternalClassInstance = ResolvingInstance | JsonObject;
|
|
101
|
+
|
|
102
|
+
/** An external class as its implementation module exports it. */
|
|
103
|
+
type ExternalClass = new (config: ExternalClassConfig) => ExternalClassInstance;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether a constructed instance computes its value through `resolve()`.
|
|
107
|
+
*
|
|
108
|
+
* @param {ExternalClassInstance} instance
|
|
109
|
+
* @returns {boolean}
|
|
110
|
+
*/
|
|
111
|
+
function isResolvingInstance(instance: ExternalClassInstance): instance is ResolvingInstance {
|
|
112
|
+
return isFunction(instance.resolve);
|
|
113
|
+
}
|
|
114
|
+
|
|
52
115
|
/**
|
|
53
116
|
* Resolve all generic $prototype entries in a document's state at build time.
|
|
54
117
|
*
|
|
@@ -61,27 +124,18 @@ const RESERVED_KEYS = new Set([
|
|
|
61
124
|
* result
|
|
62
125
|
*
|
|
63
126
|
* @param {JxMutableNode | JxDocument} doc - The page document (mutated in place)
|
|
64
|
-
* @param {
|
|
127
|
+
* @param {PrototypeRoute} route - Route info
|
|
65
128
|
* @param {string} projectRoot - Absolute path to the project root
|
|
66
|
-
* @param {
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* }} [projectContext]
|
|
71
|
-
* - Project-level context injected into all classes; `sections` holds the extension-loaded project
|
|
72
|
-
* sections keyed by section key (spread into `_project`); `registry` enables manifest class
|
|
73
|
-
* resolution and the generic `lower` capability
|
|
129
|
+
* @param {PrototypeProjectContext} [projectContext] - Project-level context injected into all
|
|
130
|
+
* classes; `sections` holds the extension-loaded project sections keyed by section key (spread
|
|
131
|
+
* into `_project`); `registry` enables manifest class resolution and the generic `lower`
|
|
132
|
+
* capability
|
|
74
133
|
*/
|
|
75
134
|
export async function resolvePrototypes(
|
|
76
135
|
doc: JxMutableNode | JxDocument,
|
|
77
|
-
route:
|
|
136
|
+
route: PrototypeRoute,
|
|
78
137
|
projectRoot: string,
|
|
79
|
-
projectContext: {
|
|
80
|
-
config?: Record<string, unknown>;
|
|
81
|
-
sections?: Record<string, unknown>;
|
|
82
|
-
registry?: ExtensionRegistry;
|
|
83
|
-
registerBundle?: (specifier: string) => void;
|
|
84
|
-
} = {},
|
|
138
|
+
projectContext: PrototypeProjectContext = {},
|
|
85
139
|
) {
|
|
86
140
|
const imports = doc.imports ?? {};
|
|
87
141
|
const { state } = doc;
|
|
@@ -112,18 +166,18 @@ export async function resolvePrototypes(
|
|
|
112
166
|
// A lowered def may name client modules it depends on via `$bundle`
|
|
113
167
|
// (extensions.md §8.3): the site build bundles each specifier for the browser.
|
|
114
168
|
// The key is host metadata, not part of the core def shape — strip it.
|
|
115
|
-
if (
|
|
116
|
-
const bundles =
|
|
169
|
+
if (isJsonObject(lowered)) {
|
|
170
|
+
const bundles = lowered.$bundle;
|
|
117
171
|
if (Array.isArray(bundles)) {
|
|
118
172
|
for (const specifier of bundles) {
|
|
119
|
-
if (
|
|
173
|
+
if (isString(specifier)) {
|
|
120
174
|
projectContext.registerBundle?.(specifier);
|
|
121
175
|
}
|
|
122
176
|
}
|
|
123
177
|
}
|
|
124
|
-
delete
|
|
178
|
+
delete lowered.$bundle;
|
|
125
179
|
}
|
|
126
|
-
state[key] = lowered
|
|
180
|
+
state[key] = lowered;
|
|
127
181
|
} catch (error) {
|
|
128
182
|
console.warn(
|
|
129
183
|
`prototype-resolver: failed to lower "${key}" ($prototype: "${def.$prototype}"):`,
|
|
@@ -146,8 +200,8 @@ export async function resolvePrototypes(
|
|
|
146
200
|
try {
|
|
147
201
|
const resolved = await resolveClassPrototype(def, route, projectRoot, state, projectContext);
|
|
148
202
|
// Preserve timing metadata on the resolved value so compilePage() can strip it
|
|
149
|
-
if (def.timing &&
|
|
150
|
-
|
|
203
|
+
if (def.timing && isJsonObject(resolved)) {
|
|
204
|
+
resolved.timing = def.timing;
|
|
151
205
|
}
|
|
152
206
|
state[key] = resolved;
|
|
153
207
|
} catch (error) {
|
|
@@ -179,22 +233,19 @@ function registryClassPath(
|
|
|
179
233
|
* Resolve a single $prototype entry via its .class.json.
|
|
180
234
|
*
|
|
181
235
|
* @param {JxPrototypeDef} def - The state entry definition
|
|
182
|
-
* @param {
|
|
236
|
+
* @param {PrototypeRoute} route
|
|
183
237
|
* @param {string} projectRoot
|
|
184
|
-
* @param {Record<string,
|
|
185
|
-
* @param {
|
|
238
|
+
* @param {Record<string, JxStateDefinition>} state - The full page state object
|
|
239
|
+
* @param {PrototypeProjectContext} projectContext
|
|
186
240
|
* @returns {Promise<JsonValue>} The resolved value
|
|
187
241
|
*/
|
|
188
242
|
async function resolveClassPrototype(
|
|
189
243
|
def: JxPrototypeDef,
|
|
190
|
-
route:
|
|
244
|
+
route: PrototypeRoute,
|
|
191
245
|
projectRoot: string,
|
|
192
|
-
state: Record<string,
|
|
193
|
-
projectContext:
|
|
194
|
-
|
|
195
|
-
sections?: Record<string, unknown>;
|
|
196
|
-
},
|
|
197
|
-
) {
|
|
246
|
+
state: Record<string, JxStateDefinition>,
|
|
247
|
+
projectContext: PrototypeProjectContext,
|
|
248
|
+
): Promise<JsonValue> {
|
|
198
249
|
const src = def.$src;
|
|
199
250
|
if (!src) {
|
|
200
251
|
throw new Error(`$prototype "${def.$prototype}" has no $src to resolve`);
|
|
@@ -229,16 +280,17 @@ async function resolveClassPrototype(
|
|
|
229
280
|
|
|
230
281
|
// 5. Find the exported class
|
|
231
282
|
const exportName = def.$export ?? classDef.title ?? def.$prototype;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
283
|
+
/* SAFETY: what is found is the export a `.class.json` names as its implementation, which by the
|
|
284
|
+
external-class contract (specs/extensions.md §8) is a class taking this config and building an
|
|
285
|
+
ExternalClassInstance; only its presence is checked below, and the result is baked into the
|
|
286
|
+
page as JSON. */
|
|
287
|
+
const ExportedClass = (mod[exportName] ?? mod.default?.[exportName]) as ExternalClass | undefined;
|
|
236
288
|
if (!ExportedClass) {
|
|
237
289
|
throw new Error(`Module ${classDef.$implementation} does not export "${exportName}"`);
|
|
238
290
|
}
|
|
239
291
|
|
|
240
292
|
// 6. Build config — filter out reserved keys
|
|
241
|
-
const config:
|
|
293
|
+
const config: ExternalClassConfig = {};
|
|
242
294
|
for (const [k, v] of Object.entries(def)) {
|
|
243
295
|
if (!RESERVED_KEYS.has(k)) {
|
|
244
296
|
config[k] = v;
|
|
@@ -262,8 +314,8 @@ async function resolveClassPrototype(
|
|
|
262
314
|
// 7. Instantiate and resolve. Trust boundary: external classes contractually
|
|
263
315
|
// Return JSON-serializable data (the result is baked into the compiled page).
|
|
264
316
|
const instance = new ExportedClass(config);
|
|
265
|
-
if (
|
|
266
|
-
return
|
|
317
|
+
if (isResolvingInstance(instance)) {
|
|
318
|
+
return await instance.resolve();
|
|
267
319
|
}
|
|
268
|
-
return instance
|
|
320
|
+
return instance;
|
|
269
321
|
}
|
|
@@ -23,10 +23,12 @@ import {
|
|
|
23
23
|
composeProjectSchemas,
|
|
24
24
|
flattenSchema,
|
|
25
25
|
} from "@jxsuite/schema/project-schemas";
|
|
26
|
+
import { isJsonObject } from "@jxsuite/schema/guards";
|
|
26
27
|
import { buildProjectExtensionRegistry } from "./format-host.ts";
|
|
27
28
|
import { loadProjectConfig } from "./site-loader.ts";
|
|
28
29
|
import type { ExtensionInfo } from "@jxsuite/schema/extension-registry";
|
|
29
30
|
import type { SchemaJsonLoader } from "@jxsuite/schema/project-schemas";
|
|
31
|
+
import type { JsonObject, JsonValue } from "@jxsuite/schema/types";
|
|
30
32
|
|
|
31
33
|
const CORE_PROJECT_SPECIFIER = "@jxsuite/schema/schemas/project.core.schema.json";
|
|
32
34
|
const CORE_DOCUMENT_SPECIFIER = "@jxsuite/schema/schema.json";
|
|
@@ -85,7 +87,7 @@ export async function writeProjectSchemas(projectRoot: string) {
|
|
|
85
87
|
* (workspace hoisting leaves in-repo projects without their own node_modules).
|
|
86
88
|
*
|
|
87
89
|
* @param {string} projectRoot - Absolute path to the project directory
|
|
88
|
-
* @returns {Promise<{ project:
|
|
90
|
+
* @returns {Promise<{ project: JsonObject; document: JsonObject }>}
|
|
89
91
|
*/
|
|
90
92
|
export async function readBundledProjectSchemas(
|
|
91
93
|
projectRoot: string,
|
|
@@ -129,17 +131,31 @@ export async function readBundledProjectSchemas(
|
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
const loadJson = restrictedSchemaLoader(root);
|
|
132
|
-
const projectFile =
|
|
133
|
-
|
|
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>);
|
|
134
|
+
const projectFile = composed?.projectSchema ?? readSchemaFile(projectSchemaPath);
|
|
135
|
+
const documentFile = composed?.documentSchema ?? readSchemaFile(documentSchemaPath);
|
|
138
136
|
const project = flattenSchema(await bundleSchema(projectFile, loadJson, root));
|
|
139
137
|
const document = flattenSchema(await bundleSchema(documentFile, loadJson, root));
|
|
140
138
|
return { document, project };
|
|
141
139
|
}
|
|
142
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Read and parse one schema file.
|
|
143
|
+
*
|
|
144
|
+
* Every file this host bundles is a schema resource that `bundleSchema` gives an `$id` and embeds
|
|
145
|
+
* under `$defs`, and only a JSON object can be one, so anything else is refused here by name.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} path - Absolute path to the schema file
|
|
148
|
+
* @returns {JsonObject}
|
|
149
|
+
*/
|
|
150
|
+
function readSchemaFile(path: string): JsonObject {
|
|
151
|
+
// SAFETY: JSON.parse only ever yields JSON values; its `any` return type says nothing more.
|
|
152
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as JsonValue;
|
|
153
|
+
if (!isJsonObject(parsed)) {
|
|
154
|
+
throw new TypeError(`Schema file "${path}" is not a JSON object`);
|
|
155
|
+
}
|
|
156
|
+
return parsed;
|
|
157
|
+
}
|
|
158
|
+
|
|
143
159
|
/** Packages whose schemas define the Jx document contract itself, never a project's own content. */
|
|
144
160
|
const FIRST_PARTY_SCHEMA_SCOPE = "@jxsuite/";
|
|
145
161
|
|
|
@@ -215,13 +231,10 @@ export function restrictedSchemaLoader(root: string): SchemaJsonLoader {
|
|
|
215
231
|
// Noticed for six weeks, because the artifact was only ever regenerated on the machine that
|
|
216
232
|
// Had the shadow.
|
|
217
233
|
if (specifier !== null && isFirstPartySchema(specifier)) {
|
|
218
|
-
return
|
|
219
|
-
string,
|
|
220
|
-
unknown
|
|
221
|
-
>;
|
|
234
|
+
return readSchemaFile(hostResolve(specifier, path));
|
|
222
235
|
}
|
|
223
236
|
if (existsSync(abs)) {
|
|
224
|
-
return
|
|
237
|
+
return readSchemaFile(abs);
|
|
225
238
|
}
|
|
226
239
|
if (specifier === null) {
|
|
227
240
|
throw new Error(`Schema ref "${path}" does not exist`);
|
|
@@ -242,7 +255,7 @@ export function restrictedSchemaLoader(root: string): SchemaJsonLoader {
|
|
|
242
255
|
);
|
|
243
256
|
}
|
|
244
257
|
}
|
|
245
|
-
return
|
|
258
|
+
return readSchemaFile(resolved);
|
|
246
259
|
};
|
|
247
260
|
}
|
|
248
261
|
|