@jxsuite/compiler 0.28.0 → 0.28.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,62 +7,93 @@
7
7
  */
8
8
 
9
9
  import { existsSync } from "node:fs";
10
- import { createHash } from "node:crypto";
11
- import { resolve, extname, basename } from "node:path";
10
+ import { basename, extname, resolve } from "node:path";
12
11
  import {
13
- processImage,
14
12
  buildSrcset,
15
- contentHash,
16
13
  configHash,
14
+ contentHash,
17
15
  getImageMetadata,
16
+ processImage,
18
17
  } from "./image-optimizer.ts";
19
- import { getCached, setCached, getImageCacheDir } from "./image-cache.ts";
18
+ import { getCached, getImageCacheDir, setCached } from "./image-cache.ts";
20
19
 
21
- import type { ImageConfig } from "./image-optimizer.ts";
22
- import type { ImageManifest } from "./image-optimizer.ts";
23
- import type { JxMutableNode } from "@jxsuite/schema/types";
20
+ import type { ImageConfig, ImageManifest } from "./image-optimizer.ts";
21
+ import type { JxDocument, JxMutableNode } from "@jxsuite/schema/types";
24
22
  import type { CacheManifest } from "./image-cache.ts";
25
- import type { JxDocument } from "@jxsuite/schema/types";
26
23
 
27
24
  const SKIP_EXTENSIONS = new Set([".svg", ".gif"]);
28
25
  const EXTERNAL_PREFIXES = ["http://", "https://", "data:", "//"];
29
26
 
30
27
  /** Per-build memo of original image dimensions + content hash (cloudflare mode). */
31
- export type ImageMetaCache = Map<string, { width: number; height: number; hash: string }>;
28
+ export type ImageMetaCache = Map<
29
+ string,
30
+ { width: number | null; height: number | null; hash: string }
31
+ >;
32
+
33
+ let sharpWarned = false;
32
34
 
33
35
  /**
34
36
  * Resolve original image dimensions and content hash for cloudflare mode. Reads only the image
35
- * header via Sharp — no variants are generated.
37
+ * header via Sharp — no variants are generated. Dimensions degrade to null when Sharp is
38
+ * unavailable (e.g. native binary won't load): srcsets are still emitted (fit=scale-down guards
39
+ * against upscaling), only the width/height attributes are skipped.
36
40
  *
37
41
  * @param {string} absoluteSrc
38
42
  * @param {ImageMetaCache} metaCache
39
- * @returns {Promise<{ width: number; height: number; hash: string }>}
43
+ * @returns {Promise<{ width: number | null; height: number | null; hash: string }>}
40
44
  */
41
45
  async function resolveCfMeta(absoluteSrc: string, metaCache: ImageMetaCache) {
42
46
  let meta = metaCache.get(absoluteSrc);
43
47
  if (!meta) {
44
- const m = await getImageMetadata(absoluteSrc);
45
- meta = { width: m.width, height: m.height, hash: contentHash(absoluteSrc) };
48
+ const hash = contentHash(absoluteSrc);
49
+ try {
50
+ const m = await getImageMetadata(absoluteSrc);
51
+ meta = { width: m.width, height: m.height, hash };
52
+ } catch (error) {
53
+ if (!sharpWarned) {
54
+ sharpWarned = true;
55
+ console.warn(
56
+ `Could not read image dimensions (${(error as Error).message}) — ` +
57
+ `emitting srcsets without width/height attributes.`,
58
+ );
59
+ }
60
+ meta = { width: null, height: null, hash };
61
+ }
46
62
  metaCache.set(absoluteSrc, meta);
47
63
  }
48
64
  return meta;
49
65
  }
50
66
 
51
67
  /**
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.
68
+ * Build a srcset of Cloudflare `/cdn-cgi/image/` transform-via-URL entries for the configured
69
+ * widths that fit within the original image width. `format=auto` lets Cloudflare negotiate
70
+ * AVIF/WebP per browser; `fit=scale-down` guards against upscaling. Requires Image Transformations
71
+ * to be enabled on the serving zone (does not work on *.pages.dev).
54
72
  *
55
- * @param {string} src - Original site-relative src (e.g. "/images/hero.png")
56
- * @param {number[]} widths
73
+ * @param {string} src - Site-relative src (e.g. "/images/hero.png") or allowlisted https URL
74
+ * @param {ImageConfig} config
57
75
  * @param {number} originalWidth
58
- * @param {string} hash - 8-char content hash
76
+ * @param {string | null} hash - 8-char content hash for cache busting (null for remote sources,
77
+ * whose bytes aren't available at build time)
59
78
  * @returns {string}
60
79
  */
