@jxsuite/compiler 5.1.1 → 5.1.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.
- package/dist/cli.js +224 -355
- package/dist/compiler.js +126 -257
- package/package.json +8 -8
- package/src/cli.ts +12 -14
- package/src/compiler.ts +45 -23
- package/src/shadow.ts +9 -8
- package/src/shared.ts +350 -296
- package/src/site/asset-mounts.ts +22 -12
- package/src/site/bundler.ts +38 -12
- package/src/site/client-runtime.ts +31 -17
- package/src/site/csp.ts +20 -11
- package/src/site/db-push.ts +38 -24
- package/src/site/dev-command.ts +3 -3
- package/src/site/format-host.ts +56 -45
- package/src/site/headers-emitter.ts +7 -4
- package/src/site/i18n.ts +20 -19
- package/src/site/image-cache.ts +16 -3
- package/src/site/image-optimizer.ts +19 -5
- package/src/site/image-transform.ts +56 -60
- package/src/site/img-loading.ts +11 -5
- package/src/site/layout-resolver.ts +7 -6
- package/src/site/link-relations.ts +10 -6
- package/src/site/locale-negotiation.ts +1 -1
- package/src/site/pages-discovery.ts +65 -47
- package/src/site/preview-server.ts +22 -19
- package/src/site/project-sections.ts +12 -3
- package/src/site/prototype-resolver.ts +94 -42
- package/src/site/schema-command.ts +26 -13
- package/src/site/service-worker.ts +2 -2
- package/src/site/site-build.ts +379 -262
- package/src/site/site-loader.ts +6 -3
- package/src/site/static-host.ts +3 -3
- package/src/site/validate-command.ts +64 -34
- package/src/site/well-known.ts +57 -12
- package/src/targets/compile-class.ts +17 -5
- package/src/targets/compile-client.ts +141 -106
- package/src/targets/compile-element.ts +157 -140
- package/src/targets/compile-server.ts +11 -5
- package/src/targets/compile-static.ts +57 -63
- package/src/types.ts +5 -6
|
@@ -23,10 +23,12 @@ import {
|
|
|
23
23
|
import { getCached, getImageCacheDir, setCached } from "./image-cache.ts";
|
|
24
24
|
import { imgLoadingAttrs } from "./img-loading.ts";
|
|
25
25
|
import { resolveAssetUrl } from "@jxsuite/schema/asset-paths";
|
|
26
|
+
import { isNodeObject, isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
27
|
+
import { errorMessage } from "@jxsuite/schema/parse";
|
|
26
28
|
|
|
27
29
|
import type { AssetMount } from "@jxsuite/schema/asset-paths";
|
|
28
30
|
import type { ImageConfig, ImageManifest, ImageVariant } from "./image-optimizer.ts";
|
|
29
|
-
import type { JxDocument, JxMutableNode } from "@jxsuite/schema/types";
|
|
31
|
+
import type { JxAttributeValue, JxDocument, JxMutableNode, JxStyle } from "@jxsuite/schema/types";
|
|
30
32
|
import type { CacheManifest } from "./image-cache.ts";
|
|
31
33
|
|
|
32
34
|
const SKIP_EXTENSIONS = new Set([".svg", ".gif"]);
|
|
@@ -61,7 +63,7 @@ async function resolveCfMeta(absoluteSrc: string, metaCache: ImageMetaCache) {
|
|
|
61
63
|
if (!sharpWarned) {
|
|
62
64
|
sharpWarned = true;
|
|
63
65
|
console.warn(
|
|
64
|
-
`Could not read image dimensions (${(error
|
|
66
|
+
`Could not read image dimensions (${errorMessage(error)}) — ` +
|
|
65
67
|
`emitting srcsets without width/height attributes.`,
|
|
66
68
|
);
|
|
67
69
|
}
|
|
@@ -91,7 +93,7 @@ function buildCloudflareSrcset(
|
|
|
91
93
|
originalWidth: number,
|
|
92
94
|
hash: string | null,
|
|
93
95
|
) {
|
|
94
|
-
const quality = config.quality
|
|
96
|
+
const quality = config.quality.webp ?? 80;
|
|
95
97
|
const separator = src.startsWith("/") ? "" : "/";
|
|
96
98
|
const suffix = hash ? `?v=${hash}` : "";
|
|
97
99
|
return config.widths
|
|
@@ -145,9 +147,6 @@ async function resolveManifest(
|
|
|
145
147
|
* @returns {boolean}
|
|
146
148
|
*/
|
|
147
149
|
function shouldSkip(src: string) {
|
|
148
|
-
if (typeof src !== "string") {
|
|
149
|
-
return true;
|
|
150
|
-
}
|
|
151
150
|
if (!src) {
|
|
152
151
|
return true;
|
|
153
152
|
}
|
|
@@ -181,7 +180,7 @@ function isAllowedRemote(src: string, config: ImageConfig) {
|
|
|
181
180
|
if (!config.remoteDomains?.length) {
|
|
182
181
|
return false;
|
|
183
182
|
}
|
|
184
|
-
if (
|
|
183
|
+
if (!src.startsWith("https://")) {
|
|
185
184
|
return false;
|
|
186
185
|
}
|
|
187
186
|
if (src.includes("${")) {
|
|
@@ -269,12 +268,8 @@ async function walkAndTransform(
|
|
|
269
268
|
mounts: readonly AssetMount[],
|
|
270
269
|
containerWidth: number | null = null,
|
|
271
270
|
) {
|
|
272
|
-
if (!node || typeof node !== "object") {
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
271
|
if (node.tagName === "img") {
|
|
277
|
-
await transformImgNode(
|
|
272
|
+
const wrapped = await transformImgNode(
|
|
278
273
|
node,
|
|
279
274
|
config,
|
|
280
275
|
projectRoot,
|
|
@@ -286,12 +281,13 @@ async function walkAndTransform(
|
|
|
286
281
|
);
|
|
287
282
|
// Wrapping rewrote this node into a `<picture>` whose children this pass just authored.
|
|
288
283
|
// Descending into them would find the `<img>` and wrap it again, forever.
|
|
289
|
-
if (
|
|
284
|
+
if (wrapped) {
|
|
290
285
|
return;
|
|
291
286
|
}
|
|
292
287
|
}
|
|
293
288
|
|
|
294
|
-
|
|
289
|
+
// A document is authored JSON, so `innerHTML` is checked rather than trusted to be a string.
|
|
290
|
+
if (isString(node.innerHTML) && node.innerHTML.includes("<img")) {
|
|
295
291
|
node.innerHTML = await transformInnerHtmlImages(
|
|
296
292
|
node.innerHTML,
|
|
297
293
|
config,
|
|
@@ -307,7 +303,8 @@ async function walkAndTransform(
|
|
|
307
303
|
// Every descendant is bounded by the narrowest literal max-width above it.
|
|
308
304
|
const inner = containerWidthOf(node, containerWidth);
|
|
309
305
|
for (const child of node.children) {
|
|
310
|
-
|
|
306
|
+
// Text children carry no images, and a non-object child (an authored `null`) is skipped.
|
|
307
|
+
if (!isNodeObject(child)) {
|
|
311
308
|
continue;
|
|
312
309
|
}
|
|
313
310
|
await walkAndTransform(
|
|
@@ -335,12 +332,12 @@ interface PictureSource {
|
|
|
335
332
|
* takes the FIRST source it can decode, so this order is the whole negotiation.
|
|
336
333
|
*/
|
|
337
334
|
const PICTURE_FORMAT_ORDER: readonly string[] = ["avif", "webp", "jpeg", "png"];
|
|
338
|
-
const MEDIA_TYPES
|
|
339
|
-
avif
|
|
340
|
-
jpeg
|
|
341
|
-
png
|
|
342
|
-
webp
|
|
343
|
-
|
|
335
|
+
const MEDIA_TYPES = new Map<string, string>([
|
|
336
|
+
["avif", "image/avif"],
|
|
337
|
+
["jpeg", "image/jpeg"],
|
|
338
|
+
["png", "image/png"],
|
|
339
|
+
["webp", "image/webp"],
|
|
340
|
+
]);
|
|
344
341
|
|
|
345
342
|
interface ResolvedVariants {
|
|
346
343
|
/** Candidate list for the `<img>` itself, in the preferred format. */
|
|
@@ -359,7 +356,7 @@ interface ResolvedVariants {
|
|
|
359
356
|
* the allowlist, a missing file, an explicit `data-no-optimize`. Both callers treat null the same
|
|
360
357
|
* way: leave the markup alone apart from the loading attributes.
|
|
361
358
|
*
|
|
362
|
-
* @param {
|
|
359
|
+
* @param {JxAttributeValue | undefined} src - The `src` as written, which may be bound or absent
|
|
363
360
|
* @param {boolean} optedOut - `data-no-optimize` is present
|
|
364
361
|
* @param {ImageConfig} config
|
|
365
362
|
* @param {string} projectRoot
|
|
@@ -370,7 +367,7 @@ interface ResolvedVariants {
|
|
|
370
367
|
* @returns {Promise<ResolvedVariants | null>}
|
|
371
368
|
*/
|
|
372
369
|
async function resolveVariants(
|
|
373
|
-
src:
|
|
370
|
+
src: JxAttributeValue | undefined,
|
|
374
371
|
optedOut: boolean,
|
|
375
372
|
config: ImageConfig,
|
|
376
373
|
projectRoot: string,
|
|
@@ -380,7 +377,7 @@ async function resolveVariants(
|
|
|
380
377
|
mounts: readonly AssetMount[],
|
|
381
378
|
): Promise<ResolvedVariants | null> {
|
|
382
379
|
// Bound ($ref) or missing srcs cannot be optimized at build time.
|
|
383
|
-
if (
|
|
380
|
+
if (!isString(src) || optedOut) {
|
|
384
381
|
return null;
|
|
385
382
|
}
|
|
386
383
|
const remote = isAllowedRemote(src, config);
|
|
@@ -423,12 +420,10 @@ async function resolveVariants(
|
|
|
423
420
|
...PICTURE_FORMAT_ORDER.filter((format) => config.formats.includes(format)),
|
|
424
421
|
...config.formats.filter((format) => !PICTURE_FORMAT_ORDER.includes(format)),
|
|
425
422
|
];
|
|
426
|
-
const available = ordered
|
|
427
|
-
.
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
}))
|
|
431
|
-
.filter((source) => source.srcset !== "");
|
|
423
|
+
const available = ordered.flatMap((format): PictureSource[] => {
|
|
424
|
+
const srcset = buildSrcset(manifest.variants, format);
|
|
425
|
+
return srcset === "" ? [] : [{ srcset, type: MEDIA_TYPES.get(format) ?? `image/${format}` }];
|
|
426
|
+
});
|
|
432
427
|
|
|
433
428
|
const dims =
|
|
434
429
|
manifest.original?.width && manifest.original.height
|
|
@@ -491,8 +486,8 @@ function fallbackVariant(variants: ImageVariant[], formats: readonly string[]):
|
|
|
491
486
|
const CSS_LENGTH = /^(\d+(?:\.\d+)?)(px|rem)$/;
|
|
492
487
|
|
|
493
488
|
/** A CSS length in px, but only when it is a plain `px`/`rem` literal. `null` for anything else. */
|
|
494
|
-
function pxLength(value:
|
|
495
|
-
if (
|
|
489
|
+
function pxLength(value: JxStyle[string]): number | null {
|
|
490
|
+
if (!isString(value)) {
|
|
496
491
|
return null;
|
|
497
492
|
}
|
|
498
493
|
const match = CSS_LENGTH.exec(value.trim());
|
|
@@ -514,8 +509,9 @@ function containerWidthOf(
|
|
|
514
509
|
node: JxMutableNode | JxDocument,
|
|
515
510
|
inherited: number | null,
|
|
516
511
|
): number | null {
|
|
517
|
-
|
|
518
|
-
|
|
512
|
+
// Authored JSON: a `style` that is not an object (a CSS string, say) declares no width here.
|
|
513
|
+
const { style } = node;
|
|
514
|
+
if (!isNonNullObject(style)) {
|
|
519
515
|
return inherited;
|
|
520
516
|
}
|
|
521
517
|
const declared = pxLength(style.maxWidth) ?? pxLength(style.width);
|
|
@@ -552,7 +548,7 @@ async function transformImgNode(
|
|
|
552
548
|
imageRefs: Map<string, ImageManifest>,
|
|
553
549
|
mounts: readonly AssetMount[],
|
|
554
550
|
containerWidth: number | null = null,
|
|
555
|
-
) {
|
|
551
|
+
): Promise<boolean> {
|
|
556
552
|
if (!node.attributes) {
|
|
557
553
|
node.attributes = {};
|
|
558
554
|
}
|
|
@@ -562,14 +558,14 @@ async function transformImgNode(
|
|
|
562
558
|
* fixed set of node-level DOM properties and `src` is not among them, so an image written that
|
|
563
559
|
* way used to acquire a `srcset` and lose its `src` — the fallback the srcset exists to have.
|
|
564
560
|
*/
|
|
565
|
-
if (attrs.src === undefined &&
|
|
561
|
+
if (attrs.src === undefined && isString(node.src)) {
|
|
566
562
|
attrs.src = node.src;
|
|
567
563
|
delete node.src;
|
|
568
564
|
}
|
|
569
565
|
|
|
570
566
|
const variants = config.optimize
|
|
571
567
|
? await resolveVariants(
|
|
572
|
-
attrs.src
|
|
568
|
+
attrs.src,
|
|
573
569
|
attrs["data-no-optimize"] !== undefined,
|
|
574
570
|
config,
|
|
575
571
|
projectRoot,
|
|
@@ -586,15 +582,12 @@ async function transformImgNode(
|
|
|
586
582
|
* guess, so it outranks it — and an image with neither keeps whatever the project chose.
|
|
587
583
|
*/
|
|
588
584
|
const ownWidth = containerWidthOf(node, containerWidth);
|
|
589
|
-
const resolvedSizes =
|
|
590
|
-
(attrs.sizes as string | undefined) ?? sizesForContainer(ownWidth) ?? config.sizes;
|
|
585
|
+
const resolvedSizes = attrs.sizes ?? sizesForContainer(ownWidth) ?? config.sizes;
|
|
591
586
|
|
|
592
587
|
if (variants) {
|
|
593
588
|
if (variants.srcset !== "") {
|
|
594
589
|
attrs.srcset = variants.srcset;
|
|
595
|
-
|
|
596
|
-
attrs.sizes = resolvedSizes;
|
|
597
|
-
}
|
|
590
|
+
attrs.sizes = resolvedSizes;
|
|
598
591
|
if (variants.fallbackSrc !== undefined) {
|
|
599
592
|
attrs.src = variants.fallbackSrc;
|
|
600
593
|
}
|
|
@@ -609,8 +602,10 @@ async function transformImgNode(
|
|
|
609
602
|
Object.assign(attrs, imgLoadingAttrs(attrs, config.lazyLoad));
|
|
610
603
|
|
|
611
604
|
if (variants && variants.sources.length > 0) {
|
|
612
|
-
wrapInPicture(node, variants.sources, resolvedSizes
|
|
605
|
+
wrapInPicture(node, variants.sources, resolvedSizes);
|
|
606
|
+
return true;
|
|
613
607
|
}
|
|
608
|
+
return false;
|
|
614
609
|
}
|
|
615
610
|
|
|
616
611
|
/**
|
|
@@ -623,37 +618,38 @@ async function transformImgNode(
|
|
|
623
618
|
*
|
|
624
619
|
* @param {JxMutableNode | JxDocument} node - The `<img>` node, mutated into a `<picture>`
|
|
625
620
|
* @param {PictureSource[]} sources
|
|
626
|
-
* @param {
|
|
621
|
+
* @param {JxAttributeValue} sizes
|
|
627
622
|
*/
|
|
628
623
|
function wrapInPicture(
|
|
629
624
|
node: JxMutableNode | JxDocument,
|
|
630
625
|
sources: readonly PictureSource[],
|
|
631
|
-
sizes:
|
|
626
|
+
sizes: JxAttributeValue,
|
|
632
627
|
) {
|
|
633
|
-
const imgAttrs
|
|
634
|
-
const inheritedSizes =
|
|
628
|
+
const imgAttrs = { ...node.attributes };
|
|
629
|
+
const inheritedSizes = imgAttrs.sizes ?? sizes;
|
|
635
630
|
delete imgAttrs.srcset;
|
|
636
631
|
delete imgAttrs.sizes;
|
|
637
|
-
const img:
|
|
632
|
+
const img: JxMutableNode = { attributes: imgAttrs, tagName: "img" };
|
|
638
633
|
if (node.style !== undefined) {
|
|
639
634
|
img.style = node.style;
|
|
640
635
|
delete node.style;
|
|
641
636
|
}
|
|
642
637
|
|
|
638
|
+
const children = sources.map((source): JxMutableNode => {
|
|
639
|
+
const attributes: Record<string, JxAttributeValue> = {};
|
|
640
|
+
// An empty `sizes` is not a neutral default — omit it and let the browser assume 100vw.
|
|
641
|
+
if (inheritedSizes !== "") {
|
|
642
|
+
attributes.sizes = inheritedSizes;
|
|
643
|
+
}
|
|
644
|
+
attributes.srcset = source.srcset;
|
|
645
|
+
attributes.type = source.type;
|
|
646
|
+
return { attributes, tagName: "source" };
|
|
647
|
+
});
|
|
648
|
+
children.push(img);
|
|
649
|
+
|
|
643
650
|
node.tagName = "picture";
|
|
644
651
|
node.attributes = {};
|
|
645
|
-
node.children =
|
|
646
|
-
...sources.map((source) => ({
|
|
647
|
-
attributes: {
|
|
648
|
-
// An empty `sizes` is not a neutral default — omit it and let the browser assume 100vw.
|
|
649
|
-
...(inheritedSizes === "" ? {} : { sizes: inheritedSizes }),
|
|
650
|
-
srcset: source.srcset,
|
|
651
|
-
type: source.type,
|
|
652
|
-
},
|
|
653
|
-
tagName: "source",
|
|
654
|
-
})),
|
|
655
|
-
img,
|
|
656
|
-
] as (string | JxMutableNode)[];
|
|
652
|
+
node.children = children;
|
|
657
653
|
}
|
|
658
654
|
|
|
659
655
|
const IMG_TAG_RE = /<img\b([^>]*)>/gi;
|
package/src/site/img-loading.ts
CHANGED
|
@@ -14,14 +14,19 @@
|
|
|
14
14
|
* @docs framework/site/images
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import type { JsonValue } from "@jxsuite/schema/types";
|
|
18
|
+
|
|
17
19
|
/** WHATWG HTML: `fetchpriority` values. `high` is the one that means "this is the LCP image". */
|
|
18
20
|
export const HIGH_PRIORITY = "high";
|
|
19
21
|
|
|
20
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Attributes an `<img>` already carries, as far as this decision is concerned: values as the
|
|
24
|
+
* document wrote them, where an empty string or `null` counts as absent.
|
|
25
|
+
*/
|
|
21
26
|
export interface ImgLoadingState {
|
|
22
|
-
loading?:
|
|
23
|
-
decoding?:
|
|
24
|
-
fetchpriority?:
|
|
27
|
+
loading?: JsonValue | undefined;
|
|
28
|
+
decoding?: JsonValue | undefined;
|
|
29
|
+
fetchpriority?: JsonValue | undefined;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
/** Attributes to add. Empty when the author has already decided, or when lazy loading is off. */
|
|
@@ -30,7 +35,8 @@ export interface ImgLoadingAttrs {
|
|
|
30
35
|
decoding?: "async";
|
|
31
36
|
}
|
|
32
37
|
|
|
33
|
-
const present = (value:
|
|
38
|
+
const present = (value: JsonValue | undefined) =>
|
|
39
|
+
value !== undefined && value !== null && value !== "";
|
|
34
40
|
|
|
35
41
|
/**
|
|
36
42
|
* Decide the loading attributes for one `<img>`.
|
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
-
import { parseJxDocument } from "@jxsuite/schema/parse";
|
|
12
|
+
import { errorMessage, parseJxDocument } from "@jxsuite/schema/parse";
|
|
13
13
|
import { resolve } from "node:path";
|
|
14
14
|
import { resolveLayout as resolveLayoutWith } from "@jxsuite/site/layout";
|
|
15
15
|
import type { LayoutLoader } from "@jxsuite/site/layout";
|
|
16
|
-
import type { JxDocument } from "@jxsuite/schema/types";
|
|
16
|
+
import type { JxDocument, ProjectConfig } from "@jxsuite/schema/types";
|
|
17
17
|
|
|
18
18
|
/** A {@link LayoutLoader} over the project's own directory. */
|
|
19
19
|
export function nodeLayoutLoader(projectRoot: string): LayoutLoader {
|
|
@@ -25,8 +25,9 @@ export function nodeLayoutLoader(projectRoot: string): LayoutLoader {
|
|
|
25
25
|
try {
|
|
26
26
|
return parseJxDocument(readFileSync(layoutPath, "utf8"), layoutPath);
|
|
27
27
|
} catch (error) {
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
throw new Error(`Invalid layout JSON at ${layoutPath}: ${errorMessage(error)}`, {
|
|
29
|
+
cause: error,
|
|
30
|
+
});
|
|
30
31
|
}
|
|
31
32
|
};
|
|
32
33
|
}
|
|
@@ -35,13 +36,13 @@ export function nodeLayoutLoader(projectRoot: string): LayoutLoader {
|
|
|
35
36
|
* Resolve a page's layout against a project on disk.
|
|
36
37
|
*
|
|
37
38
|
* @param {JxDocument} pageDoc - The raw page document
|
|
38
|
-
* @param {
|
|
39
|
+
* @param {ProjectConfig} projectConfig - Site configuration (for defaults.layout)
|
|
39
40
|
* @param {string} projectRoot - Project root directory
|
|
40
41
|
* @returns {Promise<JxDocument>} The merged document (layout wrapping page content)
|
|
41
42
|
*/
|
|
42
43
|
export function resolveLayout(
|
|
43
44
|
pageDoc: JxDocument,
|
|
44
|
-
projectConfig:
|
|
45
|
+
projectConfig: ProjectConfig,
|
|
45
46
|
projectRoot: string,
|
|
46
47
|
): Promise<JxDocument> {
|
|
47
48
|
return resolveLayoutWith(pageDoc, projectConfig, nodeLayoutLoader(projectRoot));
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
* @docs framework/site/seo
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
18
|
+
import type { JsonPrimitive, JsonValue, JxHeadEntry } from "@jxsuite/schema/types";
|
|
19
|
+
|
|
17
20
|
/**
|
|
18
21
|
* The IANA Link Relation Types registry, snapshotted from
|
|
19
22
|
* `https://www.iana.org/assignments/link-relations/link-relations-1.csv` on 2026-08-16.
|
|
@@ -206,11 +209,11 @@ function isExtensionRelation(token: string): boolean {
|
|
|
206
209
|
* token is judged on its own; comparison is case-insensitive, which is what the HTML Standard
|
|
207
210
|
* specifies for link types.
|
|
208
211
|
*
|
|
209
|
-
* @param {
|
|
212
|
+
* @param {JsonValue | undefined} rel - The raw attribute value, as the document wrote it
|
|
210
213
|
* @returns {string[]} Unrecognized tokens, as written
|
|
211
214
|
*/
|
|
212
|
-
export function unregisteredRelations(rel:
|
|
213
|
-
if (
|
|
215
|
+
export function unregisteredRelations(rel: JsonValue | undefined): string[] {
|
|
216
|
+
if (!isString(rel)) {
|
|
214
217
|
return [];
|
|
215
218
|
}
|
|
216
219
|
return rel.split(/\s+/).filter((token) => {
|
|
@@ -232,15 +235,16 @@ export function unregisteredRelations(rel: unknown): string[] {
|
|
|
232
235
|
* Deduplicated because one mistyped `rel` in a layout is on every page of the site, and a warning
|
|
233
236
|
* repeated four hundred times is a warning nobody reads.
|
|
234
237
|
*
|
|
235
|
-
* @param {readonly
|
|
238
|
+
* @param {readonly (JxHeadEntry | JsonPrimitive)[]} entries - As authored: a `$head` is JSON, and
|
|
239
|
+
* the merger passes a primitive (`null`, a stray string) through where an entry belongs
|
|
236
240
|
* @returns {string[]}
|
|
237
241
|
*/
|
|
238
242
|
export function unregisteredHeadRelations(
|
|
239
|
-
entries: readonly
|
|
243
|
+
entries: readonly (JxHeadEntry | JsonPrimitive)[],
|
|
240
244
|
): string[] {
|
|
241
245
|
const found = new Set<string>();
|
|
242
246
|
for (const entry of entries) {
|
|
243
|
-
if (entry
|
|
247
|
+
if (!isNonNullObject(entry) || entry.tagName !== "link") {
|
|
244
248
|
continue;
|
|
245
249
|
}
|
|
246
250
|
for (const token of unregisteredRelations(entry.attributes?.rel)) {
|
|
@@ -122,7 +122,7 @@ export function negotiateLocale(
|
|
|
122
122
|
available: readonly string[],
|
|
123
123
|
fallback: string,
|
|
124
124
|
): string {
|
|
125
|
-
if (
|
|
125
|
+
if (header === null || header === undefined || header.trim() === "") {
|
|
126
126
|
return fallback;
|
|
127
127
|
}
|
|
128
128
|
for (const { range } of parseAcceptLanguage(header)) {
|