@jxsuite/compiler 1.4.0 → 2.0.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.
@@ -4,6 +4,11 @@
4
4
  * Walks a Jx document tree, finds <img> nodes with static src paths, and injects srcset, sizes,
5
5
  * width, height, loading, and decoding attributes. Collects image references so the build
6
6
  * orchestrator knows which files to process.
7
+ *
8
+ * Two passes, deliberately separable. The **loading** pass (`images.lazyLoad`) runs on every image
9
+ * in every project, because deciding not to optimize an image says nothing about when to fetch it.
10
+ * The **variant** pass (`images.optimize`) is the expensive half and is the one the master switch
11
+ * turns off.
7
12
  */
8
13
 
9
14
  import { existsSync } from "node:fs";
@@ -16,6 +21,7 @@ import {
16
21
  processImage,
17
22
  } from "./image-optimizer.ts";
18
23
  import { getCached, getImageCacheDir, setCached } from "./image-cache.ts";
24
+ import { imgLoadingAttrs } from "./img-loading.ts";
19
25
  import { resolveAssetUrl } from "@jxsuite/schema/asset-paths";
20
26
 
21
27
  import type { AssetMount } from "@jxsuite/schema/asset-paths";
@@ -239,14 +245,8 @@ export async function transformImageNodes(
239
245
  mounts: readonly AssetMount[] = [],
240
246
  ) {
241
247
  const imageRefs = new Map<string, ImageManifest>();
242
-
243
- if (!config.optimize) {
244
- return { imageRefs };
245
- }
246
-
247
248
  const meta = metaCache ?? new Map();
248
249
  await walkAndTransform(doc, config, projectRoot, cache, meta, imageRefs, mounts);
249
-
250
250
  return { imageRefs };
251
251
  }
252
252
 
