@jxsuite/compiler 0.27.0 → 0.28.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.
@@ -30,6 +30,14 @@ export interface ImageConfig {
30
30
  quality: { webp?: number; avif?: number; jpeg?: number; png?: number };
31
31
  sizes: string;
32
32
  lazyLoad: boolean;
33
+ service?: "build" | "cloudflare";
34
+ binding?: string;
35
+ /**
36
+ * Hostnames whose remote (https) images are routed through the optimization pipeline. Cloudflare
37
+ * service only — the generated /_jx/image endpoint fetches and transforms allowlisted remote
38
+ * sources; everything else is rejected to prevent open-proxy use.
39
+ */
40
+ remoteDomains?: string[];
33
41
  }
34
42
 
35
43
  let _sharp: typeof import("sharp") | null = null;
@@ -7,8 +7,15 @@
7
7
  */
8
8
 
9
9
  import { existsSync } from "node:fs";
10
+ import { createHash } from "node:crypto";
10
11
  import { resolve, extname, basename } from "node:path";
11
- import { processImage, buildSrcset, contentHash, configHash } from "./image-optimizer.ts";
12
+ import {
13
+ processImage,
14
+ buildSrcset,
15
+ contentHash,
16
+ configHash,
17
+ getImageMetadata,
18
+ } from "./image-optimizer.ts";
12
19
  import { getCached, setCached, getImageCacheDir } from "./image-cache.ts";
13
20
 
14
21
  import type { ImageConfig } from "./image-optimizer.ts";
@@ -20,6 +27,45 @@ import type { JxDocument } from "@jxsuite/schema/types";
20
27
  const SKIP_EXTENSIONS = new Set([".svg", ".gif"]);
21
28
  const EXTERNAL_PREFIXES = ["http://", "https://", "data:", "//"];
22
29
 
