@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.
Files changed (40) hide show
  1. package/dist/cli.js +224 -355
  2. package/dist/compiler.js +126 -257
  3. package/package.json +8 -8
  4. package/src/cli.ts +12 -14
  5. package/src/compiler.ts +45 -23
  6. package/src/shadow.ts +9 -8
  7. package/src/shared.ts +350 -296
  8. package/src/site/asset-mounts.ts +22 -12
  9. package/src/site/bundler.ts +38 -12
  10. package/src/site/client-runtime.ts +31 -17
  11. package/src/site/csp.ts +20 -11
  12. package/src/site/db-push.ts +38 -24
  13. package/src/site/dev-command.ts +3 -3
  14. package/src/site/format-host.ts +56 -45
  15. package/src/site/headers-emitter.ts +7 -4
  16. package/src/site/i18n.ts +20 -19
  17. package/src/site/image-cache.ts +16 -3
  18. package/src/site/image-optimizer.ts +19 -5
  19. package/src/site/image-transform.ts +56 -60
  20. package/src/site/img-loading.ts +11 -5
  21. package/src/site/layout-resolver.ts +7 -6
  22. package/src/site/link-relations.ts +10 -6
  23. package/src/site/locale-negotiation.ts +1 -1
  24. package/src/site/pages-discovery.ts +65 -47
  25. package/src/site/preview-server.ts +22 -19
  26. package/src/site/project-sections.ts +12 -3
  27. package/src/site/prototype-resolver.ts +94 -42
  28. package/src/site/schema-command.ts +26 -13
  29. package/src/site/service-worker.ts +2 -2
  30. package/src/site/site-build.ts +379 -262
  31. package/src/site/site-loader.ts +6 -3
  32. package/src/site/static-host.ts +3 -3
  33. package/src/site/validate-command.ts +64 -34
  34. package/src/site/well-known.ts +57 -12
  35. package/src/targets/compile-class.ts +17 -5
  36. package/src/targets/compile-client.ts +141 -106
  37. package/src/targets/compile-element.ts +157 -140
  38. package/src/targets/compile-server.ts +11 -5
  39. package/src/targets/compile-static.ts +57 -63
  40. package/src/types.ts +5 -6
@@ -17,14 +17,20 @@
17
17
  */
18
18
 
19
19
  import { readFileSync, readdirSync } from "node:fs";
