@jxsuite/compiler 0.28.2 → 0.28.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.
@@ -12,10 +12,11 @@
12
12
  * prefix)
13
13
  */
14
14
 
15
- import { readdirSync, readFileSync } from "node:fs";
16
- import { resolve, relative, extname, join } from "node:path";
15
+ import { readFileSync, readdirSync } from "node:fs";
16
+ import { parseJxDocument } from "@jxsuite/schema/parse";
17
+ import { extname, join, relative, resolve } from "node:path";
17
18
  import type { FormatRegistry } from "@jxsuite/schema/format-registry";
18
- import type { JxDocument } from "@jxsuite/schema/types";
19
+ import type { JxDocument, JxPathsDef } from "@jxsuite/schema/types";
19
20
  import type { ContentLoaderEntry } from "@jxsuite/parser/types";
20
21
 
21
22
  interface Route {
@@ -43,7 +44,7 @@ export async function readPageDocument(
43
44
  ): Promise<JxDocument> {
44
45
  const source = readFileSync(filePath, "utf8");
45
46
  if (filePath.endsWith(".json")) {
46
- return JSON.parse(source);
47
+ return parseJxDocument(source, filePath);
47
48
  }
48
49
  const ext = extname(filePath).toLowerCase();
49
50
  const entry = registry?.byExtension(ext, "parse");
@@ -71,8 +72,12 @@ export async function discoverPages(pagesDir: string, registry?: FormatRegistry)
71
72
 
72
73
  // Sort: static routes first, then by specificity (more segments = more specific)
73
74
  routes.sort((a, b) => {
74
- if (a.isDynamic !== b.isDynamic) return a.isDynamic ? 1 : -1;
75
- if (a.isCatchAll !== b.isCatchAll) return a.isCatchAll ? 1 : -1;
75
+ if (a.isDynamic !== b.isDynamic) {
76
+ return a.isDynamic ? 1 : -1;
77
+ }
78
+ if (a.isCatchAll !== b.isCatchAll) {
79
+ return a.isCatchAll ? 1 : -1;
80
+ }
76
81
  return a.urlPattern.localeCompare(b.urlPattern);
77
82
  });
78
83
 
@@ -102,21 +107,29 @@ async function walkDir(
102
107
 
103
108
  if (entry.isDirectory()) {
104
109
  // Skip underscore-prefixed directories
105
- if (entry.name.startsWith("_")) continue;
110
+ if (entry.name.startsWith("_")) {
111
+ continue;
112
+ }
106
113
  await walkDir(fullPath, pagesRoot, routes, pageExtensions, registry);
107
114
  continue;
108
115
  }
109
116
 
110
117
  // Only process .json and registered page-format files
111
118
  const ext = extname(entry.name).toLowerCase();
112
- if (!pageExtensions.has(ext)) continue;
119
+ if (!pageExtensions.has(ext)) {
120
+ continue;
121
+ }
113
122
 
114
123
  // Skip underscore-prefixed files (local components, not routes)
115
- if (entry.name.startsWith("_")) continue;
124
+ if (entry.name.startsWith("_")) {
125
+ continue;
126
+ }
116
127
 
117
128
  const relativePath = relative(pagesRoot, fullPath);
118
129
  const route = await fileToRoute(relativePath, fullPath, registry);
119
- if (route) routes.push(route);
130
+ if (route) {
131
+ routes.push(route);
132
+ }
120
133
  }
121
134
  }
122
135
 
@@ -136,7 +149,7 @@ async function fileToRoute(relativePath: string, absolutePath: string, registry?
136
149
  // Normalize path separators
137
150
  urlPath = urlPath.split("\\").join("/");
138
151
 
139
- // index files map to their parent directory
152
+ // Index files map to their parent directory
140
153
  if (urlPath.endsWith("/index")) {
141
154
  urlPath = urlPath.slice(0, -6) || "/";
142
155
  } else if (urlPath === "index") {
@@ -144,7 +157,9 @@ async function fileToRoute(relativePath: string, absolutePath: string, registry?
144
157
  }
145
158
 
146
159
  // Ensure leading slash
147
- if (!urlPath.startsWith("/")) urlPath = "/" + urlPath;
160
+ if (!urlPath.startsWith("/")) {
161
+ urlPath = `/${urlPath}`;
162
+ }
148
163
 
149
164
  // Extract parameters from bracket syntax
150
165
  const params: string[] = [];
@@ -152,7 +167,7 @@ async function fileToRoute(relativePath: string, absolutePath: string, registry?
152
167
  let isCatchAll = false;
153
168
 
154
169
  // Convert [param] → :param and [...param] → *
155
- const urlPattern = urlPath.replace(
170
+ const urlPattern = urlPath.replaceAll(
156
171
  /\[\.\.\.(\w+)\]|\[(\w+)\]/g,
157
172
  (match: string, spread: string, named: string) => {
158
173
  if (spread) {
@@ -168,25 +183,25 @@ async function fileToRoute(relativePath: string, absolutePath: string, registry?
168
183
  );
169
184
 
170
185
  // Peek at the page to extract $layout if present
171
- /** @type {string | null} */
172
- let $layout = null;
186
+ let $layout: string | null = null;
173
187
  try {
174
188
  const doc = await readPageDocument(absolutePath, registry);
175
- if (typeof doc.$layout === "string") {
176
- $layout = doc.$layout;
189
+ const layout = doc.$layout;
190
+ if (typeof layout === "string") {
191
+ $layout = layout;
177
192
  }
178
193
  } catch {
179
194
  // Skip unreadable files — will error during compilation
180
195
  }
181
196
 
182
197
  return {
183
- urlPattern,
184
- sourcePath: absolutePath,
185
- relativePath,
186
- isDynamic,
198
+ $layout,
187
199
  isCatchAll,
200
+ isDynamic,
188
201
  params,
189
- $layout,
202
+ relativePath,
203
+ sourcePath: absolutePath,
204
+ urlPattern,
190
205
  };
191
206
  }
192
207
 
@@ -200,14 +215,15 @@ async function fileToRoute(relativePath: string, absolutePath: string, registry?
200
215
  *
201
216
  * @param {Route[]} routes - Discovered route table
202
217
  * @param {string} projectRoot - Project root for resolving $ref paths
203
- * @param {Map<string, any[]>} [contentTypes] - Loaded content types (from content-loader)
218
+ * @param {Map<string, ContentLoaderEntry[]>} [contentTypes] - Loaded content types (from
219
+ * content-loader)
204
220
  * @param {FormatRegistry} [registry] - Format registry for reading non-JSON dynamic pages
205
221
  * @returns {Promise<Route[]>} Expanded routes with concrete paths
206
222
  */
207
223
  export async function expandDynamicRoutes(
208
224
  routes: Route[],
209
225
  projectRoot: string,
210
- contentTypes: Map<string, any[]> = new Map(),
226
+ contentTypes = new Map<string, ContentLoaderEntry[]>(),
211
227
  registry?: FormatRegistry,
212
228
  ) {
213
229
  const expanded: Route[] = [];
@@ -237,18 +253,20 @@ export async function expandDynamicRoutes(
237
253
 
238
254
  for (const pathEntry of pathEntries) {
239
255
  let concreteUrl = route.urlPattern;
256
+ const params: Record<string, string> = {};
240
257
  for (const [param, value] of Object.entries(pathEntry)) {
241
- concreteUrl = concreteUrl.replace(`:${param}`, value as string);
242
- concreteUrl = concreteUrl.replace("*", value as string);
258
+ params[param] = String(value);
259
+ concreteUrl = concreteUrl.replace(`:${param}`, params[param]);
260
+ concreteUrl = concreteUrl.replace("*", params[param]);
243
261
  }
244
262
 
245
263
  expanded.push({
246
264
  ...route,
247
- urlPattern: concreteUrl,
248
- isDynamic: false,
265
+ _pathParams: params,
249
266
  isCatchAll: false,
267
+ isDynamic: false,
250
268
  params: [],
251
- _pathParams: pathEntry,
269
+ urlPattern: concreteUrl,
252
270
  });
253
271
  }
254
272
  }
@@ -259,19 +277,23 @@ export async function expandDynamicRoutes(
259
277
  /**
260
278
  * Resolve $paths into an array of param objects.
261
279
  *
262
- * @param {any} $paths - The $paths declaration
280
+ * @param {import("@jxsuite/schema/types").JxPathsDef} $paths - The $paths declaration
263
281
  * @param {string} projectRoot
264
- * @param {Map<string, any[]>} contentTypes
265
- * @returns {Record<string, any>[]} Array of { paramName: value } objects
282
+ * @param {Map<string, ContentLoaderEntry[]>} contentTypes
283
+ * @returns {Record<string, unknown>[]} Array of { paramName: value } objects
266
284
  */
267
- function resolvePathEntries($paths: any, projectRoot: string, contentTypes: Map<string, any[]>) {
285
+ function resolvePathEntries(
286
+ $paths: JxPathsDef,
287
+ projectRoot: string,
288
+ contentTypes: Map<string, ContentLoaderEntry[]>,
289
+ ): Record<string, unknown>[] {
268
290
  // Legacy: array of param objects
269
291
  if (Array.isArray($paths)) {
270
292
  return $paths;
271
293
  }
272
294
 
273
295
  // Content type-based: { contentType: "blog", param: "slug", field: "id" }
274
- if ($paths.contentType) {
296
+ if ("contentType" in $paths && $paths.contentType) {
275
297
  const entries = contentTypes.get($paths.contentType);
276
298
  if (!entries || entries.length === 0) {
277
299
  console.warn(
@@ -289,20 +311,20 @@ function resolvePathEntries($paths: any, projectRoot: string, contentTypes: Map<
289
311
  }
290
312
 
291
313
  // Explicit values: { values: ["en", "fr"], param: "lang" }
292
- if (Array.isArray($paths.values)) {
314
+ if ("values" in $paths && Array.isArray($paths.values)) {
293
315
  const param = $paths.param ?? "value";
294
- return $paths.values.map((v: string) => ({ [param]: v }));
316
+ return $paths.values.map((v) => ({ [param]: v }));
295
317
  }
296
318
 
297
319
  // Data file ref: { "$ref": "./data/products.json", param: "id", field: "sku" }
298
- if ($paths.$ref) {
320
+ if ("$ref" in $paths && $paths.$ref) {
299
321
  const filePath = resolve(projectRoot, $paths.$ref);
300
322
  /** @type {Record<string, unknown>[]} */
301
323
  let data;
302
324
  try {
303
325
  data = JSON.parse(readFileSync(filePath, "utf8"));
304
- } catch (e) {
305
- const err = e as Error;
326
+ } catch (error) {
327
+ const err = error as Error;
306
328
  console.warn(`Warning: $paths.$ref could not load "${$paths.$ref}": ${err.message}`);
307
329
  return [];
308
330
  }
@@ -18,7 +18,15 @@ 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 type { JxDocument, JxMutableNode } from "@jxsuite/schema/types";
21
+ import { isPrototypeDef } from "@jxsuite/schema/guards";
22
+ import { errorMessage, parseClassDef } from "@jxsuite/schema/parse";
23
+ import type {
24
+ JsonObject,
25
+ JsonValue,
26
+ JxDocument,
27
+ JxMutableNode,
28
+ JxPrototypeDef,
29
+ } from "@jxsuite/schema/types";
22
30
 
23
31
  /**
24
32
  * Prototype names handled elsewhere (builtins + legacy content system). These are skipped by the
@@ -65,20 +73,30 @@ export async function resolvePrototypes(
65
73
  } = {},
66
74
  ) {
67
75
  const imports = doc.imports ?? {};
68
- const state = doc.state;
69
- if (!state) return;
76
+ const { state } = doc;
77
+ if (!state) {
78
+ return;
79
+ }
70
80
 
71
81
  for (const [key, def] of Object.entries(state)) {
72
- if (!def || typeof def !== "object" || !def.$prototype) continue;
73
- if (SKIP_PROTOTYPES.has(def.$prototype)) continue;
82
+ if (!isPrototypeDef(def)) {
83
+ continue;
84
+ }
85
+ if (SKIP_PROTOTYPES.has(def.$prototype)) {
86
+ continue;
87
+ }
74
88
  // Only resolve timing: "compiler" (or unset timing with a .class.json mapping).
75
89
  // Leave timing: "server" and timing: "client" for their respective pipelines.
76
- if (def.timing && def.timing !== "compiler") continue;
90
+ if (def.timing && def.timing !== "compiler") {
91
+ continue;
92
+ }
77
93
 
78
94
  // Look up in imports if no $src already set
79
95
  if (!def.$src) {
80
96
  const mapped = imports[def.$prototype];
81
- if (!mapped) continue;
97
+ if (!mapped) {
98
+ continue;
99
+ }
82
100
  def.$src = mapped;
83
101
  }
84
102
 
@@ -86,13 +104,13 @@ export async function resolvePrototypes(
86
104
  const resolved = await resolveClassPrototype(def, route, projectRoot, state, projectContext);
87
105
  // Preserve timing metadata on the resolved value so compilePage() can strip it
88
106
  if (def.timing && resolved && typeof resolved === "object" && !Array.isArray(resolved)) {
89
- (resolved as Record<string, unknown>).timing = def.timing;
107
+ (resolved as JsonObject).timing = def.timing;
90
108
  }
91
109
  state[key] = resolved;
92
- } catch (err) {
110
+ } catch (error) {
93
111
  console.warn(
94
112
  `prototype-resolver: failed to resolve "${key}" ($prototype: "${def.$prototype}"):`,
95
- (err as Error).message,
113
+ errorMessage(error),
96
114
  );
97
115
  }
98
116
  }
@@ -101,15 +119,15 @@ export async function resolvePrototypes(
101
119
  /**
102
120
  * Resolve a single $prototype entry via its .class.json.
103
121
  *
104
- * @param {Record<string, any>} def - The state entry definition
122
+ * @param {JxPrototypeDef} def - The state entry definition
105
123
  * @param {{ sourcePath?: string; _pathParams?: Record<string, string> }} route
106
124
  * @param {string} projectRoot
107
125
  * @param {Record<string, unknown>} state - The full page state object
108
126
  * @param {{ config?: Record<string, unknown>; contentTypes?: Map<string, unknown[]> }} projectContext
109
- * @returns {Promise<any>} The resolved value
127
+ * @returns {Promise<JsonValue>} The resolved value
110
128
  */
111
129
  async function resolveClassPrototype(
112
- def: Record<string, any>,
130
+ def: JxPrototypeDef,
113
131
  route: { sourcePath?: string; _pathParams?: Record<string, string> },
114
132
  projectRoot: string,
115
133
  state: Record<string, unknown>,
@@ -119,6 +137,9 @@ async function resolveClassPrototype(
119
137
  },
120
138
  ) {
121
139
  const src = def.$src;
140
+ if (!src) {
141
+ throw new Error(`$prototype "${def.$prototype}" has no $src to resolve`);
142
+ }
122
143
 
123
144
  // 1. Resolve .class.json path — handles both npm specifiers and relative paths
124
145
  let classJsonPath;
@@ -128,14 +149,14 @@ async function resolveClassPrototype(
128
149
  ? resolve(dirname(route.sourcePath), src)
129
150
  : resolve(projectRoot, src);
130
151
  } else {
131
- // npm/bare specifier — use createRequire from the project root to walk node_modules
152
+ // Npm/bare specifier — use createRequire from the project root to walk node_modules
132
153
  const require = createRequire(resolve(projectRoot, "package.json"));
133
154
  classJsonPath = require.resolve(src);
134
155
  }
135
156
 
136
157
  // 2. Read and parse .class.json
137
- const classJsonText = readFileSync(classJsonPath, "utf-8");
138
- const classDef = JSON.parse(classJsonText);
158
+ const classJsonText = readFileSync(classJsonPath, "utf8");
159
+ const classDef = parseClassDef(classJsonText, classJsonPath);
139
160
 
140
161
  if (!classDef.$implementation) {
141
162
  throw new Error(`${src} has no $implementation field`);
@@ -158,9 +179,11 @@ async function resolveClassPrototype(
158
179
  }
159
180
 
160
181
  // 6. Build config — filter out reserved keys
161
- const config: Record<string, any> = {};
182
+ const config: Record<string, unknown> = {};
162
183
  for (const [k, v] of Object.entries(def)) {
163
- if (!RESERVED_KEYS.has(k)) config[k] = v;
184
+ if (!RESERVED_KEYS.has(k)) {
185
+ config[k] = v;
186
+ }
164
187
  }
165
188
 
166
189
  // Auto-set basePath from the page's directory if the config has `src` but no `basePath`
@@ -176,10 +199,11 @@ async function resolveClassPrototype(
176
199
  };
177
200
  config._document = { route, state };
178
201
 
179
- // 7. Instantiate and resolve
202
+ // 7. Instantiate and resolve. Trust boundary: external classes contractually
203
+ // Return JSON-serializable data (the result is baked into the compiled page).
180
204
  const instance = new ExportedClass(config);
181
205
  if (typeof instance.resolve === "function") {
182
- return await instance.resolve();
206
+ return (await instance.resolve()) as JsonValue;
183
207
  }
184
- return instance;
208
+ return instance as unknown as JsonValue;
185
209
  }