@jxsuite/compiler 0.26.2 → 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.
- package/dist/cli.js +232 -350
- package/dist/compiler.js +182 -293
- package/package.json +6 -8
- package/src/compiler.ts +24 -7
- package/src/shared.ts +14 -7
- package/src/site/content-loader.ts +104 -215
- package/src/site/context-injection.ts +1 -1
- package/src/site/format-host.ts +84 -0
- package/src/site/head-merger.ts +4 -1
- package/src/site/image-optimizer.ts +8 -0
- package/src/site/image-transform.ts +157 -43
- package/src/site/pages-discovery.ts +71 -34
- package/src/site/prototype-resolver.ts +17 -9
- package/src/site/site-build.ts +98 -40
- package/src/site/site-loader.ts +19 -0
- package/src/targets/compile-client.ts +32 -7
- package/src/targets/compile-element.ts +20 -10
- package/src/targets/compile-image-endpoint.ts +138 -0
- package/src/targets/compile-server.ts +12 -3
- package/src/targets/compile-markdown.ts +0 -977
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
256
|
+
let srcset;
|
|
257
|
+
let original: { width: number; height: number } | undefined;
|
|
173
258
|
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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 &&
|
|
188
|
-
node.attributes.width = String(
|
|
285
|
+
if (!node.attributes.width && original?.width) {
|
|
286
|
+
node.attributes.width = String(original.width);
|
|
189
287
|
}
|
|
190
|
-
if (!node.attributes.height &&
|
|
191
|
-
node.attributes.height = String(
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
335
|
+
// Attribute values in pre-rendered innerHTML are entity-escaped — decode for processing
|
|
336
|
+
const src = srcMatch[1].replaceAll("&", "&");
|
|
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) &&
|
|
253
|
-
extra += ` width="${
|
|
366
|
+
if (!/\bwidth=/.test(attrs) && original?.width) {
|
|
367
|
+
extra += ` width="${original.width}"`;
|
|
254
368
|
}
|
|
255
|
-
if (!/\bheight=/.test(attrs) &&
|
|
256
|
-
extra += ` 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
|
|
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
|
|
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
|
-
* @
|
|
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
|
-
|
|
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(
|
|
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
|
|
69
|
-
const ext = extname(entry.name);
|
|
70
|
-
if (
|
|
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
|
-
* @
|
|
128
|
+
* @param {FormatRegistry} [registry]
|
|
129
|
+
* @returns {Promise<Route>}
|
|
87
130
|
*/
|
|
88
|
-
function fileToRoute(relativePath: string, absolutePath: string) {
|
|
89
|
-
// Remove
|
|
90
|
-
|
|
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
|
|
131
|
-
if (
|
|
132
|
-
|
|
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 =
|
|
225
|
+
raw = await readPageDocument(route.sourcePath, registry);
|
|
191
226
|
} catch {
|
|
192
227
|
expanded.push(route);
|
|
193
228
|
continue;
|
|
@@ -246,9 +281,11 @@ function resolvePathEntries($paths: any, projectRoot: string, contentTypes: Map<
|
|
|
246
281
|
}
|
|
247
282
|
const param = $paths.param ?? "slug";
|
|
248
283
|
const field = $paths.field ?? "id";
|
|
249
|
-
return entries
|
|
250
|
-
|
|
251
|
-
|
|
284
|
+
return entries
|
|
285
|
+
.map((entry: ContentLoaderEntry) => ({
|
|
286
|
+
[param]: field === "id" ? entry.id : (entry.data[field] ?? entry.id),
|
|
287
|
+
}))
|
|
288
|
+
.filter((p: Record<string, unknown>) => p[param]);
|
|
252
289
|
}
|
|
253
290
|
|
|
254
291
|
// Explicit values: { values: ["en", "fr"], param: "lang" }
|
|
@@ -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: {
|
|
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: {
|
|
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
|
|
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
|
|
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] ??
|
|
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
|
}
|