20
- import { parseJxDocument } from "@jxsuite/schema/parse";
21
- import { compareRoutes, fileToRoute as routeShape } from "@jxsuite/site/routes";
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 { FormatRegistry } from "@jxsuite/schema/format-registry";
25
- import type { JxDocument, JxPathsDef, ProjectConfig } from "@jxsuite/schema/types";
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 (await entry.call("parse", source)) as JxDocument;
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 typeof doc.$translationKey === "string" && doc.$translationKey !== ""
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
- const route = await fileToRoute(relativePath, fullPath, registry);
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 } = routeShape(relativePath);
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 (typeof layout === "string") {
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 {Record<string, unknown>} pathEntry
292
+ * @param {PathParams} pathEntry
292
293
  * @returns {string | null}
293
294
  */
294
- function entryMtime(pathEntry: Record<string, unknown>): string | null {
295
+ function entryMtime(pathEntry: PathParams): string | null {
295
296
  const meta = pathEntry[PATH_ENTRY_META];
296
- if (meta === null || typeof meta !== "object") {
297
+ if (!isJsonObject(meta)) {
297
298
  return null;
298
299
  }
299
- const { mtime } = meta as { mtime?: unknown };
300
- return typeof mtime === "string" && mtime !== "" ? mtime : null;
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 {Record<string, unknown>} [sections] - Loaded project sections (projectData results)
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: Record<string, unknown> = {},
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.filter((r) => r.isDynamic).map((r) => r.urlPattern);
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
- /** @type {Record<string, unknown>} */
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
- expanded.push({
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 {Record<string, unknown>} sections - Loaded project sections keyed by section key
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<Record<string, unknown>[]>} Array of { paramName: value } objects
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: Record<string, unknown>,
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<Record<string, unknown>[]> {
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 = typeof $paths.param === "string" ? $paths.param : "value";
467
- return ($paths.values as unknown[]).map((v) => ({ [param]: v }));
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 && typeof $paths.$ref === "string" && $paths.$ref) {
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: unknown;
479
+ let data: JsonValue;
475
480
  try {
476
- data = JSON.parse(readFileSync(filePath, "utf8")) as unknown;
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
- const err = error as Error;
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 = typeof $paths.param === "string" ? $paths.param : "id";
487
- const field = typeof $paths.field === "string" ? $paths.field : "id";
488
- return (data as Record<string, unknown>[]).map((item: Record<string, unknown>) => ({
489
- [param]: item[field] ?? item.id ?? String(item),
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
- return (await entry.call("resolvePaths", $paths, {
512
+ const context: ResolvePathsContext = {
501
513
  data: sections[entry.project.key],
502
514
  locale,
503
515
  projectConfig,
504
516
  root: projectRoot,
505
- ...(route === undefined ? {} : { params: route.params, urlPattern: route.urlPattern }),
506
- ...(patterns === undefined ? {} : { patterns }),
507
- })) as Record<string, unknown>[];
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: Record<string, string> = {
25
- ".avif": "image/avif",
26
- ".css": "text/css; charset=utf-8",
27
- ".gif": "image/gif",
28
- ".html": "text/html; charset=utf-8",
29
- ".ico": "image/x-icon",
30
- ".jpeg": "image/jpeg",
31
- ".jpg": "image/jpeg",
32
- ".js": "text/javascript; charset=utf-8",
33
- ".json": "application/json; charset=utf-8",
34
- ".png": "image/png",
35
- ".svg": "image/svg+xml",
36
- ".txt": "text/plain; charset=utf-8",
37
- ".webp": "image/webp",
38
- ".woff2": "font/woff2",
39
- ".xml": "application/xml; charset=utf-8",
40
- ...MEDIA_TYPE_BY_EXTENSION,
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": MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream",
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<Record<string, unknown>>} Section key → projectData result
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<Record<string, unknown>> {
30
- const sections: Record<string, unknown> = {};
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 {{ sourcePath?: string; _pathParams?: Record<string, string> }} route - Route info
127
+ * @param {PrototypeRoute} route - Route info
65
128
  * @param {string} projectRoot - Absolute path to the project root
66
- * @param {{
67
- * config?: Record<string, unknown>;
68
- * sections?: Record<string, unknown>;
69
- * registry?: ExtensionRegistry;
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: { sourcePath?: string; _pathParams?: Record<string, string> },
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 (lowered && typeof lowered === "object" && !Array.isArray(lowered)) {
116
- const bundles = (lowered as JsonObject).$bundle;
169
+ if (isJsonObject(lowered)) {
170
+ const bundles = lowered.$bundle;
117
171
  if (Array.isArray(bundles)) {
118
172
  for (const specifier of bundles) {
119
- if (typeof specifier === "string") {
173
+ if (isString(specifier)) {
120
174
  projectContext.registerBundle?.(specifier);
121
175
  }
122
176
  }
123
177
  }
124
- delete (lowered as JsonObject).$bundle;
178
+ delete lowered.$bundle;
125
179
  }
126
- state[key] = lowered as JxStateDefinition;
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 && resolved && typeof resolved === "object" && !Array.isArray(resolved)) {
150
- (resolved as JsonObject).timing = def.timing;
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 {{ sourcePath?: string; _pathParams?: Record<string, string> }} route
236
+ * @param {PrototypeRoute} route
183
237
  * @param {string} projectRoot
184
- * @param {Record<string, unknown>} state - The full page state object
185
- * @param {{ config?: Record<string, unknown>; sections?: Record<string, unknown> }} projectContext
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: { sourcePath?: string; _pathParams?: Record<string, string> },
244
+ route: PrototypeRoute,
191
245
  projectRoot: string,
192
- state: Record<string, unknown>,
193
- projectContext: {
194
- config?: Record<string, unknown>;
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
- const ExportedClass = (mod[exportName] ??
233
- (mod.default as Record<string, unknown> | undefined)?.[exportName]) as
234
- | (new (config: Record<string, unknown>) => { resolve?: () => unknown })
235
- | undefined;
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: Record<string, unknown> = {};
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 (typeof instance.resolve === "function") {
266
- return (await instance.resolve()) as JsonValue;
317
+ if (isResolvingInstance(instance)) {
318
+ return await instance.resolve();
267
319
  }
268
- return instance as unknown as JsonValue;
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: Record<string, unknown>; document: Record<string, unknown> }>}
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
- 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>);
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 JSON.parse(readFileSync(hostResolve(specifier, path), "utf8")) as Record<
219
- string,
220
- unknown
221
- >;
234
+ return readSchemaFile(hostResolve(specifier, path));
222
235
  }
223
236
  if (existsSync(abs)) {
224
- return JSON.parse(readFileSync(abs, "utf8")) as Record<string, unknown>;
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 JSON.parse(readFileSync(resolved, "utf8")) as Record<string, unknown>;
258
+ return readSchemaFile(resolved);
246
259
  };
247
260
  }
248
261