@jxsuite/compiler 0.27.0 → 0.28.2

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.
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Format-host — node-side FormatHostIO and project registry construction
3
+ *
4
+ * Provides the filesystem/module I/O the shared format registry needs to run inside the compiler
5
+ * (and dev server). Format classes are auto-discovered from the project-level imports map; see
6
+ * specs/extensions.md.
7
+ */
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import { dirname, resolve, isAbsolute, sep } from "node:path";
11
+ import { pathToFileURL } from "node:url";
12
+ import { createRequire } from "node:module";
13
+ import {
14
+ buildFormatRegistry,
15
+ type FormatRegistry,
16
+ type FormatHostIO,
17
+ } from "@jxsuite/schema/format-registry";
18
+ import type { ProjectConfig } from "@jxsuite/schema/types";
19
+
20
+ export type { FormatRegistry, FormatEntry } from "@jxsuite/schema/format-registry";
21
+
22
+ /**
23
+ * Import a class $implementation module by absolute path, tolerating source-vs-built layouts. A
24
+ * `.class.json` typically declares `"$implementation": "./markdown.js"` next to its TypeScript
25
+ * source: under Bun the sibling `.ts` resolves directly, while under node the built module lives in
26
+ * the package's `dist/` directory. Tries, in order: the literal path, src/ → dist/, .js → .ts.
27
+ *
28
+ * @param {string} path - Absolute path to the implementation module
29
+ * @returns {Promise<Record<string, unknown>>}
30
+ */
31
+ export async function importImplementation(path: string): Promise<Record<string, unknown>> {
32
+ const candidates = [path];
33
+ const srcSeg = `${sep}src${sep}`;
34
+ const distSeg = `${sep}dist${sep}`;
35
+ if (path.includes(srcSeg)) candidates.push(path.replace(srcSeg, distSeg));
36
+ if (path.endsWith(".js")) candidates.push(path.slice(0, -3) + ".ts");
37
+
38
+ let lastError: unknown;
39
+ for (const candidate of candidates) {
40
+ try {
41
+ return await import(pathToFileURL(candidate).href);
42
+ } catch (e) {
43
+ lastError = e;
44
+ }
45
+ }
46
+ throw lastError;
47
+ }
48
+
49
+ /** Create a node FormatHostIO rooted at a project (bare specifiers resolve via node_modules). */
50
+ export function createNodeFormatIO(projectRoot: string): FormatHostIO {
51
+ return {
52
+ loadJson: async (path) => JSON.parse(readFileSync(path, "utf-8")),
53
+ importModule: (path) => importImplementation(path),
54
+ resolvePath: (base, ref) => {
55
+ if (ref.startsWith("./") || ref.startsWith("../")) {
56
+ return resolve(dirname(base), ref);
57
+ }
58
+ if (isAbsolute(ref)) return ref;
59
+ const require = createRequire(resolve(projectRoot, "package.json"));
60
+ return require.resolve(ref);
61
+ },
62
+ };
63
+ }
64
+
65
+ /** Build the format registry for a project from its project.json imports map. */
66
+ export async function buildProjectFormatRegistry(
67
+ projectRoot: string,
68
+ projectConfig?: ProjectConfig,
69
+ ): Promise<FormatRegistry> {
70
+ return buildFormatRegistry(
71
+ projectConfig?.imports as Record<string, string> | undefined,
72
+ createNodeFormatIO(projectRoot),
73
+ resolve(projectRoot, "project.json"),
74
+ );
75
+ }
76
+
77
+ /** Error message for an unregistered non-JSON extension, naming the fix. */
78
+ export function unknownFormatError(path: string, ext: string): Error {
79
+ return new Error(
80
+ `No format class imported for "${ext}" (${path}). ` +
81
+ `Add a format class to project.json imports, e.g. ` +
82
+ `"Markdown": "@jxsuite/parser/Markdown.class.json".`,
83
+ );
84
+ }
@@ -37,7 +37,10 @@ export function mergeHead(
37
37
  { tagName: "meta", attributes: { charset: context.charset ?? "utf-8" } },
38
38
  {
39
39
  tagName: "meta",
40
- attributes: { name: "viewport", content: "width=device-width, initial-scale=1" },
40
+ attributes: {
41
+ name: "viewport",
42
+ content: "width=device-width, initial-scale=1",
43
+ },
41
44
  },
42
45
  ];