@@ -274,6 +274,11 @@ async function walkAndTransform(
274
274
 
275
275
  if (node.tagName === "img") {
276
276
  await transformImgNode(node, config, projectRoot, cache, metaCache, imageRefs, mounts);
277
+ // Wrapping rewrote this node into a `<picture>` whose children this pass just authored.
278
+ // Descending into them would find the `<img>` and wrap it again, forever.
279
+ if (node.tagName !== "img") {
280
+ return;
281
+ }
277
282
  }
278
283
 
279
284
  if (typeof node.innerHTML === "string" && node.innerHTML.includes("<img")) {
@@ -298,94 +303,226 @@ async function walkAndTransform(
298
303
  }
299
304
  }
300
305
 
306
+ /** A `<source>` in a generated `<picture>`: one image format, one candidate list. */
307
+ interface PictureSource {
308
+ type: string;
309
+ srcset: string;
310
+ }
311
+
301
312
  /**
302
- * @param {JxMutableNode | JxDocument} node
313
+ * Formats in the order a browser should be offered them — best compression first. A `<picture>`
314
+ * takes the FIRST source it can decode, so this order is the whole negotiation.
315
+ */
316
+ const PICTURE_FORMAT_ORDER: readonly string[] = ["avif", "webp", "jpeg", "png"];
317
+ const MEDIA_TYPES: Record<string, string> = {
318
+ avif: "image/avif",
319
+ jpeg: "image/jpeg",
320
+ png: "image/png",
321
+ webp: "image/webp",
322
+ };
323
+
324
+ interface ResolvedVariants {
325
+ /** Candidate list for the `<img>` itself, in the preferred format. */
326
+ srcset: string;
327
+ dims: { width: number; height: number } | null;
328
+ /** One entry per additional format, when the image is to be wrapped in a `<picture>`. */
329
+ sources: PictureSource[];
330
+ }
331
+
332
+ /**
333
+ * Generate (or look up) this image's variants and describe what the markup should say.
334
+ *
335
+ * Returns null whenever the image is not ours to touch — a bound `src`, an SVG, a remote URL off
336
+ * the allowlist, a missing file, an explicit `data-no-optimize`. Both callers treat null the same
337
+ * way: leave the markup alone apart from the loading attributes.
338
+ *
339
+ * @param {string} src
340
+ * @param {boolean} optedOut - `data-no-optimize` is present
303
341
  * @param {ImageConfig} config
304
342
  * @param {string} projectRoot
305
343
  * @param {CacheManifest | null} cache
306
344
  * @param {ImageMetaCache} metaCache
307
345
  * @param {Map<string, ImageManifest>} imageRefs
308
346
  * @param {readonly AssetMount[]} mounts
347
+ * @returns {Promise<ResolvedVariants | null>}
309
348
  */
310
- async function transformImgNode(
311
- node: JxMutableNode | JxDocument,
349
+ async function resolveVariants(
350
+ src: string,
351
+ optedOut: boolean,
312
352
  config: ImageConfig,
313
353
  projectRoot: string,
314
354
  cache: CacheManifest | null,
315
355
  metaCache: ImageMetaCache,
316
356
  imageRefs: Map<string, ImageManifest>,
317
357
  mounts: readonly AssetMount[],
318
- ) {
319
- if (!node.attributes) {
320
- node.attributes = {};
321
- }
322
-
323
- const src = node.attributes.src ?? node.src;
358
+ ): Promise<ResolvedVariants | null> {
324
359
  // Bound ($ref) or missing srcs cannot be optimized at build time.
325
- if (typeof src !== "string") {
326
- return;
360
+ if (typeof src !== "string" || optedOut) {
361
+ return null;
327
362
  }
328
363
  const remote = isAllowedRemote(src, config);
329
364
  if (!remote && shouldSkip(src)) {
330
- return;
331
- }
332
- if (node.attributes["data-no-optimize"] !== undefined) {
333
- return;
365
+ return null;
334
366
  }
335
367
 
336
- let srcset;
337
- let dims: { width: number; height: number } | null = null;
338
-
339
368
  if (remote) {
340
369
  // Original dimensions are unknown without fetching — emit every configured width and let
341
370
  // Fit=scale-down avoid upscaling past the source size.
342
- srcset = buildCloudflareSrcset(src, config, Infinity, null);
343
- } else {
344
- const absoluteSrc = resolveImagePath(src, projectRoot, mounts);
345
- if (!existsSync(absoluteSrc)) {
346
- return;
347
- }
371
+ return { dims: null, sources: [], srcset: buildCloudflareSrcset(src, config, Infinity, null) };
372
+ }
348
373
 
349
- if (config.service === "cloudflare") {
350
- const meta = await resolveCfMeta(absoluteSrc, metaCache);
351
- srcset = buildCloudflareSrcset(src, config, meta.width ?? Infinity, meta.hash);
352
- dims = meta.width && meta.height ? { height: meta.height, width: meta.width } : null;
353
- } else {
354
- let manifest = imageRefs.get(absoluteSrc);
374
+ const absoluteSrc = resolveImagePath(src, projectRoot, mounts);
375
+ if (!existsSync(absoluteSrc)) {
376
+ return null;
377
+ }
355
378
 
356
- if (!manifest) {
357
- manifest = await resolveManifest(absoluteSrc, src, config, projectRoot, cache!);
358
- imageRefs.set(absoluteSrc, manifest);
359
- }
379
+ if (config.service === "cloudflare") {
380
+ // Cloudflare negotiates the format itself (`format=auto`), so there is nothing for a
381
+ // `<picture>` to choose between.
382
+ const meta = await resolveCfMeta(absoluteSrc, metaCache);
383
+ const srcset = buildCloudflareSrcset(src, config, meta.width ?? Infinity, meta.hash);
384
+ const dims = meta.width && meta.height ? { height: meta.height, width: meta.width } : null;
385
+ return { dims, sources: [], srcset };
386
+ }
360
387
 
361
- const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0]!;
362
- srcset = buildSrcset(manifest.variants, preferredFormat);
363
- dims =
364
- manifest.original?.width && manifest.original.height
365
- ? { height: manifest.original.height, width: manifest.original.width }
366
- : null;
367
- }
388
+ let manifest = imageRefs.get(absoluteSrc);
389
+ if (!manifest) {
390
+ manifest = await resolveManifest(absoluteSrc, src, config, projectRoot, cache!);
391
+ imageRefs.set(absoluteSrc, manifest);
368
392
  }
369
393
 
370
- if (srcset) {
371
- node.attributes.srcset = srcset;
372
- node.attributes.sizes ??= config.sizes;
394
+ /*
395
+ * The project's own formats, best-compression first. A format the project did not ask for is not
396
+ * offered even if a stale manifest happens to hold variants of it, and one this order does not
397
+ * know keeps the position the project gave it.
398
+ */
399
+ const ordered = [
400
+ ...PICTURE_FORMAT_ORDER.filter((format) => config.formats.includes(format)),
401
+ ...config.formats.filter((format) => !PICTURE_FORMAT_ORDER.includes(format)),
402
+ ];
403
+ const available = ordered
404
+ .map((format) => ({
405
+ srcset: buildSrcset(manifest.variants, format),
406
+ type: MEDIA_TYPES[format] ?? `image/${format}`,
407
+ }))
408
+ .filter((source) => source.srcset !== "");
409
+
410
+ const dims =
411
+ manifest.original?.width && manifest.original.height
412
+ ? { height: manifest.original.height, width: manifest.original.width }
413
+ : null;
414
+ /*
415
+ * One format needs no negotiation, and a srcset in the single format the project asked for is
416
+ * both smaller markup and the historical behaviour. Two or more do: `srcset` alone carries no
417
+ * format information, so a browser that cannot decode AVIF would still pick an AVIF candidate.
418
+ */
419
+ const wrap = config.picture !== false && available.length > 1;
420
+ return { dims, sources: wrap ? available : [], srcset: available[0]?.srcset ?? "" };
421
+ }
422
+
423
+ /**
424
+ * @param {JxMutableNode | JxDocument} node
425
+ * @param {ImageConfig} config
426
+ * @param {string} projectRoot
427
+ * @param {CacheManifest | null} cache
428
+ * @param {ImageMetaCache} metaCache
429
+ * @param {Map<string, ImageManifest>} imageRefs
430
+ * @param {readonly AssetMount[]} mounts
431
+ */
432
+ async function transformImgNode(
433
+ node: JxMutableNode | JxDocument,
434
+ config: ImageConfig,
435
+ projectRoot: string,
436
+ cache: CacheManifest | null,
437
+ metaCache: ImageMetaCache,
438
+ imageRefs: Map<string, ImageManifest>,
439
+ mounts: readonly AssetMount[],
440
+ ) {
441
+ if (!node.attributes) {
442
+ node.attributes = {};
443
+ }
444
+ const attrs = node.attributes;
445
+ /*
446
+ * A node-level `src` is normalized into `attributes` first. The static emitter only renders a
447
+ * fixed set of node-level DOM properties and `src` is not among them, so an image written that
448
+ * way used to acquire a `srcset` and lose its `src` — the fallback the srcset exists to have.
449
+ */
450
+ if (attrs.src === undefined && typeof node.src === "string") {
451
+ attrs.src = node.src;
452
+ delete node.src;
373
453
  }
374
454
 
375
- // Intrinsic dimensions prevent layout shift; author-supplied values win.
376
- if (dims && node.attributes.width === undefined && node.attributes.height === undefined) {
377
- node.attributes.width = String(dims.width);
378
- node.attributes.height = String(dims.height);
455
+ const variants = config.optimize
456
+ ? await resolveVariants(
457
+ attrs.src as string,
458
+ attrs["data-no-optimize"] !== undefined,
459
+ config,
460
+ projectRoot,
461
+ cache,
462
+ metaCache,
463
+ imageRefs,
464
+ mounts,
465
+ )
466
+ : null;
467
+
468
+ if (variants) {
469
+ if (variants.srcset !== "") {
470
+ attrs.srcset = variants.srcset;
471
+ attrs.sizes ??= config.sizes;
472
+ }
473
+ // Intrinsic dimensions prevent layout shift; author-supplied values win.
474
+ if (variants.dims && attrs.width === undefined && attrs.height === undefined) {
475
+ attrs.width = String(variants.dims.width);
476
+ attrs.height = String(variants.dims.height);
477
+ }
379
478
  }
380
479
 
381
- if (config.lazyLoad && node.attributes.loading !== "eager") {
382
- node.attributes.loading = "lazy";
383
- node.attributes.decoding = "async";
480
+ Object.assign(attrs, imgLoadingAttrs(attrs, config.lazyLoad));
481
+
482
+ if (variants && variants.sources.length > 0) {
483
+ wrapInPicture(node, variants.sources, config.sizes);
384
484
  }
385
485
  }
386
486
 
487
+ /**
488
+ * Turn an `<img>` node into a `<picture>` wrapping it, one `<source>` per format.
489
+ *
490
+ * The `<img>` keeps everything that describes the image — `src`, `alt`, dimensions, styles, the
491
+ * loading attributes — and loses `srcset`/`sizes`, which now live on the sources. That is what
492
+ * makes it a real fallback: a browser that matches no `<source>` gets the original file, not a
493
+ * candidate list in a format it just declined.
494
+ *
495
+ * @param {JxMutableNode | JxDocument} node - The `<img>` node, mutated into a `<picture>`
496
+ * @param {PictureSource[]} sources
497
+ * @param {string} sizes
498
+ */
499
+ function wrapInPicture(
500
+ node: JxMutableNode | JxDocument,
501
+ sources: readonly PictureSource[],
502
+ sizes: string,
503
+ ) {
504
+ const imgAttrs: Record<string, unknown> = { ...node.attributes };
505
+ const inheritedSizes = (imgAttrs.sizes as string | undefined) ?? sizes;
506
+ delete imgAttrs.srcset;
507
+ delete imgAttrs.sizes;
508
+ const img: Record<string, unknown> = { attributes: imgAttrs, tagName: "img" };
509
+ if (node.style !== undefined) {
510
+ img.style = node.style;
511
+ delete node.style;
512
+ }
513
+
514
+ node.tagName = "picture";
515
+ node.attributes = {};
516
+ node.children = [
517
+ ...sources.map((source) => ({
518
+ attributes: { sizes: inheritedSizes, srcset: source.srcset, type: source.type },
519
+ tagName: "source",
520
+ })),
521
+ img,
522
+ ] as (string | JxMutableNode)[];
523
+ }
524
+
387
525
  const IMG_TAG_RE = /<img\b([^>]*)>/gi;
388
- const SRC_ATTR_RE = /\bsrc="([^"]*)"/;
389
526
  const SRCSET_ATTR_RE = /\bsrcset="/;
390
527
  const DATA_NO_OPT_RE = /\bdata-no-optimize\b/;
391
528
 
@@ -410,78 +547,57 @@ async function transformInnerHtmlImages(
410
547
  imageRefs: Map<string, ImageManifest>,
411
548
  mounts: readonly AssetMount[],
412
549
  ) {
413
- /** @type {{ match: string; replacement: string }[]} */
414
- const replacements = [];
550
+ const replacements: { match: string; replacement: string }[] = [];
415
551
 
416
552
  for (const m of html.matchAll(IMG_TAG_RE)) {
417
553
  const tag = m[0]!;
418
554
  const attrs = m[1]!;
419
555
 
420
- if (SRCSET_ATTR_RE.test(attrs)) {
421
- continue;
422
- }
423
- if (DATA_NO_OPT_RE.test(attrs)) {
424
- continue;
425
- }
426
-
427
- const srcMatch = attrs.match(SRC_ATTR_RE);
428
- if (!srcMatch) {
429
- continue;
430
- }
431
-
432
556
  // Attribute values in pre-rendered innerHTML are entity-escaped — decode for processing
433
- const src = srcMatch[1]!.replaceAll("&amp;", "&");
434
- const remote = isAllowedRemote(src, config);
435
- if (!remote && shouldSkip(src)) {
436
- continue;
557
+ const src = (attrValue(attrs, "src") ?? "").replaceAll("&amp;", "&");
558
+ const alreadyResponsive = SRCSET_ATTR_RE.test(attrs);
559
+ const variants =
560
+ config.optimize && src !== "" && !alreadyResponsive
561
+ ? await resolveVariants(
562
+ src,
563
+ DATA_NO_OPT_RE.test(attrs),
564
+ config,
565
+ projectRoot,
566
+ cache,
567
+ metaCache,
568
+ imageRefs,
569
+ mounts,
570
+ )
571
+ : null;
572
+
573
+ let extra = "";
574
+ if (variants && variants.srcset !== "") {
575
+ extra += ` srcset="${variants.srcset}" sizes="${config.sizes}"`;
437
576
  }
438
-
439
- const absoluteSrc = remote ? null : resolveImagePath(src, projectRoot, mounts);
440
- if (absoluteSrc && !existsSync(absoluteSrc)) {
441
- continue;
577
+ if (variants?.dims && !/\bwidth=/.test(attrs) && !/\bheight=/.test(attrs)) {
578
+ extra += ` width="${variants.dims.width}" height="${variants.dims.height}"`;
442
579
  }
443
-
444
- let srcset;
445
- let dims: { width: number; height: number } | null = null;
446
-
447
- if (remote) {
448
- srcset = buildCloudflareSrcset(src, config, Infinity, null);
449
- } else if (config.service === "cloudflare") {
450
- const meta = await resolveCfMeta(absoluteSrc as string, metaCache);
451
- srcset = buildCloudflareSrcset(src, config, meta.width ?? Infinity, meta.hash);
452
- dims = meta.width && meta.height ? { height: meta.height, width: meta.width } : null;
453
- } else {
454
- let manifest = imageRefs.get(absoluteSrc as string);
455
- if (!manifest) {
456
- manifest = await resolveManifest(absoluteSrc as string, src, config, projectRoot, cache!);
457
- imageRefs.set(absoluteSrc as string, manifest);
458
- }
459
-
460
- const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0]!;
461
- srcset = buildSrcset(manifest.variants, preferredFormat);
462
- dims =
463
- manifest.original?.width && manifest.original.height
464
- ? { height: manifest.original.height, width: manifest.original.width }
465
- : null;
466
- }
467
- if (!srcset) {
468
- continue;
580
+ const loading = imgLoadingAttrs(
581
+ {
582
+ decoding: attrValue(attrs, "decoding"),
583
+ fetchpriority: attrValue(attrs, "fetchpriority"),
584
+ loading: attrValue(attrs, "loading"),
585
+ },
586
+ config.lazyLoad,
587
+ );
588
+ for (const [key, value] of Object.entries(loading)) {
589
+ extra += ` ${key}="${value}"`;
469
590
  }
470
591
 
471
- let extra = ` srcset="${srcset}" sizes="${config.sizes}"`;
472
- if (dims && !/\bwidth=/.test(attrs) && !/\bheight=/.test(attrs)) {
473
- extra += ` width="${dims.width}" height="${dims.height}"`;
474
- }
475
- if (config.lazyLoad && !/\bloading="eager"/.test(attrs)) {
476
- if (!/\bloading=/.test(attrs)) {
477
- extra += ` loading="lazy"`;
478
- }
479
- if (!/\bdecoding=/.test(attrs)) {
480
- extra += ` decoding="async"`;
481
- }
592
+ if (extra === "") {
593
+ continue;
482
594
  }
483
595
 
484
- replacements.push({ match: tag, replacement: `<img${attrs}${extra}>` });
596
+ const img =
597
+ variants && variants.sources.length > 0
598
+ ? renderPictureTag(attrs, variants.sources, config.sizes, extra)
599
+ : `<img${attrs}${extra}>`;
600
+ replacements.push({ match: tag, replacement: img });
485
601
  }
486
602
 
487
603
  let result = html;
@@ -490,3 +606,38 @@ async function transformInnerHtmlImages(
490
606
  }
491
607
  return result;
492
608
  }
609
+
610
+ /**
611
+ * The string-surgery twin of {@link wrapInPicture}: the same markup, built from a raw attribute
612
+ * string rather than a node. `srcset`/`sizes` stay off the `<img>` so it remains a real fallback.
613
+ *
614
+ * @param {string} attrs - The original tag's attribute string
615
+ * @param {readonly PictureSource[]} sources
616
+ * @param {string} sizes
617
+ * @param {string} extra - Attributes this pass decided to add
618
+ * @returns {string}
619
+ */
620
+ function renderPictureTag(
621
+ attrs: string,
622
+ sources: readonly PictureSource[],
623
+ sizes: string,
624
+ extra: string,
625
+ ) {
626
+ const inheritedSizes = attrValue(attrs, "sizes") ?? sizes;
627
+ const imgExtra = extra.replace(/ srcset="[^"]*"/, "").replace(/ sizes="[^"]*"/, "");
628
+ const sourceTags = sources
629
+ .map((s) => `<source type="${s.type}" srcset="${s.srcset}" sizes="${inheritedSizes}">`)
630
+ .join("");
631
+ return `<picture>${sourceTags}<img${attrs}${imgExtra}></picture>`;
632
+ }
633
+
634
+ /**
635
+ * Read one double-quoted attribute out of a raw tag attribute string.
636
+ *
637
+ * @param {string} attrs
638
+ * @param {string} name
639
+ * @returns {string | undefined}
640
+ */
641
+ function attrValue(attrs: string, name: string): string | undefined {
642
+ return new RegExp(`\\b${name}="([^"]*)"`, "i").exec(attrs)?.[1];
643
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * One decision, three callers: which loading attributes an `<img>` ends up with.
3
+ *
4
+ * It used to be three decisions. The static emitter added `loading="lazy"` to every `<img>` that
5
+ * did not already carry one — unconditionally, outside `images.optimize` and outside the image
6
+ * pipeline, so it reached the LCP image too, which is the one image on a page that must never be
7
+ * lazy. The pipeline then made the same decision again for the nodes it rewrote, and a third time
8
+ * for `<img>` tags sitting inside pre-rendered `innerHTML`. Only two of the three consulted
9
+ * `images.lazyLoad`, and none of them knew what `fetchpriority` meant.
10
+ *
11
+ * The compiler cannot know which image is the largest contentful paint — that depends on the
12
+ * viewport, not the document — so it does not guess. It honours the author's answer instead.
13
+ *
14
+ * @docs framework/site/images
15
+ */
16
+
17
+ /** WHATWG HTML: `fetchpriority` values. `high` is the one that means "this is the LCP image". */
18
+ export const HIGH_PRIORITY = "high";
19
+
20
+ /** Attributes an `<img>` already carries, as far as this decision is concerned. */
21
+ export interface ImgLoadingState {
22
+ loading?: unknown;
23
+ decoding?: unknown;
24
+ fetchpriority?: unknown;
25
+ }
26
+
27
+ /** Attributes to add. Empty when the author has already decided, or when lazy loading is off. */
28
+ export interface ImgLoadingAttrs {
29
+ loading?: "lazy";
30
+ decoding?: "async";
31
+ }
32
+
33
+ const present = (value: unknown) => value !== undefined && value !== null && value !== "";
34
+
35
+ /**
36
+ * Decide the loading attributes for one `<img>`.
37
+ *
38
+ * Three ways to end up with nothing added: `lazyLoad` is off, the author set `loading` themselves,
39
+ * or the author marked the image `fetchpriority="high"`. That last one is not a stylistic
40
+ * preference — a high-priority lazy image is a contradiction the browser resolves by ignoring the
41
+ * priority, so honouring both would silently discard the author's only lever over LCP.
42
+ *
43
+ * @param {ImgLoadingState} attrs - Attributes the image already carries
44
+ * @param {boolean} lazyLoad - `images.lazyLoad`
45
+ * @returns {ImgLoadingAttrs}
46
+ */
47
+ export function imgLoadingAttrs(attrs: ImgLoadingState, lazyLoad: boolean): ImgLoadingAttrs {
48
+ if (!lazyLoad || present(attrs.loading)) {
49
+ return {};
50
+ }
51
+ if (String(attrs.fetchpriority ?? "").toLowerCase() === HIGH_PRIORITY) {
52
+ return {};
53
+ }
54
+ return present(attrs.decoding) ? { loading: "lazy" } : { decoding: "async", loading: "lazy" };
55
+ }