@jxsuite/compiler 0.28.2 → 0.28.4

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,41 +7,58 @@
7
7
  */
8
8
 
9
9
  import { existsSync } from "node:fs";
10
- import { resolve, extname, basename } from "node:path";
10
+ import { basename, extname, resolve } from "node:path";
11
11
  import {
12
- processImage,
13
12
  buildSrcset,
14
- contentHash,
15
13
  configHash,
14
+ contentHash,
16
15
  getImageMetadata,
16
+ processImage,
17
17
  } from "./image-optimizer.ts";
18
- import { getCached, setCached, getImageCacheDir } from "./image-cache.ts";
18
+ import { getCached, getImageCacheDir, setCached } from "./image-cache.ts";
19
19
 
20
- import type { ImageConfig } from "./image-optimizer.ts";
21
- import type { ImageManifest } from "./image-optimizer.ts";
22
- 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";
23
22
  import type { CacheManifest } from "./image-cache.ts";
24
- import type { JxDocument } from "@jxsuite/schema/types";
25
23
 
26
24
  const SKIP_EXTENSIONS = new Set([".svg", ".gif"]);
27
25
  const EXTERNAL_PREFIXES = ["http://", "https://", "data:", "//"];
28
26
 
29
27
  /** Per-build memo of original image dimensions + content hash (cloudflare mode). */
30
- 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;
31
34
 
32
35
  /**
33
36
  * Resolve original image dimensions and content hash for cloudflare mode. Reads only the image
34
- * 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.
35
40
  *
36
41
  * @param {string} absoluteSrc
37
42
  * @param {ImageMetaCache} metaCache
38
- * @returns {Promise<{ width: number; height: number; hash: string }>}
43
+ * @returns {Promise<{ width: number | null; height: number | null; hash: string }>}
39
44
  */
40
45
  async function resolveCfMeta(absoluteSrc: string, metaCache: ImageMetaCache) {
41
46
  let meta = metaCache.get(absoluteSrc);
42
47
  if (!meta) {
43
- const m = await getImageMetadata(absoluteSrc);
44
- 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
+ }
45
62
  metaCache.set(absoluteSrc, meta);
46
63
  }
47
64
  return meta;