43
46
 
@@ -30,6 +30,13 @@ 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
+ /**
35
+ * Hostnames whose remote (https) images are routed through the optimization pipeline. Cloudflare
36
+ * service only — allowlisted remote sources get /cdn-cgi/image transform srcsets (the zone must
37
+ * permit resizing from the remote origin); everything else is left untouched.
38
+ */
39
+ remoteDomains?: string[];
33
40
  }
34
41
 
35
42
  let _sharp: typeof import("sharp") | null = null;
@@ -8,7 +8,13 @@
8
8
 
9
9
  import { existsSync } from "node:fs";
10
10
  import { resolve, extname, basename } from "node:path";
11
- import { processImage, buildSrcset, contentHash, configHash } from "./image-optimizer.ts";
11
+ import {
12
+ processImage,
13
+ buildSrcset,
14
+ contentHash,
15
+ configHash,
16
+ getImageMetadata,
17
+ } from "./image-optimizer.ts";
12
18
  import { getCached, setCached, getImageCacheDir } from "./image-cache.ts";
13
19
 
14
20
  import type { ImageConfig } from "./image-optimizer.ts";
@@ -20,6 +26,60 @@ import type { JxDocument } from "@jxsuite/schema/types";
20
26
  const SKIP_EXTENSIONS = new Set([".svg", ".gif"]);
21
27
  const EXTERNAL_PREFIXES = ["http://", "https://", "data:", "//"];
22
28
 