30
+ /** Per-build memo of original image dimensions + content hash (cloudflare mode). */
31
+ export type ImageMetaCache = Map<string, { width: number; height: number; hash: string }>;
32
+
33
+ /**
34
+ * Resolve original image dimensions and content hash for cloudflare mode. Reads only the image
35
+ * header via Sharp — no variants are generated.
36
+ *
37
+ * @param {string} absoluteSrc
38
+ * @param {ImageMetaCache} metaCache
39
+ * @returns {Promise<{ width: number; height: number; hash: string }>}
40
+ */
41
+ async function resolveCfMeta(absoluteSrc: string, metaCache: ImageMetaCache) {
42
+ let meta = metaCache.get(absoluteSrc);
43
+ if (!meta) {
44
+ const m = await getImageMetadata(absoluteSrc);
45
+ meta = { width: m.width, height: m.height, hash: contentHash(absoluteSrc) };
46
+ metaCache.set(absoluteSrc, meta);
47
+ }
48
+ return meta;
49
+ }
50
+
51
+ /**
52
+ * Build a srcset of `/_jx/image` endpoint URLs for the configured widths that fit within the
53
+ * original image width. The `v` param carries the content hash for cache busting.
54
+ *
55
+ * @param {string} src - Original site-relative src (e.g. "/images/hero.png")
56
+ * @param {number[]} widths
57
+ * @param {number} originalWidth
58
+ * @param {string} hash - 8-char content hash
59
+ * @returns {string}
60
+ */
61
+ function buildCloudflareSrcset(src: string, widths: number[], originalWidth: number, hash: string) {
62
+ return widths
63
+ .filter((w) => w <= originalWidth)
64
+ .sort((a, b) => a - b)
65
+ .map((w) => `/_jx/image?src=${encodeURIComponent(src)}&w=${w}&v=${hash} ${w}w`)
66
+ .join(", ");
67
+ }
68
+
23
69
  /**
24
70
  * @param {string} absoluteSrc
25
71
  * @param {string} src
@@ -64,6 +110,35 @@ function shouldSkip(src: string) {
64
110
  return false;
65
111
  }
66
112
 
113
+ /**
114
+ * Check if a remote src is eligible for cloudflare-service optimization: an https URL whose
115
+ * hostname is in the project's `images.remoteDomains` allowlist. Content data (e.g. CSV columns)
116
+ * routinely references externally hosted images — allowlisted hosts flow through the /_jx/image
117
+ * endpoint like local assets; everything else passes through untouched.
118
+ *
119
+ * @param {string} src
120
+ * @param {ImageConfig} config
121
+ * @returns {boolean}
122
+ */
123
+ function isAllowedRemote(src: string, config: ImageConfig) {
124
+ if (config.service !== "cloudflare") return false;
125
+ if (!config.remoteDomains?.length) return false;
126
+ if (typeof src !== "string" || !src.startsWith("https://")) return false;
127
+ if (src.includes("${")) return false;
128
+ try {
129
+ const url = new URL(src);
130
+ if (SKIP_EXTENSIONS.has(extname(url.pathname).toLowerCase())) return false;
131
+ return config.remoteDomains.includes(url.hostname);
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+
137
+ /** 8-char cache-busting hash for a remote URL (original bytes aren't available at build time). */
138
+ function urlHash(src: string) {
139
+ return createHash("sha256").update(src).digest("hex").slice(0, 8);
140
+ }
141
+
67
142
  /**
68
143
  * Resolve a src path to an absolute filesystem path.
69
144
  *
@@ -90,20 +165,23 @@ function resolveImagePath(src: string, projectRoot: string) {
90
165
  * @param {JxMutableNode | JxDocument} doc - The Jx document tree (mutated in place)
91
166
  * @param {ImageConfig} config
92
167
  * @param {string} projectRoot
93
- * @param {CacheManifest} cache
168
+ * @param {CacheManifest | null} cache - Variant cache (build mode); unused in cloudflare mode
169
+ * @param {ImageMetaCache} [metaCache] - Dimension/hash memo (cloudflare mode)
94
170
  * @returns {Promise<{ imageRefs: Map<string, ImageManifest> }>}
95
171
  */
96
172
  export async function transformImageNodes(
97
173
  doc: JxMutableNode | JxDocument,
98
174
  config: ImageConfig,
99
175
  projectRoot: string,
100
- cache: CacheManifest,
176
+ cache: CacheManifest | null,
177
+ metaCache?: ImageMetaCache,
101
178
  ) {
102
179
  const imageRefs: Map<string, ImageManifest> = new Map();
103
180
 
104
181
  if (!config.optimize) return { imageRefs };
105
182
 
106
- await walkAndTransform(doc, config, projectRoot, cache, imageRefs);
183
+ const meta = metaCache ?? new Map();
184
+ await walkAndTransform(doc, config, projectRoot, cache, meta, imageRefs);
107
185
 
108
186
  return { imageRefs };
109
187
  }
@@ -112,20 +190,22 @@ export async function transformImageNodes(
112
190
  * @param {JxMutableNode | JxDocument} node
113
191
  * @param {ImageConfig} config
114
192
  * @param {string} projectRoot
115
- * @param {CacheManifest} cache
193
+ * @param {CacheManifest | null} cache
194
+ * @param {ImageMetaCache} metaCache
116
195
  * @param {Map<string, ImageManifest>} imageRefs
117
196
  */
118
197
  async function walkAndTransform(
119
198
  node: JxMutableNode | JxDocument,
120
199
  config: ImageConfig,
121
200
  projectRoot: string,
122
- cache: CacheManifest,
201
+ cache: CacheManifest | null,
202
+ metaCache: ImageMetaCache,
123
203
  imageRefs: Map<string, ImageManifest>,
124
204
  ) {
125
205
  if (!node || typeof node !== "object") return;
126
206
 
127
207
  if (node.tagName === "img") {
128
- await transformImgNode(node, config, projectRoot, cache, imageRefs);
208
+ await transformImgNode(node, config, projectRoot, cache, metaCache, imageRefs);
129
209
  }
130
210
 
131
211
  if (typeof node.innerHTML === "string" && node.innerHTML.includes("<img")) {
@@ -134,6 +214,7 @@ async function walkAndTransform(
134
214
  config,
135
215
  projectRoot,
136
216
  cache,
217
+ metaCache,
137
218
  imageRefs,
138
219
  );
139
220
  }
@@ -141,7 +222,7 @@ async function walkAndTransform(
141
222
  if (Array.isArray(node.children)) {
142
223
  for (const child of node.children) {
143
224
  if (typeof child === "string") continue;
144
- await walkAndTransform(child, config, projectRoot, cache, imageRefs);
225
+ await walkAndTransform(child, config, projectRoot, cache, metaCache, imageRefs);
145
226
  }
146
227
  }
147
228
  }
@@ -150,45 +231,62 @@ async function walkAndTransform(
150
231
  * @param {JxMutableNode | JxDocument} node
151
232
  * @param {ImageConfig} config
152
233
  * @param {string} projectRoot
153
- * @param {CacheManifest} cache
234
+ * @param {CacheManifest | null} cache
235
+ * @param {ImageMetaCache} metaCache
154
236
  * @param {Map<string, ImageManifest>} imageRefs
155
237
  */
156
238
  async function transformImgNode(
157
239
  node: JxMutableNode | JxDocument,
158
240
  config: ImageConfig,
159
241
  projectRoot: string,
160
- cache: CacheManifest,
242
+ cache: CacheManifest | null,
243
+ metaCache: ImageMetaCache,
161
244
  imageRefs: Map<string, ImageManifest>,
162
245
  ) {
163
246
  if (!node.attributes) node.attributes = {};
164
247
 
165
248
  const src = node.attributes.src ?? node.src;
166
- if (shouldSkip(src)) return;
249
+ const remote = isAllowedRemote(src, config);
250
+ if (!remote && shouldSkip(src)) return;
167
251
  if (node.attributes["data-no-optimize"] !== undefined) return;
168
252
 
169
- const absoluteSrc = resolveImagePath(src, projectRoot);
170
- if (!existsSync(absoluteSrc)) return;
253
+ const absoluteSrc = remote ? null : resolveImagePath(src, projectRoot);
254
+ if (absoluteSrc && !existsSync(absoluteSrc)) return;
171
255
 
172
- let manifest = imageRefs.get(absoluteSrc);
256
+ let srcset;
257
+ let original: { width: number; height: number } | undefined;
173
258
 
174
- if (!manifest) {
175
- manifest = await resolveManifest(absoluteSrc, src, config, projectRoot, cache);
176
- imageRefs.set(absoluteSrc, manifest);
177
- }
259
+ if (remote) {
260
+ // Original dimensions are unknown without fetching — emit every configured width and let
261
+ // the endpoint's scale-down fit avoid upscaling past the source size.
262
+ srcset = buildCloudflareSrcset(src, config.widths, Infinity, urlHash(src));
263
+ } else if (config.service === "cloudflare") {
264
+ const meta = await resolveCfMeta(absoluteSrc as string, metaCache);
265
+ srcset = buildCloudflareSrcset(src, config.widths, meta.width, meta.hash);
266
+ original = meta;
267
+ } else {
268
+ let manifest = imageRefs.get(absoluteSrc as string);
269
+
270
+ if (!manifest) {
271
+ manifest = await resolveManifest(absoluteSrc as string, src, config, projectRoot, cache!);
272
+ imageRefs.set(absoluteSrc as string, manifest);
273
+ }
178
274
 
179
- const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0];
180
- const srcset = buildSrcset(manifest.variants, preferredFormat);
275
+ const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0];
276
+ srcset = buildSrcset(manifest.variants, preferredFormat);
277
+ original = manifest.original;
278
+ }
181
279
 
182
280
  if (srcset) {
183
281
  node.attributes.srcset = srcset;
184
282
  node.attributes.sizes = node.attributes.sizes ?? config.sizes;
185
283
  }
186
284
 
187
- if (!node.attributes.width && manifest.original?.width) {
188
- node.attributes.width = String(manifest.original.width);
285
+ if (!node.attributes.width && original?.width) {
286
+ node.attributes.width = String(original.width);
189
287
  }
190
- if (!node.attributes.height && manifest.original?.height) {
191
- node.attributes.height = String(manifest.original.height);
288
+ if (!node.attributes.height && original?.height) {
289
+ node.attributes.height = String(original.height);
192
290
  }
193
291
 
194
292
  if (config.lazyLoad && node.attributes.loading !== "eager") {
@@ -208,7 +306,8 @@ const DATA_NO_OPT_RE = /\bdata-no-optimize\b/;
208
306
  * @param {string} html
209
307
  * @param {ImageConfig} config
210
308
  * @param {string} projectRoot
211
- * @param {CacheManifest} cache
309
+ * @param {CacheManifest | null} cache
310
+ * @param {ImageMetaCache} metaCache
212
311
  * @param {Map<string, ImageManifest>} imageRefs
213
312
  * @returns {Promise<string>}
214
313
  */
@@ -216,7 +315,8 @@ async function transformInnerHtmlImages(
216
315
  html: string,
217
316
  config: ImageConfig,
218
317
  projectRoot: string,
219
- cache: CacheManifest,
318
+ cache: CacheManifest | null,
319
+ metaCache: ImageMetaCache,
220
320
  imageRefs: Map<string, ImageManifest>,
221
321
  ) {
222
322
  /** @type {{ match: string; replacement: string }[]} */
@@ -232,28 +332,42 @@ async function transformInnerHtmlImages(
232
332
  const srcMatch = attrs.match(SRC_ATTR_RE);
233
333
  if (!srcMatch) continue;
234
334
 
235
- const src = srcMatch[1];
236
- if (shouldSkip(src)) continue;
237
-
238
- const absoluteSrc = resolveImagePath(src, projectRoot);
239
- if (!existsSync(absoluteSrc)) continue;
240
-
241
- let manifest = imageRefs.get(absoluteSrc);
242
- if (!manifest) {
243
- manifest = await resolveManifest(absoluteSrc, src, config, projectRoot, cache);
244
- imageRefs.set(absoluteSrc, manifest);
335
+ // Attribute values in pre-rendered innerHTML are entity-escaped — decode for processing
336
+ const src = srcMatch[1].replaceAll("&amp;", "&");
337
+ const remote = isAllowedRemote(src, config);
338
+ if (!remote && shouldSkip(src)) continue;
339
+
340
+ const absoluteSrc = remote ? null : resolveImagePath(src, projectRoot);
341
+ if (absoluteSrc && !existsSync(absoluteSrc)) continue;
342
+
343
+ let srcset;
344
+ let original: { width: number; height: number } | undefined;
345
+
346
+ if (remote) {
347
+ srcset = buildCloudflareSrcset(src, config.widths, Infinity, urlHash(src));
348
+ } else if (config.service === "cloudflare") {
349
+ const meta = await resolveCfMeta(absoluteSrc as string, metaCache);
350
+ srcset = buildCloudflareSrcset(src, config.widths, meta.width, meta.hash);
351
+ original = meta;
352
+ } else {
353
+ let manifest = imageRefs.get(absoluteSrc as string);
354
+ if (!manifest) {
355
+ manifest = await resolveManifest(absoluteSrc as string, src, config, projectRoot, cache!);
356
+ imageRefs.set(absoluteSrc as string, manifest);
357
+ }
358
+
359
+ const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0];
360
+ srcset = buildSrcset(manifest.variants, preferredFormat);
361
+ original = manifest.original;
245
362
  }
246
-
247
- const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0];
248
- const srcset = buildSrcset(manifest.variants, preferredFormat);
249
363
  if (!srcset) continue;
250
364
 
251
365
  let extra = ` srcset="${srcset}" sizes="${config.sizes}"`;
252
- if (!/\bwidth=/.test(attrs) && manifest.original?.width) {
253
- extra += ` width="${manifest.original.width}"`;
366
+ if (!/\bwidth=/.test(attrs) && original?.width) {
367
+ extra += ` width="${original.width}"`;
254
368
  }
255
- if (!/\bheight=/.test(attrs) && manifest.original?.height) {
256
- extra += ` height="${manifest.original.height}"`;
369
+ if (!/\bheight=/.test(attrs) && original?.height) {
370
+ extra += ` height="${original.height}"`;
257
371
  }
258
372
  if (config.lazyLoad && !/\bloading="eager"/.test(attrs)) {
259
373
  if (!/\bloading=/.test(attrs)) extra += ` loading="lazy"`;
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Pages-discovery.js — File-based route discovery
3
3
  *
4
- * Scans the pages/ directory and builds a route table mapping URL paths to their source JSON files,
5
- * layouts, and metadata.
4
+ * Scans the pages/ directory and builds a route table mapping URL paths to their source files,
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) registered from the project's
7
+ * imports map.
6
8
  *
7
9
  * Conventions (per site-architecture spec §4): pages/index.json → / pages/about.json → /about
8
10
  * pages/about/index.json → /about pages/blog/[slug].json → /blog/:slug (dynamic)
@@ -12,11 +14,13 @@
12
14
 
13
15
  import { readdirSync, readFileSync } from "node:fs";
14
16
  import { resolve, relative, extname, join } from "node:path";
17
+ import type { FormatRegistry } from "@jxsuite/schema/format-registry";
18
+ import type { JxDocument } from "@jxsuite/schema/types";
15
19
  import type { ContentLoaderEntry } from "@jxsuite/parser/types";
16
20
 
17
21
  interface Route {
18
22
  urlPattern: string; // URL pattern (e.g. "/blog/:slug")
19
- sourcePath: string; // Absolute path to the .json source file
23
+ sourcePath: string; // Absolute path to the source file
20
24
  relativePath: string; // Path relative to pages/ dir
21
25
  isDynamic: boolean; // Whether route has parameters
22
26
  isCatchAll: boolean; // Whether route uses [...param] spread
@@ -25,15 +29,45 @@ interface Route {
25
29
  _pathParams?: Record<string, string>; // Resolved path parameters
26
30
  }
27
31
 
32
+ /**
33
+ * Read a page document, dispatching by extension: .json natively, anything else via the format
34
+ * registry's parse capability.
35
+ *
36
+ * @param {string} filePath - Absolute path to the page source
37
+ * @param {FormatRegistry} [registry]
38
+ * @returns {Promise<JxDocument>}
39
+ */
40
+ export async function readPageDocument(
41
+ filePath: string,
42
+ registry?: FormatRegistry,
43
+ ): Promise<JxDocument> {
44
+ const source = readFileSync(filePath, "utf8");
45
+ if (filePath.endsWith(".json")) {
46
+ return JSON.parse(source);
47
+ }
48
+ const ext = extname(filePath).toLowerCase();
49
+ const entry = registry?.byExtension(ext, "parse");
50
+ if (!entry) {
51
+ throw new Error(
52
+ `No format class imported for "${ext}" (${filePath}). ` +
53
+ `Add a format class to project.json imports, e.g. ` +
54
+ `"Markdown": "@jxsuite/parser/Markdown.class.json".`,
55
+ );
56
+ }
57
+ return (await entry.call("parse", source)) as JxDocument;
58
+ }
59
+
28
60
  /**
29
61
  * Discover all routable pages in a pages/ directory.
30
62
  *
31
63
  * @param {string} pagesDir - Absolute path to the pages/ directory
32
- * @returns {Route[]} Sorted route table (static routes first, then dynamic)
64
+ * @param {FormatRegistry} [registry] - Format registry; its "page"-kind extensions are routable
65
+ * @returns {Promise<Route[]>} Sorted route table (static routes first, then dynamic)
33
66
  */
34
- export function discoverPages(pagesDir: string) {
67
+ export async function discoverPages(pagesDir: string, registry?: FormatRegistry) {
35
68
  const routes: Route[] = [];
36
- walkDir(pagesDir, pagesDir, routes);
69
+ const pageExtensions = new Set([".json", ...(registry?.documentExtensions("page") ?? [])]);
70
+ await walkDir(pagesDir, pagesDir, routes, pageExtensions, registry);
37
71
 
38
72
  // Sort: static routes first, then by specificity (more segments = more specific)
39
73
  routes.sort((a, b) => {
@@ -51,8 +85,16 @@ export function discoverPages(pagesDir: string) {
51
85
  * @param {string} dir
52
86
  * @param {string} pagesRoot
53
87
  * @param {Route[]} routes
88
+ * @param {Set<string>} pageExtensions
89
+ * @param {FormatRegistry} [registry]
54
90
  */
55
- function walkDir(dir: string, pagesRoot: string, routes: Route[]) {
91
+ async function walkDir(
92
+ dir: string,
93
+ pagesRoot: string,
94
+ routes: Route[],
95
+ pageExtensions: Set<string>,
96
+ registry?: FormatRegistry,
97
+ ) {
56
98
  const entries = readdirSync(dir, { withFileTypes: true });
57
99
 
58
100
  for (const entry of entries) {
@@ -61,19 +103,19 @@ function walkDir(dir: string, pagesRoot: string, routes: Route[]) {
61
103
  if (entry.isDirectory()) {
62
104
  // Skip underscore-prefixed directories
63
105
  if (entry.name.startsWith("_")) continue;
64
- walkDir(fullPath, pagesRoot, routes);
106
+ await walkDir(fullPath, pagesRoot, routes, pageExtensions, registry);
65
107
  continue;
66
108
  }
67
109
 
68
- // Only process .json and .md files
69
- const ext = extname(entry.name);
70
- if (ext !== ".json" && ext !== ".md") continue;
110
+ // Only process .json and registered page-format files
111
+ const ext = extname(entry.name).toLowerCase();
112
+ if (!pageExtensions.has(ext)) continue;
71
113
 
72
114
  // Skip underscore-prefixed files (local components, not routes)
73
115
  if (entry.name.startsWith("_")) continue;
74
116
 
75
117
  const relativePath = relative(pagesRoot, fullPath);
76
- const route = fileToRoute(relativePath, fullPath);
118
+ const route = await fileToRoute(relativePath, fullPath, registry);
77
119
  if (route) routes.push(route);
78
120
  }
79
121
  }
@@ -83,11 +125,13 @@ function walkDir(dir: string, pagesRoot: string, routes: Route[]) {
83
125
  *
84
126
  * @param {string} relativePath - E.g. "blog/[slug].json"
85
127
  * @param {string} absolutePath - Full filesystem path
86
- * @returns {Route}
128
+ * @param {FormatRegistry} [registry]
129
+ * @returns {Promise<Route>}
87
130
  */
88
- function fileToRoute(relativePath: string, absolutePath: string) {
89
- // Remove .json or .md extension
90
- let urlPath = relativePath.replace(/\.(json|md)$/, "");
131
+ async function fileToRoute(relativePath: string, absolutePath: string, registry?: FormatRegistry) {
132
+ // Remove the source extension
133
+ const ext = extname(relativePath);
134
+ let urlPath = ext ? relativePath.slice(0, -ext.length) : relativePath;
91
135
 
92
136
  // Normalize path separators
93
137
  urlPath = urlPath.split("\\").join("/");
@@ -127,20 +171,9 @@ function fileToRoute(relativePath: string, absolutePath: string) {
127
171
  /** @type {string | null} */
128
172
  let $layout = null;
129
173
  try {
130
- const source = readFileSync(absolutePath, "utf8");
131
- if (absolutePath.endsWith(".md")) {
132
- // Parse YAML frontmatter for $layout
133
- const fmMatch = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
134
- if (fmMatch) {
135
- // Quick regex extraction — avoids full YAML dependency
136
- const layoutMatch = fmMatch[1].match(/^\$layout:\s*(.+)/m);
137
- if (layoutMatch) $layout = layoutMatch[1].trim().replace(/^['"]|['"]$/g, "");
138
- }
139
- } else {
140
- const raw = JSON.parse(source);
141
- if (typeof raw.$layout === "string") {
142
- $layout = raw.$layout;
143
- }
174
+ const doc = await readPageDocument(absolutePath, registry);
175
+ if (typeof doc.$layout === "string") {
176
+ $layout = doc.$layout;
144
177
  }
145
178
  } catch {
146
179
  // Skip unreadable files — will error during compilation
@@ -168,12 +201,14 @@ function fileToRoute(relativePath: string, absolutePath: string) {
168
201
  * @param {Route[]} routes - Discovered route table
169
202
  * @param {string} projectRoot - Project root for resolving $ref paths
170
203
  * @param {Map<string, any[]>} [contentTypes] - Loaded content types (from content-loader)
204
+ * @param {FormatRegistry} [registry] - Format registry for reading non-JSON dynamic pages
171
205
  * @returns {Promise<Route[]>} Expanded routes with concrete paths
172
206
  */
173
207
  export async function expandDynamicRoutes(
174
208
  routes: Route[],
175
209
  projectRoot: string,
176
210
  contentTypes: Map<string, any[]> = new Map(),
211
+ registry?: FormatRegistry,
177
212
  ) {
178
213
  const expanded: Route[] = [];
179
214
 
@@ -187,7 +222,7 @@ export async function expandDynamicRoutes(
187
222
  /** @type {Record<string, unknown>} */
188
223
  let raw;
189
224
  try {
190
- raw = JSON.parse(readFileSync(route.sourcePath, "utf8"));
225
+ raw = await readPageDocument(route.sourcePath, registry);
191
226
  } catch {
192
227
  expanded.push(route);
193
228
  continue;
@@ -16,8 +16,8 @@
16
16
 
17
17
  import { readFileSync } from "node:fs";
18
18
  import { dirname, resolve } from "node:path";
19
- import { pathToFileURL } from "node:url";
20
19
  import { createRequire } from "node:module";
20
+ import { importImplementation } from "./format-host.ts";
21
21
  import type { JxDocument, JxMutableNode } from "@jxsuite/schema/types";
22
22
 
23
23
  /**
@@ -59,7 +59,10 @@ export async function resolvePrototypes(
59
59
  doc: JxMutableNode | JxDocument,
60
60
  route: { sourcePath?: string; _pathParams?: Record<string, string> },
61
61
  projectRoot: string,
62
- projectContext: { config?: Record<string, unknown>; contentTypes?: Map<string, unknown[]> } = {},
62
+ projectContext: {
63
+ config?: Record<string, unknown>;
64
+ contentTypes?: Map<string, unknown[]>;
65
+ } = {},
63
66
  ) {
64
67
  const imports = doc.imports ?? {};
65
68
  const state = doc.state;
@@ -83,7 +86,7 @@ export async function resolvePrototypes(
83
86
  const resolved = await resolveClassPrototype(def, route, projectRoot, state, projectContext);
84
87
  // Preserve timing metadata on the resolved value so compilePage() can strip it
85
88
  if (def.timing && resolved && typeof resolved === "object" && !Array.isArray(resolved)) {
86
- resolved.timing = def.timing;
89
+ (resolved as Record<string, unknown>).timing = def.timing;
87
90
  }
88
91
  state[key] = resolved;
89
92
  } catch (err) {
@@ -110,7 +113,10 @@ async function resolveClassPrototype(
110
113
  route: { sourcePath?: string; _pathParams?: Record<string, string> },
111
114
  projectRoot: string,
112
115
  state: Record<string, unknown>,
113
- projectContext: { config?: Record<string, unknown>; contentTypes?: Map<string, unknown[]> },
116
+ projectContext: {
117
+ config?: Record<string, unknown>;
118
+ contentTypes?: Map<string, unknown[]>;
119
+ },
114
120
  ) {
115
121
  const src = def.$src;
116
122
 
@@ -136,15 +142,17 @@ async function resolveClassPrototype(
136
142
  }
137
143
 
138
144
  // 3. Resolve $implementation relative to .class.json location
139
- const classJsonURL = pathToFileURL(classJsonPath).href;
140
- const implURL = new URL(classDef.$implementation, classJsonURL).href;
145
+ const implPath = resolve(dirname(classJsonPath), classDef.$implementation);
141
146
 
142
- // 4. Import the module
143
- const mod = await import(implURL);
147
+ // 4. Import the module (tolerates src-vs-dist layouts and .js → .ts under Bun)
148
+ const mod = await importImplementation(implPath);
144
149
 
145
150
  // 5. Find the exported class
146
151
  const exportName = def.$export ?? classDef.title ?? def.$prototype;
147
- const ExportedClass = mod[exportName] ?? mod.default?.[exportName];
152
+ const ExportedClass = (mod[exportName] ??
153
+ (mod.default as Record<string, unknown> | undefined)?.[exportName]) as
154
+ | (new (config: Record<string, unknown>) => { resolve?: () => unknown })
155
+ | undefined;
148
156
  if (!ExportedClass) {
149
157
  throw new Error(`Module ${classDef.$implementation} does not export "${exportName}"`);
150
158
  }