@@ -71,7 +88,7 @@ function buildCloudflareSrcset(
71
88
  const suffix = hash ? `?v=${hash}` : "";
72
89
  return config.widths
73
90
  .filter((w) => w <= originalWidth)
74
- .sort((a, b) => a - b)
91
+ .toSorted((a, b) => a - b)
75
92
  .map(
76
93
  (w) =>
77
94
  `/cdn-cgi/image/width=${w},quality=${quality},fit=scale-down,format=auto` +
@@ -100,10 +117,14 @@ async function resolveManifest(
100
117
 
101
118
  if (cached) {
102
119
  const allExist = cached.variants.every((v) => existsSync(v.absolutePath));
103
- if (allExist) return cached;
120
+ if (allExist) {
121
+ return cached;
122
+ }
104
123
  }
105
124
 
106
- if (!cached) console.log(` Optimizing ${basename(absoluteSrc)}...`);
125
+ if (!cached) {
126
+ console.log(` Optimizing ${basename(absoluteSrc)}...`);
127
+ }
107
128
  const manifest = await processImage(absoluteSrc, getImageCacheDir(projectRoot), config);
108
129
  setCached(cache, key, src, manifest);
109
130
  return manifest;
@@ -116,11 +137,21 @@ async function resolveManifest(
116
137
  * @returns {boolean}
117
138
  */
118
139
  function shouldSkip(src: string) {
119
- if (typeof src !== "string") return true;
120
- if (!src) return true;
121
- if (src.includes("${")) return true;
122
- if (EXTERNAL_PREFIXES.some((p) => src.startsWith(p))) return true;
123
- 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
+ }
124
155
  return false;
125
156
  }
126
157
 
@@ -136,13 +167,23 @@ function shouldSkip(src: string) {
136
167
  * @returns {boolean}
137
168
  */
138
169
  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;
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
+ }
143
182
  try {
144
183
  const url = new URL(src);
145
- if (SKIP_EXTENSIONS.has(extname(url.pathname).toLowerCase())) return false;
184
+ if (SKIP_EXTENSIONS.has(extname(url.pathname).toLowerCase())) {
185
+ return false;
186
+ }
146
187
  return config.remoteDomains.includes(url.hostname);
147
188
  } catch {
148
189
  return false;
@@ -186,9 +227,11 @@ export async function transformImageNodes(
186
227
  cache: CacheManifest | null,
187
228
  metaCache?: ImageMetaCache,
188
229
  ) {
189
- const imageRefs: Map<string, ImageManifest> = new Map();
230
+ const imageRefs = new Map<string, ImageManifest>();
190
231
 
191
- if (!config.optimize) return { imageRefs };
232
+ if (!config.optimize) {
233
+ return { imageRefs };
234
+ }
192
235
 
193
236
  const meta = metaCache ?? new Map();
194
237
  await walkAndTransform(doc, config, projectRoot, cache, meta, imageRefs);
@@ -212,7 +255,9 @@ async function walkAndTransform(
212
255
  metaCache: ImageMetaCache,
213
256
  imageRefs: Map<string, ImageManifest>,
214
257
  ) {
215
- if (!node || typeof node !== "object") return;
258
+ if (!node || typeof node !== "object") {
259
+ return;
260
+ }
216
261
 
217
262
  if (node.tagName === "img") {
218
263
  await transformImgNode(node, config, projectRoot, cache, metaCache, imageRefs);
@@ -231,7 +276,9 @@ async function walkAndTransform(
231
276
 
232
277
  if (Array.isArray(node.children)) {
233
278
  for (const child of node.children) {
234
- if (typeof child === "string") continue;
279
+ if (typeof child === "string") {
280
+ continue;
281
+ }
235
282
  await walkAndTransform(child, config, projectRoot, cache, metaCache, imageRefs);
236
283
  }
237
284
  }
@@ -253,50 +300,54 @@ async function transformImgNode(
253
300
  metaCache: ImageMetaCache,
254
301
  imageRefs: Map<string, ImageManifest>,
255
302
  ) {
256
- if (!node.attributes) node.attributes = {};
303
+ if (!node.attributes) {
304
+ node.attributes = {};
305
+ }
257
306
 
258
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
+ }
259
312
  const remote = isAllowedRemote(src, config);
260
- if (!remote && shouldSkip(src)) return;
261
- if (node.attributes["data-no-optimize"] !== undefined) return;
262
-
263
- const absoluteSrc = remote ? null : resolveImagePath(src, projectRoot);
264
- if (absoluteSrc && !existsSync(absoluteSrc)) return;
313
+ if (!remote && shouldSkip(src)) {
314
+ return;
315
+ }
316
+ if (node.attributes["data-no-optimize"] !== undefined) {
317
+ return;
318
+ }
265
319
 
266
320
  let srcset;
267
- let original: { width: number; height: number } | undefined;
268
321
 
269
322
  if (remote) {
270
323
  // Original dimensions are unknown without fetching — emit every configured width and let
271
- // fit=scale-down avoid upscaling past the source size.
324
+ // Fit=scale-down avoid upscaling past the source size.
272
325
  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
326
  } else {
278
- let manifest = imageRefs.get(absoluteSrc as string);
279
-
280
- if (!manifest) {
281
- manifest = await resolveManifest(absoluteSrc as string, src, config, projectRoot, cache!);
282
- imageRefs.set(absoluteSrc as string, manifest);
327
+ const absoluteSrc = resolveImagePath(src, projectRoot);
328
+ if (!existsSync(absoluteSrc)) {
329
+ return;
283
330
  }
284
331
 
285
- const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0];
286
- srcset = buildSrcset(manifest.variants, preferredFormat);
287
- original = manifest.original;
332
+ if (config.service === "cloudflare") {
333
+ const meta = await resolveCfMeta(absoluteSrc, metaCache);
334
+ srcset = buildCloudflareSrcset(src, config, meta.width ?? Infinity, meta.hash);
335
+ } else {
336
+ let manifest = imageRefs.get(absoluteSrc);
337
+
338
+ if (!manifest) {
339
+ manifest = await resolveManifest(absoluteSrc, src, config, projectRoot, cache!);
340
+ imageRefs.set(absoluteSrc, manifest);
341
+ }
342
+
343
+ const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0];
344
+ srcset = buildSrcset(manifest.variants, preferredFormat);
345
+ }
288
346
  }
289
347
 
290
348
  if (srcset) {
291
349
  node.attributes.srcset = srcset;
292
- node.attributes.sizes = node.attributes.sizes ?? config.sizes;
293
- }
294
-
295
- if (!node.attributes.width && original?.width) {
296
- node.attributes.width = String(original.width);
297
- }
298
- if (!node.attributes.height && original?.height) {
299
- node.attributes.height = String(original.height);
350
+ node.attributes.sizes ??= config.sizes;
300
351
  }
301
352
 
302
353
  if (config.lazyLoad && node.attributes.loading !== "eager") {
@@ -333,32 +384,40 @@ async function transformInnerHtmlImages(
333
384
  const replacements = [];
334
385
 
335
386
  for (const m of html.matchAll(IMG_TAG_RE)) {
336
- const tag = m[0];
337
- const attrs = m[1];
387
+ const [tag] = m;
388
+ const [, attrs] = m;
338
389
 
339
- if (SRCSET_ATTR_RE.test(attrs)) continue;
340
- if (DATA_NO_OPT_RE.test(attrs)) continue;
390
+ if (SRCSET_ATTR_RE.test(attrs)) {
391
+ continue;
392
+ }
393
+ if (DATA_NO_OPT_RE.test(attrs)) {
394
+ continue;
395
+ }
341
396
 
342
397
  const srcMatch = attrs.match(SRC_ATTR_RE);
343
- if (!srcMatch) continue;
398
+ if (!srcMatch) {
399
+ continue;
400
+ }
344
401
 
345
402
  // Attribute values in pre-rendered innerHTML are entity-escaped — decode for processing
346
403
  const src = srcMatch[1].replaceAll("&amp;", "&");
347
404
  const remote = isAllowedRemote(src, config);
348
- if (!remote && shouldSkip(src)) continue;
405
+ if (!remote && shouldSkip(src)) {
406
+ continue;
407
+ }
349
408
 
350
409
  const absoluteSrc = remote ? null : resolveImagePath(src, projectRoot);
351
- if (absoluteSrc && !existsSync(absoluteSrc)) continue;
410
+ if (absoluteSrc && !existsSync(absoluteSrc)) {
411
+ continue;
412
+ }
352
413
 
353
414
  let srcset;
354
- let original: { width: number; height: number } | undefined;
355
415
 
356
416
  if (remote) {
357
417
  srcset = buildCloudflareSrcset(src, config, Infinity, null);
358
418
  } else if (config.service === "cloudflare") {
359
419
  const meta = await resolveCfMeta(absoluteSrc as string, metaCache);
360
- srcset = buildCloudflareSrcset(src, config, meta.width, meta.hash);
361
- original = meta;
420
+ srcset = buildCloudflareSrcset(src, config, meta.width ?? Infinity, meta.hash);
362
421
  } else {
363
422
  let manifest = imageRefs.get(absoluteSrc as string);
364
423
  if (!manifest) {
@@ -368,20 +427,19 @@ async function transformInnerHtmlImages(
368
427
 
369
428
  const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0];
370
429
  srcset = buildSrcset(manifest.variants, preferredFormat);
371
- original = manifest.original;
372
430
  }
373
- if (!srcset) continue;
431
+ if (!srcset) {
432
+ continue;
433
+ }
374
434
 
375
435
  let extra = ` srcset="${srcset}" sizes="${config.sizes}"`;
376
- if (!/\bwidth=/.test(attrs) && original?.width) {
377
- extra += ` width="${original.width}"`;
378
- }
379
- if (!/\bheight=/.test(attrs) && original?.height) {
380
- extra += ` height="${original.height}"`;
381
- }
382
436
  if (config.lazyLoad && !/\bloading="eager"/.test(attrs)) {
383
- if (!/\bloading=/.test(attrs)) extra += ` loading="lazy"`;
384
- if (!/\bdecoding=/.test(attrs)) extra += ` decoding="async"`;
437
+ if (!/\bloading=/.test(attrs)) {
438
+ extra += ` loading="lazy"`;
439
+ }
440
+ if (!/\bdecoding=/.test(attrs)) {
441
+ extra += ` decoding="async"`;
442
+ }
385
443
  }
386
444
 
387
445
  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
  }