29
+ /** Per-build memo of original image dimensions + content hash (cloudflare mode). */
30
+ export type ImageMetaCache = Map<string, { width: number; height: number; hash: string }>;
31
+
32
+ /**
33
+ * Resolve original image dimensions and content hash for cloudflare mode. Reads only the image
34
+ * header via Sharp — no variants are generated.
35
+ *
36
+ * @param {string} absoluteSrc
37
+ * @param {ImageMetaCache} metaCache
38
+ * @returns {Promise<{ width: number; height: number; hash: string }>}
39
+ */
40
+ async function resolveCfMeta(absoluteSrc: string, metaCache: ImageMetaCache) {
41
+ let meta = metaCache.get(absoluteSrc);
42
+ if (!meta) {
43
+ const m = await getImageMetadata(absoluteSrc);
44
+ meta = { width: m.width, height: m.height, hash: contentHash(absoluteSrc) };
45
+ metaCache.set(absoluteSrc, meta);
46
+ }
47
+ return meta;
48
+ }
49
+
50
+ /**
51
+ * Build a srcset of Cloudflare `/cdn-cgi/image/` transform-via-URL entries for the configured
52
+ * widths that fit within the original image width. `format=auto` lets Cloudflare negotiate
53
+ * AVIF/WebP per browser; `fit=scale-down` guards against upscaling. Requires Image Transformations
54
+ * to be enabled on the serving zone (does not work on *.pages.dev).
55
+ *
56
+ * @param {string} src - Site-relative src (e.g. "/images/hero.png") or allowlisted https URL
57
+ * @param {ImageConfig} config
58
+ * @param {number} originalWidth
59
+ * @param {string | null} hash - 8-char content hash for cache busting (null for remote sources,
60
+ * whose bytes aren't available at build time)
61
+ * @returns {string}
62
+ */
63
+ function buildCloudflareSrcset(
64
+ src: string,
65
+ config: ImageConfig,
66
+ originalWidth: number,
67
+ hash: string | null,
68
+ ) {
69
+ const quality = config.quality?.webp ?? 80;
70
+ const separator = src.startsWith("/") ? "" : "/";
71
+ const suffix = hash ? `?v=${hash}` : "";
72
+ return config.widths
73
+ .filter((w) => w <= originalWidth)
74
+ .sort((a, b) => a - b)
75
+ .map(
76
+ (w) =>
77
+ `/cdn-cgi/image/width=${w},quality=${quality},fit=scale-down,format=auto` +
78
+ `${separator}${src}${suffix} ${w}w`,
79
+ )
80
+ .join(", ");
81
+ }
82
+
23
83
  /**
24
84
  * @param {string} absoluteSrc
25
85
  * @param {string} src
@@ -64,6 +124,31 @@ function shouldSkip(src: string) {
64
124
  return false;
65
125
  }
66
126
 
127
+ /**
128
+ * Check if a remote src is eligible for cloudflare-service optimization: an https URL whose
129
+ * hostname is in the project's `images.remoteDomains` allowlist. Content data (e.g. CSV columns)
130
+ * routinely references externally hosted images — allowlisted hosts flow through the /cdn-cgi/image
131
+ * transform URL like local assets; everything else passes through untouched. The zone must permit
132
+ * resizing from the remote origin (Images → Transformations → Sources).
133
+ *
134
+ * @param {string} src
135
+ * @param {ImageConfig} config
136
+ * @returns {boolean}
137
+ */
138
+ function isAllowedRemote(src: string, config: ImageConfig) {
139
+ if (config.service !== "cloudflare") return false;
140
+ if (!config.remoteDomains?.length) return false;
141
+ if (typeof src !== "string" || !src.startsWith("https://")) return false;
142
+ if (src.includes("${")) return false;
143
+ try {
144
+ const url = new URL(src);
145
+ if (SKIP_EXTENSIONS.has(extname(url.pathname).toLowerCase())) return false;
146
+ return config.remoteDomains.includes(url.hostname);
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
67
152
  /**
68
153
  * Resolve a src path to an absolute filesystem path.
69
154
  *
@@ -90,20 +175,23 @@ function resolveImagePath(src: string, projectRoot: string) {
90
175
  * @param {JxMutableNode | JxDocument} doc - The Jx document tree (mutated in place)
91
176
  * @param {ImageConfig} config
92
177
  * @param {string} projectRoot
93
- * @param {CacheManifest} cache
178
+ * @param {CacheManifest | null} cache - Variant cache (build mode); unused in cloudflare mode
179
+ * @param {ImageMetaCache} [metaCache] - Dimension/hash memo (cloudflare mode)
94
180
  * @returns {Promise<{ imageRefs: Map<string, ImageManifest> }>}
95
181
  */
96
182
  export async function transformImageNodes(
97
183
  doc: JxMutableNode | JxDocument,
98
184
  config: ImageConfig,
99
185
  projectRoot: string,
100
- cache: CacheManifest,
186
+ cache: CacheManifest | null,
187
+ metaCache?: ImageMetaCache,
101
188
  ) {
102
189
  const imageRefs: Map<string, ImageManifest> = new Map();
103
190
 
104
191
  if (!config.optimize) return { imageRefs };
105
192
 
106
- await walkAndTransform(doc, config, projectRoot, cache, imageRefs);
193
+ const meta = metaCache ?? new Map();
194
+ await walkAndTransform(doc, config, projectRoot, cache, meta, imageRefs);
107
195
 
108
196
  return { imageRefs };
109
197
  }
@@ -112,20 +200,22 @@ export async function transformImageNodes(
112
200
  * @param {JxMutableNode | JxDocument} node
113
201
  * @param {ImageConfig} config
114
202
  * @param {string} projectRoot
115
- * @param {CacheManifest} cache
203
+ * @param {CacheManifest | null} cache
204
+ * @param {ImageMetaCache} metaCache
116
205
  * @param {Map<string, ImageManifest>} imageRefs
117
206
  */
118
207
  async function walkAndTransform(
119
208
  node: JxMutableNode | JxDocument,
120
209
  config: ImageConfig,
121
210
  projectRoot: string,
122
- cache: CacheManifest,
211
+ cache: CacheManifest | null,
212
+ metaCache: ImageMetaCache,
123
213
  imageRefs: Map<string, ImageManifest>,
124
214
  ) {
125
215
  if (!node || typeof node !== "object") return;
126
216
 
127
217
  if (node.tagName === "img") {
128
- await transformImgNode(node, config, projectRoot, cache, imageRefs);
218
+ await transformImgNode(node, config, projectRoot, cache, metaCache, imageRefs);
129
219
  }
130
220
 
131
221
  if (typeof node.innerHTML === "string" && node.innerHTML.includes("<img")) {
@@ -134,6 +224,7 @@ async function walkAndTransform(
134
224
  config,
135
225
  projectRoot,
136
226
  cache,
227
+ metaCache,
137
228
  imageRefs,
138
229
  );
139
230
  }
@@ -141,7 +232,7 @@ async function walkAndTransform(
141
232
  if (Array.isArray(node.children)) {
142
233
  for (const child of node.children) {
143
234
  if (typeof child === "string") continue;
144
- await walkAndTransform(child, config, projectRoot, cache, imageRefs);
235
+ await walkAndTransform(child, config, projectRoot, cache, metaCache, imageRefs);
145
236
  }
146
237
  }
147
238
  }
@@ -150,45 +241,62 @@ async function walkAndTransform(
150
241
  * @param {JxMutableNode | JxDocument} node
151
242
  * @param {ImageConfig} config
152
243
  * @param {string} projectRoot
153
- * @param {CacheManifest} cache
244
+ * @param {CacheManifest | null} cache
245
+ * @param {ImageMetaCache} metaCache
154
246
  * @param {Map<string, ImageManifest>} imageRefs
155
247
  */
156
248
  async function transformImgNode(
157
249
  node: JxMutableNode | JxDocument,
158
250
  config: ImageConfig,
159
251
  projectRoot: string,
160
- cache: CacheManifest,
252
+ cache: CacheManifest | null,
253
+ metaCache: ImageMetaCache,
161
254
  imageRefs: Map<string, ImageManifest>,
162
255
  ) {
163
256
  if (!node.attributes) node.attributes = {};
164
257
 
165
258
  const src = node.attributes.src ?? node.src;
166
- if (shouldSkip(src)) return;
259
+ const remote = isAllowedRemote(src, config);
260
+ if (!remote && shouldSkip(src)) return;
167
261
  if (node.attributes["data-no-optimize"] !== undefined) return;
168
262
 
169
- const absoluteSrc = resolveImagePath(src, projectRoot);
170
- if (!existsSync(absoluteSrc)) return;
263
+ const absoluteSrc = remote ? null : resolveImagePath(src, projectRoot);
264
+ if (absoluteSrc && !existsSync(absoluteSrc)) return;
171
265
 
172
- let manifest = imageRefs.get(absoluteSrc);
266
+ let srcset;
267
+ let original: { width: number; height: number } | undefined;
173
268
 
174
- if (!manifest) {
175
- manifest = await resolveManifest(absoluteSrc, src, config, projectRoot, cache);
176
- imageRefs.set(absoluteSrc, manifest);
177
- }
269
+ if (remote) {
270
+ // Original dimensions are unknown without fetching — emit every configured width and let
271
+ // fit=scale-down avoid upscaling past the source size.
272
+ srcset = buildCloudflareSrcset(src, config, Infinity, null);
273
+ } else if (config.service === "cloudflare") {
274
+ const meta = await resolveCfMeta(absoluteSrc as string, metaCache);
275
+ srcset = buildCloudflareSrcset(src, config, meta.width, meta.hash);
276
+ original = meta;
277
+ } else {
278
+ let manifest = imageRefs.get(absoluteSrc as string);
178
279
 
179
- const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0];
180
- const srcset = buildSrcset(manifest.variants, preferredFormat);
280
+ if (!manifest) {
281
+ manifest = await resolveManifest(absoluteSrc as string, src, config, projectRoot, cache!);
282
+ imageRefs.set(absoluteSrc as string, manifest);
283
+ }
284
+
285
+ const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0];
286
+ srcset = buildSrcset(manifest.variants, preferredFormat);
287
+ original = manifest.original;
288
+ }
181
289
 
182
290
  if (srcset) {
183
291
  node.attributes.srcset = srcset;
184
292
  node.attributes.sizes = node.attributes.sizes ?? config.sizes;
185
293
  }
186
294
 
187
- if (!node.attributes.width && manifest.original?.width) {
188
- node.attributes.width = String(manifest.original.width);
295
+ if (!node.attributes.width && original?.width) {
296
+ node.attributes.width = String(original.width);
189
297
  }
190
- if (!node.attributes.height && manifest.original?.height) {
191
- node.attributes.height = String(manifest.original.height);
298
+ if (!node.attributes.height && original?.height) {
299
+ node.attributes.height = String(original.height);
192
300
  }
193
301
 
194
302
  if (config.lazyLoad && node.attributes.loading !== "eager") {
@@ -208,7 +316,8 @@ const DATA_NO_OPT_RE = /\bdata-no-optimize\b/;
208
316
  * @param {string} html
209
317
  * @param {ImageConfig} config
210
318
  * @param {string} projectRoot
211
- * @param {CacheManifest} cache
319
+ * @param {CacheManifest | null} cache
320
+ * @param {ImageMetaCache} metaCache
212
321
  * @param {Map<string, ImageManifest>} imageRefs
213
322
  * @returns {Promise<string>}
214
323
  */
@@ -216,7 +325,8 @@ async function transformInnerHtmlImages(
216
325
  html: string,
217
326
  config: ImageConfig,
218
327
  projectRoot: string,
219
- cache: CacheManifest,
328
+ cache: CacheManifest | null,
329
+ metaCache: ImageMetaCache,
220
330
  imageRefs: Map<string, ImageManifest>,
221
331
  ) {
222
332
  /** @type {{ match: string; replacement: string }[]} */
@@ -232,28 +342,42 @@ async function transformInnerHtmlImages(
232
342
  const srcMatch = attrs.match(SRC_ATTR_RE);
233
343
  if (!srcMatch) continue;
234
344
 
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);
345
+ // Attribute values in pre-rendered innerHTML are entity-escaped — decode for processing
346
+ const src = srcMatch[1].replaceAll("&amp;", "&");
347
+ const remote = isAllowedRemote(src, config);
348
+ if (!remote && shouldSkip(src)) continue;
349
+
350
+ const absoluteSrc = remote ? null : resolveImagePath(src, projectRoot);
351
+ if (absoluteSrc && !existsSync(absoluteSrc)) continue;
352
+
353
+ let srcset;
354
+ let original: { width: number; height: number } | undefined;
355
+
356
+ if (remote) {
357
+ srcset = buildCloudflareSrcset(src, config, Infinity, null);
358
+ } else if (config.service === "cloudflare") {
359
+ const meta = await resolveCfMeta(absoluteSrc as string, metaCache);
360
+ srcset = buildCloudflareSrcset(src, config, meta.width, meta.hash);
361
+ original = meta;
362
+ } else {
363
+ let manifest = imageRefs.get(absoluteSrc as string);
364
+ if (!manifest) {
365
+ manifest = await resolveManifest(absoluteSrc as string, src, config, projectRoot, cache!);
366
+ imageRefs.set(absoluteSrc as string, manifest);
367
+ }
368
+
369
+ const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0];
370
+ srcset = buildSrcset(manifest.variants, preferredFormat);
371
+ original = manifest.original;
245
372
  }
246
-
247
- const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0];
248
- const srcset = buildSrcset(manifest.variants, preferredFormat);
249
373
  if (!srcset) continue;
250
374
 
251
375
  let extra = ` srcset="${srcset}" sizes="${config.sizes}"`;
252
- if (!/\bwidth=/.test(attrs) && manifest.original?.width) {
253
- extra += ` width="${manifest.original.width}"`;
376
+ if (!/\bwidth=/.test(attrs) && original?.width) {
377
+ extra += ` width="${original.width}"`;
254
378
  }
255
- if (!/\bheight=/.test(attrs) && manifest.original?.height) {
256
- extra += ` height="${manifest.original.height}"`;
379
+ if (!/\bheight=/.test(attrs) && original?.height) {
380
+ extra += ` height="${original.height}"`;
257
381
  }
258
382
  if (config.lazyLoad && !/\bloading="eager"/.test(attrs)) {
259
383
  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;