@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.
- package/dist/cli.js +141 -351
- package/dist/compiler.js +100 -304
- package/package.json +6 -8
- package/src/compiler.ts +25 -13
- package/src/shared.ts +14 -7
- package/src/site/content-loader.ts +104 -229
- 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 +7 -0
- package/src/site/image-transform.ts +167 -43
- package/src/site/pages-discovery.ts +66 -31
- package/src/site/prototype-resolver.ts +17 -9
- package/src/site/site-build.ts +101 -70
- package/src/site/site-loader.ts +11 -0
- package/src/targets/compile-client.ts +32 -7
- package/src/targets/compile-element.ts +20 -10
- package/src/targets/compile-server.ts +5 -46
- package/src/targets/compile-markdown.ts +0 -977
|
@@ -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
|
+
}
|
package/src/site/head-merger.ts
CHANGED
|
@@ -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: {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
266
|
+
let srcset;
|
|
267
|
+
let original: { width: number; height: number } | undefined;
|
|
173
268
|
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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 &&
|
|
188
|
-
node.attributes.width = String(
|
|
295
|
+
if (!node.attributes.width && original?.width) {
|
|
296
|
+
node.attributes.width = String(original.width);
|
|
189
297
|
}
|
|
190
|
-
if (!node.attributes.height &&
|
|
191
|
-
node.attributes.height = String(
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
345
|
+
// Attribute values in pre-rendered innerHTML are entity-escaped — decode for processing
|
|
346
|
+
const src = srcMatch[1].replaceAll("&", "&");
|
|
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) &&
|
|
253
|
-
extra += ` width="${
|
|
376
|
+
if (!/\bwidth=/.test(attrs) && original?.width) {
|
|
377
|
+
extra += ` width="${original.width}"`;
|
|
254
378
|
}
|
|
255
|
-
if (!/\bheight=/.test(attrs) &&
|
|
256
|
-
extra += ` 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
|
|
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;
|