@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.
- package/dist/cli.js +564 -230
- package/dist/compiler.js +334 -133
- package/package.json +11 -9
- package/src/compiler.ts +1 -1
- package/src/shadow.ts +65 -0
- package/src/shared.ts +461 -51
- package/src/site/bundler.ts +149 -18
- package/src/site/client-runtime.ts +374 -0
- package/src/site/context-injection.ts +45 -2
- package/src/site/csp.ts +224 -0
- package/src/site/head-merger.ts +57 -13
- package/src/site/headers-emitter.ts +232 -0
- package/src/site/i18n.ts +365 -0
- package/src/site/image-optimizer.ts +7 -0
- package/src/site/image-transform.ts +269 -118
- package/src/site/img-loading.ts +55 -0
- package/src/site/link-relations.ts +251 -0
- package/src/site/locale-negotiation.ts +258 -0
- package/src/site/pages-discovery.ts +149 -0
- package/src/site/preview-server.ts +9 -1
- package/src/site/schema-command.ts +138 -87
- package/src/site/service-worker.ts +294 -0
- package/src/site/site-build.ts +956 -109
- package/src/site/site-loader.ts +1 -0
- package/src/site/validate-command.ts +134 -38
- package/src/site/well-known.ts +221 -0
- package/src/targets/compile-client.ts +144 -81
- package/src/targets/compile-element.ts +620 -58
- package/src/targets/compile-server.ts +19 -2
- package/src/targets/compile-static.ts +12 -3
- package/src/types.ts +2 -0
|
@@ -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
|
-
*
|
|
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
|
|
311
|
-
|
|
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
|
|
343
|
-
}
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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 =
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
557
|
+
const src = (attrValue(attrs, "src") ?? "").replaceAll("&", "&");
|
|
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
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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
|
-
|
|
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
|
+
}
|