61
- function buildCloudflareSrcset(src: string, widths: number[], originalWidth: number, hash: string) {
62
- return widths
80
+ function buildCloudflareSrcset(
81
+ src: string,
82
+ config: ImageConfig,
83
+ originalWidth: number,
84
+ hash: string | null,
85
+ ) {
86
+ const quality = config.quality?.webp ?? 80;
87
+ const separator = src.startsWith("/") ? "" : "/";
88
+ const suffix = hash ? `?v=${hash}` : "";
89
+ return config.widths
63
90
  .filter((w) => w <= originalWidth)
64
- .sort((a, b) => a - b)
65
- .map((w) => `/_jx/image?src=${encodeURIComponent(src)}&w=${w}&v=${hash} ${w}w`)
91
+ .toSorted((a, b) => a - b)
92
+ .map(
93
+ (w) =>
94
+ `/cdn-cgi/image/width=${w},quality=${quality},fit=scale-down,format=auto` +
95
+ `${separator}${src}${suffix} ${w}w`,
96
+ )
66
97
  .join(", ");
67
98
  }
68
99
 
@@ -86,10 +117,14 @@ async function resolveManifest(
86
117
 
87
118
  if (cached) {
88
119
  const allExist = cached.variants.every((v) => existsSync(v.absolutePath));
89
- if (allExist) return cached;
120
+ if (allExist) {
121
+ return cached;
122
+ }
90
123
  }
91
124
 
92
- if (!cached) console.log(` Optimizing ${basename(absoluteSrc)}...`);
125
+ if (!cached) {
126
+ console.log(` Optimizing ${basename(absoluteSrc)}...`);
127
+ }
93
128
  const manifest = await processImage(absoluteSrc, getImageCacheDir(projectRoot), config);
94
129
  setCached(cache, key, src, manifest);
95
130
  return manifest;
@@ -102,43 +137,59 @@ async function resolveManifest(
102
137
  * @returns {boolean}
103
138
  */
104
139
  function shouldSkip(src: string) {
105
- if (typeof src !== "string") return true;
106
- if (!src) return true;
107
- if (src.includes("${")) return true;
108
- if (EXTERNAL_PREFIXES.some((p) => src.startsWith(p))) return true;
109
- if (SKIP_EXTENSIONS.has(extname(src).toLowerCase())) return true;
140
+ if (typeof src !== "string") {
141
+ return true;
142
+ }
143
+ if (!src) {
144
+ return true;
145
+ }
146
+ if (src.includes("${")) {
147
+ return true;
148
+ }
149
+ if (EXTERNAL_PREFIXES.some((p) => src.startsWith(p))) {
150
+ return true;
151
+ }
152
+ if (SKIP_EXTENSIONS.has(extname(src).toLowerCase())) {
153
+ return true;
154
+ }
110
155
  return false;
111
156
  }
112
157
 
113
158
  /**
114
159
  * Check if a remote src is eligible for cloudflare-service optimization: an https URL whose
115
160
  * 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.
161
+ * routinely references externally hosted images — allowlisted hosts flow through the /cdn-cgi/image
162
+ * transform URL like local assets; everything else passes through untouched. The zone must permit
163
+ * resizing from the remote origin (Images → Transformations → Sources).
118
164
  *
119
165
  * @param {string} src
120
166
  * @param {ImageConfig} config
121
167
  * @returns {boolean}
122
168
  */
123
169
  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;
170
+ if (config.service !== "cloudflare") {
171
+ return false;
172
+ }
173
+ if (!config.remoteDomains?.length) {
174
+ return false;
175
+ }
176
+ if (typeof src !== "string" || !src.startsWith("https://")) {
177
+ return false;
178
+ }
179
+ if (src.includes("${")) {
180
+ return false;
181
+ }
128
182
  try {
129
183
  const url = new URL(src);
130
- if (SKIP_EXTENSIONS.has(extname(url.pathname).toLowerCase())) return false;
184
+ if (SKIP_EXTENSIONS.has(extname(url.pathname).toLowerCase())) {
185
+ return false;
186
+ }
131
187
  return config.remoteDomains.includes(url.hostname);
132
188
  } catch {
133
189
  return false;
134
190
  }
135
191
  }
136
192
 
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
-
142
193
  /**
143
194
  * Resolve a src path to an absolute filesystem path.
144
195
  *
@@ -176,9 +227,11 @@ export async function transformImageNodes(
176
227
  cache: CacheManifest | null,
177
228
  metaCache?: ImageMetaCache,
178
229
  ) {
179
- const imageRefs: Map<string, ImageManifest> = new Map();
230
+ const imageRefs = new Map<string, ImageManifest>();
180
231
 
181
- if (!config.optimize) return { imageRefs };
232
+ if (!config.optimize) {
233
+ return { imageRefs };
234
+ }
182
235
 
183
236
  const meta = metaCache ?? new Map();
184
237
  await walkAndTransform(doc, config, projectRoot, cache, meta, imageRefs);
@@ -202,7 +255,9 @@ async function walkAndTransform(
202
255
  metaCache: ImageMetaCache,
203
256
  imageRefs: Map<string, ImageManifest>,
204
257
  ) {
205
- if (!node || typeof node !== "object") return;
258
+ if (!node || typeof node !== "object") {
259
+ return;
260
+ }
206
261
 
207
262
  if (node.tagName === "img") {
208
263
  await transformImgNode(node, config, projectRoot, cache, metaCache, imageRefs);
@@ -221,7 +276,9 @@ async function walkAndTransform(
221
276
 
222
277
  if (Array.isArray(node.children)) {
223
278
  for (const child of node.children) {
224
- if (typeof child === "string") continue;
279
+ if (typeof child === "string") {
280
+ continue;
281
+ }
225
282
  await walkAndTransform(child, config, projectRoot, cache, metaCache, imageRefs);
226
283
  }
227
284
  }
@@ -243,43 +300,59 @@ async function transformImgNode(
243
300
  metaCache: ImageMetaCache,
244
301
  imageRefs: Map<string, ImageManifest>,
245
302
  ) {
246
- if (!node.attributes) node.attributes = {};
303
+ if (!node.attributes) {
304
+ node.attributes = {};
305
+ }
247
306
 
248
307
  const src = node.attributes.src ?? node.src;
308
+ // Bound ($ref) or missing srcs cannot be optimized at build time.
309
+ if (typeof src !== "string") {
310
+ return;
311
+ }
249
312
  const remote = isAllowedRemote(src, config);
250
- if (!remote && shouldSkip(src)) return;
251
- if (node.attributes["data-no-optimize"] !== undefined) return;
252
-
253
- const absoluteSrc = remote ? null : resolveImagePath(src, projectRoot);
254
- if (absoluteSrc && !existsSync(absoluteSrc)) return;
313
+ if (!remote && shouldSkip(src)) {
314
+ return;
315
+ }
316
+ if (node.attributes["data-no-optimize"] !== undefined) {
317
+ return;
318
+ }
255
319
 
256
320
  let srcset;
257
321
  let original: { width: number; height: number } | undefined;
258
322
 
259
323
  if (remote) {
260
324
  // 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;
325
+ // Fit=scale-down avoid upscaling past the source size.
326
+ srcset = buildCloudflareSrcset(src, config, Infinity, null);
267
327
  } 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);
328
+ const absoluteSrc = resolveImagePath(src, projectRoot);
329
+ if (!existsSync(absoluteSrc)) {
330
+ return;
273
331
  }
274
332
 
275
- const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0];
276
- srcset = buildSrcset(manifest.variants, preferredFormat);
277
- original = manifest.original;
333
+ if (config.service === "cloudflare") {
334
+ const meta = await resolveCfMeta(absoluteSrc, metaCache);
335
+ srcset = buildCloudflareSrcset(src, config, meta.width ?? Infinity, meta.hash);
336
+ if (meta.width != null && meta.height != null) {
337
+ original = { width: meta.width, height: meta.height };
338
+ }
339
+ } else {
340
+ let manifest = imageRefs.get(absoluteSrc);
341
+
342
+ if (!manifest) {
343
+ manifest = await resolveManifest(absoluteSrc, src, config, projectRoot, cache!);
344
+ imageRefs.set(absoluteSrc, manifest);
345
+ }
346
+
347
+ const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0];
348
+ srcset = buildSrcset(manifest.variants, preferredFormat);
349
+ ({ original } = manifest);
350
+ }
278
351
  }
279
352
 
280
353
  if (srcset) {
281
354
  node.attributes.srcset = srcset;
282
- node.attributes.sizes = node.attributes.sizes ?? config.sizes;
355
+ node.attributes.sizes ??= config.sizes;
283
356
  }
284
357
 
285
358
  if (!node.attributes.width && original?.width) {
@@ -323,32 +396,44 @@ async function transformInnerHtmlImages(
323
396
  const replacements = [];
324
397
 
325
398
  for (const m of html.matchAll(IMG_TAG_RE)) {
326
- const tag = m[0];
327
- const attrs = m[1];
399
+ const [tag] = m;
400
+ const [, attrs] = m;
328
401
 
329
- if (SRCSET_ATTR_RE.test(attrs)) continue;
330
- if (DATA_NO_OPT_RE.test(attrs)) continue;
402
+ if (SRCSET_ATTR_RE.test(attrs)) {
403
+ continue;
404
+ }
405
+ if (DATA_NO_OPT_RE.test(attrs)) {
406
+ continue;
407
+ }
331
408
 
332
409
  const srcMatch = attrs.match(SRC_ATTR_RE);
333
- if (!srcMatch) continue;
410
+ if (!srcMatch) {
411
+ continue;
412
+ }
334
413
 
335
414
  // Attribute values in pre-rendered innerHTML are entity-escaped — decode for processing
336
415
  const src = srcMatch[1].replaceAll("&amp;", "&");
337
416
  const remote = isAllowedRemote(src, config);
338
- if (!remote && shouldSkip(src)) continue;
417
+ if (!remote && shouldSkip(src)) {
418
+ continue;
419
+ }
339
420
 
340
421
  const absoluteSrc = remote ? null : resolveImagePath(src, projectRoot);
341
- if (absoluteSrc && !existsSync(absoluteSrc)) continue;
422
+ if (absoluteSrc && !existsSync(absoluteSrc)) {
423
+ continue;
424
+ }
342
425
 
343
426
  let srcset;
344
427
  let original: { width: number; height: number } | undefined;
345
428
 
346
429
  if (remote) {
347
- srcset = buildCloudflareSrcset(src, config.widths, Infinity, urlHash(src));
430
+ srcset = buildCloudflareSrcset(src, config, Infinity, null);
348
431
  } else if (config.service === "cloudflare") {
349
432
  const meta = await resolveCfMeta(absoluteSrc as string, metaCache);
350
- srcset = buildCloudflareSrcset(src, config.widths, meta.width, meta.hash);
351
- original = meta;
433
+ srcset = buildCloudflareSrcset(src, config, meta.width ?? Infinity, meta.hash);
434
+ if (meta.width != null && meta.height != null) {
435
+ original = { width: meta.width, height: meta.height };
436
+ }
352
437
  } else {
353
438
  let manifest = imageRefs.get(absoluteSrc as string);
354
439
  if (!manifest) {
@@ -358,9 +443,11 @@ async function transformInnerHtmlImages(
358
443
 
359
444
  const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0];
360
445
  srcset = buildSrcset(manifest.variants, preferredFormat);
361
- original = manifest.original;
446
+ ({ original } = manifest);
447
+ }
448
+ if (!srcset) {
449
+ continue;
362
450
  }
363
- if (!srcset) continue;
364
451
 
365
452
  let extra = ` srcset="${srcset}" sizes="${config.sizes}"`;
366
453
  if (!/\bwidth=/.test(attrs) && original?.width) {
@@ -370,8 +457,12 @@ async function transformInnerHtmlImages(
370
457
  extra += ` height="${original.height}"`;
371
458
  }
372
459
  if (config.lazyLoad && !/\bloading="eager"/.test(attrs)) {
373
- if (!/\bloading=/.test(attrs)) extra += ` loading="lazy"`;
374
- if (!/\bdecoding=/.test(attrs)) extra += ` decoding="async"`;
460
+ if (!/\bloading=/.test(attrs)) {
461
+ extra += ` loading="lazy"`;
462
+ }
463
+ if (!/\bdecoding=/.test(attrs)) {
464
+ extra += ` decoding="async"`;
465
+ }
375
466
  }
376
467
 
377
468
  replacements.push({ match: tag, replacement: `<img${attrs}${extra}>` });
@@ -13,9 +13,10 @@
13
13
  * - Named slots use attributes.slot on page children
14
14
  */
15
15
 
16
- import { readFileSync, existsSync } from "node:fs";
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { parseJxDocument } from "@jxsuite/schema/parse";
17
18
  import { resolve } from "node:path";
18
- import type { JxElement, JxDocument } from "@jxsuite/schema/types";
19
+ import type { JxDocument, JxElement } from "@jxsuite/schema/types";
19
20
 
20
21
  /**
21
22
  * Resolve a page's layout, wrapping the page content in the layout structure.
@@ -48,10 +49,10 @@ export function resolveLayout(
48
49
  /** @type {JxDocument} */
49
50
  let layoutDoc;
50
51
  try {
51
- layoutDoc = JSON.parse(readFileSync(layoutPath, "utf8"));
52
- } catch (e) {
53
- const err = e as Error;
54
- throw new Error(`Invalid layout JSON at ${layoutPath}: ${err.message}`);
52
+ layoutDoc = parseJxDocument(readFileSync(layoutPath, "utf8"), layoutPath);
53
+ } catch (error) {
54
+ const err = error as Error;
55
+ throw new Error(`Invalid layout JSON at ${layoutPath}: ${err.message}`, { cause: error });
55
56
  }
56
57
 
57
58
  // Check for nested layouts (layout inheriting from another layout)
@@ -93,8 +94,12 @@ export function resolveLayout(
93
94
  }
94
95
 
95
96
  // Preserve page-level metadata
96
- if (pageDoc.$head) merged._pageHead = pageDoc.$head;
97
- if (pageDoc.title) merged._pageTitle = pageDoc.title;
97
+ if (pageDoc.$head) {
98
+ merged._pageHead = pageDoc.$head;
99
+ }
100
+ if (pageDoc.title) {
101
+ merged._pageTitle = pageDoc.title;
102
+ }
98
103
 
99
104
  // Remove $layout from merged doc (already resolved)
100
105
  delete merged.$layout;
@@ -117,17 +122,23 @@ export function resolveLayout(
117
122
  * @param {(JxElement | string)[]} children - Page children to distribute
118
123
  */
119
124
  function distributeSlots(node: JxElement, children: (JxElement | string)[]) {
120
- if (!node || typeof node !== "object") return;
121
- if (!Array.isArray(node.children)) return;
125
+ if (!node || typeof node !== "object") {
126
+ return;
127
+ }
128
+ if (!Array.isArray(node.children)) {
129
+ return;
130
+ }
122
131
 
123
132
  // Collect named and default children
124
- const named: Map<string, (JxElement | string)[]> = new Map(); // slot name → children[]
125
- const defaults: (JxElement | string)[] = []; // children without a slot target
133
+ const named = new Map<string, (JxElement | string)[]>(); // Slot name → children[]
134
+ const defaults: (JxElement | string)[] = []; // Children without a slot target
126
135
 
127
136
  for (const child of children) {
128
137
  if (child && typeof child === "object" && child.attributes?.slot) {
129
138
  const slotName = child.attributes.slot as string;
130
- if (!named.has(slotName)) named.set(slotName, []);
139
+ if (!named.has(slotName)) {
140
+ named.set(slotName, []);
141
+ }
131
142
  (named.get(slotName) as (JxElement | string)[]).push(child);
132
143
  } else {
133
144
  defaults.push(child);
@@ -150,8 +161,12 @@ function fillSlots(
150
161
  named: Map<string, (JxElement | string)[]>,
151
162
  defaults: (JxElement | string)[],
152
163
  ) {
153
- if (!node || typeof node !== "object") return;
154
- if (!Array.isArray(node.children)) return;
164
+ if (!node || typeof node !== "object") {
165
+ return;
166
+ }
167
+ if (!Array.isArray(node.children)) {
168
+ return;
169
+ }
155
170
 
156
171
  const newChildren: (JxElement | string)[] = [];
157
172
 
@@ -162,12 +177,12 @@ function fillSlots(
162
177
  if (slotName && named.has(slotName as string)) {
163
178
  // Named slot — replace with matching children
164
179
  newChildren.push(...(named.get(slotName as string) as (JxElement | string)[]));
165
- named.delete(slotName as string); // consumed
180
+ named.delete(slotName as string); // Consumed
166
181
  } else if (!slotName && defaults.length > 0) {
167
182
  // Default slot — replace with unassigned children
168
183
  newChildren.push(...defaults);
169
184
  // Don't clear defaults — only one default slot should exist,
170
- // but if there are multiple, the first one wins
185
+ // But if there are multiple, the first one wins
171
186
  } else {
172
187
  // No matching content — keep slot's fallback children
173
188
  if (Array.isArray(child.children)) {
@@ -191,5 +206,5 @@ function fillSlots(
191
206
  * @returns {JxDocument}
192
207
  */
193
208
  function deepClone(obj: JxDocument) {
194
- return JSON.parse(JSON.stringify(obj));
209
+ return structuredClone(obj);
195
210
  }