@jxsuite/compiler 2.0.8 → 3.1.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/README.md +8 -8
- package/dist/cli.js +209 -202
- package/dist/compiler.js +118 -115
- package/package.json +12 -11
- package/src/shared.ts +60 -35
- package/src/site/base-path.ts +175 -0
- package/src/site/bundler.ts +28 -4
- package/src/site/client-runtime.ts +51 -1
- package/src/site/context-injection.ts +30 -128
- package/src/site/headers-emitter.ts +20 -0
- package/src/site/i18n.ts +13 -28
- package/src/site/image-optimizer.ts +31 -1
- package/src/site/image-transform.ts +141 -7
- package/src/site/layout-resolver.ts +30 -192
- package/src/site/pages-discovery.ts +9 -49
- package/src/site/service-worker.ts +22 -5
- package/src/site/site-build.ts +152 -44
- package/src/site/well-known.ts +14 -3
- package/src/targets/compile-element.ts +51 -1
- package/src/targets/compile-server.ts +14 -7
- package/src/types.ts +5 -11
- package/src/site/head-merger.ts +0 -251
|
@@ -1,40 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The build's filesystem half of context injection.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* url $site.state.* — site-wide reactive state $page.url — current page URL path $page.title — page
|
|
9
|
-
* title $page.params — dynamic route parameters (if any) $page.locale/$page.dir — the route's
|
|
10
|
-
* language and writing direction (§13.4) $page.alternates — this page in every language it exists
|
|
11
|
-
* in (§13.5)
|
|
4
|
+
* `injectContext` itself is `@jxsuite/site/context` — a live renderer binds `${$site.name}` and
|
|
5
|
+
* `${$page.url}` exactly as a built page does, so the two must not be separate implementations.
|
|
6
|
+
* What is left here is the one thing that needs real directories: rebasing a project-level relative
|
|
7
|
+
* `imports` entry onto the page's own directory.
|
|
12
8
|
*
|
|
13
9
|
* @docs framework/site/i18n
|
|
14
10
|
*/
|
|
15
11
|
|
|
16
12
|
import { dirname, relative, resolve } from "node:path";
|
|
17
|
-
import
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
13
|
+
import { injectContext as injectContextWith } from "@jxsuite/site/context";
|
|
14
|
+
import type { ImportRebaser, SiteRoute, TranslationMember } from "@jxsuite/site/context";
|
|
15
|
+
import type { ResolvedI18n } from "@jxsuite/schema/locale";
|
|
16
|
+
import type { JxDocument, ProjectConfig } from "@jxsuite/schema/types";
|
|
17
|
+
|
|
18
|
+
/** An {@link ImportRebaser} over a project on disk. */
|
|
19
|
+
export function nodeImportRebaser(projectRoot: string): ImportRebaser {
|
|
20
|
+
return (src, route) => {
|
|
21
|
+
if (!route.sourcePath) {
|
|
22
|
+
return src;
|
|
23
|
+
}
|
|
24
|
+
return `./${relative(dirname(route.sourcePath), resolve(projectRoot, src))}`;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* Inject $site and $page context
|
|
29
|
+
* Inject $site and $page context for a page compiled from a project on disk.
|
|
30
30
|
*
|
|
31
31
|
* @param {JxDocument} doc - The page document (mutated)
|
|
32
32
|
* @param {ProjectConfig} projectConfig - Loaded project configuration
|
|
33
33
|
* @param {SiteRoute} route - The resolved route for this page
|
|
34
34
|
* @param {string | null} [projectRoot] - Absolute path to the project root (for import rebasing)
|
|
35
35
|
* @param {ResolvedI18n | null} [i18n] - Validated locale config, when the project declares one
|
|
36
|
-
* @param {readonly TranslationMember[]} [translations] - This route's translation set
|
|
37
|
-
* included
|
|
36
|
+
* @param {readonly TranslationMember[]} [translations] - This route's translation set
|
|
38
37
|
* @returns {JxDocument} The mutated document
|
|
39
38
|
*/
|
|
40
39
|
export function injectContext(
|
|
@@ -44,110 +43,13 @@ export function injectContext(
|
|
|
44
43
|
projectRoot: string | null = null,
|
|
45
44
|
i18n: ResolvedI18n | null = null,
|
|
46
45
|
translations: readonly TranslationMember[] = [],
|
|
47
|
-
) {
|
|
48
|
-
|
|
49
|
-
doc
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
...(i18n === null ? {} : { defaultLocale: i18n.defaultLocale, locales: i18n.locales }),
|
|
57
|
-
...projectConfig.state,
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
/*
|
|
61
|
-
* $page context — read-only page-level data.
|
|
62
|
-
*
|
|
63
|
-
* The locale lives here rather than as a top-level `$locale` because that is what it is: a
|
|
64
|
-
* property of the route, not a third ambient namespace beside $site and $page. A document's own
|
|
65
|
-
* `$lang` still wins over the route's locale, so this is the resolved answer, not the prefix.
|
|
66
|
-
*/
|
|
67
|
-
const pageLang =
|
|
68
|
-
(typeof doc.$lang === "string" ? doc.$lang : undefined) ??
|
|
69
|
-
localeOfRoute(route.urlPattern, i18n) ??
|
|
70
|
-
projectConfig.defaults?.lang;
|
|
71
|
-
/*
|
|
72
|
-
* The same translation set `<head>` advertises, in the shape a template can render: a language
|
|
73
|
-
* switcher is the one part of a multilingual site the framework cannot write for the author, and
|
|
74
|
-
* without this it could only be hand-maintained — a hardcoded list of URLs that goes stale the
|
|
75
|
-
* moment a page gains or loses a translation, silently, in the one place a reader would use it.
|
|
76
|
-
*
|
|
77
|
-
* Site-absolute URLs, not the absolute hrefs `<head>` needs: a switcher is an internal link, and
|
|
78
|
-
* it has to work before `url` is configured. `current` marks the member this route *is*, taken
|
|
79
|
-
* from the route rather than from `$page.locale`, because a document whose `$lang` disagrees with
|
|
80
|
-
* its directory is still served from that directory.
|
|
81
|
-
*/
|
|
82
|
-
const alternates = translations.map((member) => ({
|
|
83
|
-
code: member.locale,
|
|
84
|
-
current: member.urlPattern === route.urlPattern,
|
|
85
|
-
dir: localeDirection(member.locale),
|
|
86
|
-
label: localeLabel(member.locale),
|
|
87
|
-
url: member.urlPattern,
|
|
88
|
-
}));
|
|
89
|
-
doc.state.$page = {
|
|
90
|
-
params: route._pathParams ?? {},
|
|
91
|
-
title: doc.title ?? doc._pageTitle ?? projectConfig.name ?? "",
|
|
92
|
-
url: route.urlPattern,
|
|
93
|
-
...(alternates.length > 0 && { alternates }),
|
|
94
|
-
...(pageLang === undefined ? {} : { dir: localeDirection(pageLang), locale: pageLang }),
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
// Merge project-level state into page state (page wins on conflicts)
|
|
98
|
-
if (projectConfig.state) {
|
|
99
|
-
for (const [key, value] of Object.entries(projectConfig.state)) {
|
|
100
|
-
if (key !== "$site" && key !== "$page" && !(key in doc.state)) {
|
|
101
|
-
doc.state[key] = value as JxStateDefinition;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Merge project-level $media into page $media
|
|
107
|
-
if (projectConfig.$media) {
|
|
108
|
-
doc.$media = { ...projectConfig.$media, ...doc.$media };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Merge project-level imports into page imports (page wins on collision)
|
|
112
|
-
if (projectConfig.imports && Object.keys(projectConfig.imports).length > 0) {
|
|
113
|
-
if (!doc.imports) {
|
|
114
|
-
doc.imports = {};
|
|
115
|
-
}
|
|
116
|
-
for (const [name, srcPath] of Object.entries(projectConfig.imports)) {
|
|
117
|
-
if (!(name in doc.imports)) {
|
|
118
|
-
const src = srcPath as string;
|
|
119
|
-
// Only rebase relative paths — bare/npm specifiers pass through unmodified
|
|
120
|
-
if (projectRoot && route.sourcePath && (src.startsWith("./") || src.startsWith("../"))) {
|
|
121
|
-
const abs = resolve(projectRoot, src);
|
|
122
|
-
doc.imports[name] = `./${relative(dirname(route.sourcePath), abs)}`;
|
|
123
|
-
} else {
|
|
124
|
-
doc.imports[name] = src;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Merge project-level $elements into page $elements (union, dedup)
|
|
131
|
-
if (projectConfig.$elements?.length) {
|
|
132
|
-
if (!doc.$elements?.length) {
|
|
133
|
-
doc.$elements = [...projectConfig.$elements];
|
|
134
|
-
} else {
|
|
135
|
-
const seen = new Set<string>();
|
|
136
|
-
const merged: (string | JxElement)[] = [];
|
|
137
|
-
for (const entry of [
|
|
138
|
-
...projectConfig.$elements,
|
|
139
|
-
...(doc.$elements as (string | JxElement)[]),
|
|
140
|
-
]) {
|
|
141
|
-
const key =
|
|
142
|
-
typeof entry === "string" ? entry : /** @type {{ $ref?: string }} */ entry?.$ref;
|
|
143
|
-
if (key && !seen.has(key)) {
|
|
144
|
-
seen.add(key);
|
|
145
|
-
merged.push(entry);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
doc.$elements = merged;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return doc;
|
|
46
|
+
): JxDocument {
|
|
47
|
+
return injectContextWith(
|
|
48
|
+
doc,
|
|
49
|
+
projectConfig,
|
|
50
|
+
route,
|
|
51
|
+
projectRoot === null ? null : nodeImportRebaser(projectRoot),
|
|
52
|
+
i18n,
|
|
53
|
+
translations,
|
|
54
|
+
);
|
|
153
55
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* @docs framework/site/deployment
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { withBase } from "@jxsuite/schema/asset-paths";
|
|
14
15
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
16
|
import { join } from "node:path";
|
|
16
17
|
import { buildCspHeaders, normalizeCspConfig } from "./csp.ts";
|
|
@@ -181,6 +182,25 @@ export function contentTypeRules(outDir: string): HeaderRule[] {
|
|
|
181
182
|
return rules;
|
|
182
183
|
}
|
|
183
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Re-root every rule's pattern onto the deployment base.
|
|
187
|
+
*
|
|
188
|
+
* A `_headers` pattern is matched against the REQUEST path, so on a site served from `/m/my-site/`
|
|
189
|
+
* a rule for `/assets/*` matches nothing at all — which is silent, because a missing cache header
|
|
190
|
+
* is a slow site rather than a broken one. The generated patterns and the author's `build.headers`
|
|
191
|
+
* rules go through together: both describe the same URL space.
|
|
192
|
+
*
|
|
193
|
+
* @param {readonly HeaderRule[]} rules
|
|
194
|
+
* @param {string} base - From `siteBasePath`; `""` returns the rules untouched
|
|
195
|
+
* @returns {HeaderRule[]}
|
|
196
|
+
*/
|
|
197
|
+
export function rebaseHeaderRules(rules: readonly HeaderRule[], base: string): HeaderRule[] {
|
|
198
|
+
if (base === "") {
|
|
199
|
+
return [...rules];
|
|
200
|
+
}
|
|
201
|
+
return rules.map((rule) => ({ headers: rule.headers, pattern: withBase(base, rule.pattern) }));
|
|
202
|
+
}
|
|
203
|
+
|
|
184
204
|
/** Render rules in the Netlify / Cloudflare Pages `_headers` format. */
|
|
185
205
|
export function renderHeaders(rules: readonly HeaderRule[]): string {
|
|
186
206
|
const blocks = rules.map((rule) => {
|
package/src/site/i18n.ts
CHANGED
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
* @docs framework/site/i18n
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { siteAbsoluteUrl } from "@jxsuite/schema/asset-paths";
|
|
16
|
+
import {
|
|
17
|
+
canonicalizeLocale,
|
|
18
|
+
localeDirection,
|
|
19
|
+
localeOfRoute as localeForRoute,
|
|
20
|
+
} from "@jxsuite/schema/locale";
|
|
16
21
|
import type { ResolvedI18n, TextDirection } from "@jxsuite/schema/locale";
|
|
17
22
|
|
|
18
23
|
/*
|
|
@@ -26,30 +31,10 @@ import type { ResolvedI18n, TextDirection } from "@jxsuite/schema/locale";
|
|
|
26
31
|
*/
|
|
27
32
|
export { resolveI18n } from "@jxsuite/schema/locale";
|
|
28
33
|
export type { LocaleRouting, ResolvedI18n } from "@jxsuite/schema/locale";
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
* Matching is on the **first path segment**, compared canonically, so `/fr-CA/about/` resolves for
|
|
34
|
-
* a project that declared `fr-ca`. A path under no declared locale is the default locale — under
|
|
35
|
-
* `prefix-except-default` that is the point, and under `prefix-always` it is a page the author put
|
|
36
|
-
* outside the locale tree, which is theirs to place.
|
|
37
|
-
*
|
|
38
|
-
* @param {string} urlPattern - Site-absolute route pattern, e.g. `/fr/about/`
|
|
39
|
-
* @param {ResolvedI18n | null} i18n
|
|
40
|
-
* @returns {string | null} A canonical tag, or null when the project has no i18n
|
|
41
|
-
*/
|
|
42
|
-
export function localeOfRoute(urlPattern: string, i18n: ResolvedI18n | null): string | null {
|
|
43
|
-
if (i18n === null) {
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
const first = urlPattern.split("/").find(Boolean);
|
|
47
|
-
if (first === undefined) {
|
|
48
|
-
return i18n.defaultLocale;
|
|
49
|
-
}
|
|
50
|
-
const canonical = canonicalizeLocale(first);
|
|
51
|
-
return canonical !== null && i18n.locales.includes(canonical) ? canonical : i18n.defaultLocale;
|
|
52
|
-
}
|
|
34
|
+
/* `localeOfRoute` lives beside `resolveI18n` in `@jxsuite/schema/locale` for the reason that one
|
|
35
|
+
does: the studio and the cloud preview both have to answer "what language is this URL" and
|
|
36
|
+
neither can import this package. Re-exported so nothing in the build had to change its import. */
|
|
37
|
+
export { localeOfRoute } from "@jxsuite/schema/locale";
|
|
53
38
|
|
|
54
39
|
/**
|
|
55
40
|
* Routes sitting outside the locale tree under `prefix-always`.
|
|
@@ -239,7 +224,7 @@ export function translationSets(
|
|
|
239
224
|
if (route.translationKey !== undefined) {
|
|
240
225
|
declared.add(route.urlPattern);
|
|
241
226
|
}
|
|
242
|
-
const locale =
|
|
227
|
+
const locale = localeForRoute(route.urlPattern, i18n);
|
|
243
228
|
if (locale === null) {
|
|
244
229
|
continue;
|
|
245
230
|
}
|
|
@@ -314,13 +299,13 @@ export function localeAlternates(
|
|
|
314
299
|
let alternates = built.get(members);
|
|
315
300
|
if (alternates === undefined) {
|
|
316
301
|
alternates = members.map((m) => ({
|
|
317
|
-
href:
|
|
302
|
+
href: siteAbsoluteUrl(m.urlPattern, siteUrl),
|
|
318
303
|
hreflang: m.locale,
|
|
319
304
|
}));
|
|
320
305
|
const fallback = members.find((m) => m.locale === i18n.defaultLocale);
|
|
321
306
|
if (fallback !== undefined) {
|
|
322
307
|
alternates.push({
|
|
323
|
-
href:
|
|
308
|
+
href: siteAbsoluteUrl(fallback.urlPattern, siteUrl),
|
|
324
309
|
hreflang: "x-default",
|
|
325
310
|
});
|
|
326
311
|
}
|
|
@@ -124,10 +124,24 @@ export function contentHash(srcPath: string) {
|
|
|
124
124
|
* @param {ImageConfig} config
|
|
125
125
|
* @returns {string}
|
|
126
126
|
*/
|
|
127
|
+
/**
|
|
128
|
+
* Bumped whenever the pipeline decides variants differently for the same settings.
|
|
129
|
+
*
|
|
130
|
+
* The cache key is settings plus source bytes, which answers "would this produce the same output?"
|
|
131
|
+
* only while the code between them is fixed. It is not: capping variants at the largest configured
|
|
132
|
+
* width changed the answer for every image already cached, and without a version here those images
|
|
133
|
+
* keep their stale manifests — including the oversized variants the change exists to stop
|
|
134
|
+
* emitting.
|
|
135
|
+
*
|
|
136
|
+
* 1 → 2: variants stop at the largest configured width (compiler.md §7.2).
|
|
137
|
+
*/
|
|
138
|
+
const PIPELINE_VERSION = 2;
|
|
139
|
+
|
|
127
140
|
export function configHash(config: ImageConfig) {
|
|
128
141
|
const key = JSON.stringify({
|
|
129
142
|
formats: config.formats,
|
|
130
143
|
quality: config.quality,
|
|
144
|
+
v: PIPELINE_VERSION,
|
|
131
145
|
widths: config.widths,
|
|
132
146
|
});
|
|
133
147
|
return createHash("md5").update(key).digest("hex").slice(0, 8);
|
|
@@ -166,8 +180,24 @@ export async function processImage(srcPath: string, cacheImgDir: string, config:
|
|
|
166
180
|
|
|
167
181
|
const variants: ImageVariant[] = [];
|
|
168
182
|
|
|
183
|
+
/*
|
|
184
|
+
* The configured ladder, capped at the source's own width — upscaling only wastes bytes.
|
|
185
|
+
*
|
|
186
|
+
* A source SMALLER than every rung would otherwise produce no variants at all, and one that falls
|
|
187
|
+
* between rungs would never be offered at its native resolution, so both get their own width
|
|
188
|
+
* added. A source LARGER than the top rung does not: the largest configured width is the ceiling
|
|
189
|
+
* the project chose, and honouring it is the point of configuring it.
|
|
190
|
+
*
|
|
191
|
+
* This used to add the native width unconditionally, so a 3840 px screenshot shipped a 3840 px
|
|
192
|
+
* AVIF *and* WebP — encoded on every cold build, copied into dist, and offered as a `3840w`
|
|
193
|
+
* candidate that no `sizes` on any real viewport would pick.
|
|
194
|
+
*/
|
|
195
|
+
const ceiling = config.widths.length > 0 ? Math.max(...config.widths) : meta.width;
|
|
169
196
|
const widths = config.widths.filter((w) => w <= meta.width);
|
|
170
|
-
if (
|
|
197
|
+
if (meta.width < ceiling && !widths.includes(meta.width)) {
|
|
198
|
+
widths.push(meta.width);
|
|
199
|
+
}
|
|
200
|
+
if (widths.length === 0) {
|
|
171
201
|
widths.push(meta.width);
|
|
172
202
|
}
|
|
173
203
|
widths.sort((a, b) => a - b);
|
|
@@ -25,7 +25,7 @@ import { imgLoadingAttrs } from "./img-loading.ts";
|
|
|
25
25
|
import { resolveAssetUrl } from "@jxsuite/schema/asset-paths";
|
|
26
26
|
|
|
27
27
|
import type { AssetMount } from "@jxsuite/schema/asset-paths";
|
|
28
|
-
import type { ImageConfig, ImageManifest } from "./image-optimizer.ts";
|
|
28
|
+
import type { ImageConfig, ImageManifest, ImageVariant } from "./image-optimizer.ts";
|
|
29
29
|
import type { JxDocument, JxMutableNode } from "@jxsuite/schema/types";
|
|
30
30
|
import type { CacheManifest } from "./image-cache.ts";
|
|
31
31
|
|
|
@@ -267,13 +267,23 @@ async function walkAndTransform(
|
|
|
267
267
|
metaCache: ImageMetaCache,
|
|
268
268
|
imageRefs: Map<string, ImageManifest>,
|
|
269
269
|
mounts: readonly AssetMount[],
|
|
270
|
+
containerWidth: number | null = null,
|
|
270
271
|
) {
|
|
271
272
|
if (!node || typeof node !== "object") {
|
|
272
273
|
return;
|
|
273
274
|
}
|
|
274
275
|
|
|
275
276
|
if (node.tagName === "img") {
|
|
276
|
-
await transformImgNode(
|
|
277
|
+
await transformImgNode(
|
|
278
|
+
node,
|
|
279
|
+
config,
|
|
280
|
+
projectRoot,
|
|
281
|
+
cache,
|
|
282
|
+
metaCache,
|
|
283
|
+
imageRefs,
|
|
284
|
+
mounts,
|
|
285
|
+
containerWidth,
|
|
286
|
+
);
|
|
277
287
|
// Wrapping rewrote this node into a `<picture>` whose children this pass just authored.
|
|
278
288
|
// Descending into them would find the `<img>` and wrap it again, forever.
|
|
279
289
|
if (node.tagName !== "img") {
|
|
@@ -294,11 +304,22 @@ async function walkAndTransform(
|
|
|
294
304
|
}
|
|
295
305
|
|
|
296
306
|
if (Array.isArray(node.children)) {
|
|
307
|
+
// Every descendant is bounded by the narrowest literal max-width above it.
|
|
308
|
+
const inner = containerWidthOf(node, containerWidth);
|
|
297
309
|
for (const child of node.children) {
|
|
298
310
|
if (typeof child === "string") {
|
|
299
311
|
continue;
|
|
300
312
|
}
|
|
301
|
-
await walkAndTransform(
|
|
313
|
+
await walkAndTransform(
|
|
314
|
+
child,
|
|
315
|
+
config,
|
|
316
|
+
projectRoot,
|
|
317
|
+
cache,
|
|
318
|
+
metaCache,
|
|
319
|
+
imageRefs,
|
|
320
|
+
mounts,
|
|
321
|
+
inner,
|
|
322
|
+
);
|
|
302
323
|
}
|
|
303
324
|
}
|
|
304
325
|
}
|
|
@@ -327,6 +348,8 @@ interface ResolvedVariants {
|
|
|
327
348
|
dims: { width: number; height: number } | null;
|
|
328
349
|
/** One entry per additional format, when the image is to be wrapped in a `<picture>`. */
|
|
329
350
|
sources: PictureSource[];
|
|
351
|
+
/** Resized last-resort `src`, when a universally decodable format was emitted. */
|
|
352
|
+
fallbackSrc?: string | undefined;
|
|
330
353
|
}
|
|
331
354
|
|
|
332
355
|
/**
|
|
@@ -417,7 +440,43 @@ async function resolveVariants(
|
|
|
417
440
|
* format information, so a browser that cannot decode AVIF would still pick an AVIF candidate.
|
|
418
441
|
*/
|
|
419
442
|
const wrap = config.picture !== false && available.length > 1;
|
|
420
|
-
return {
|
|
443
|
+
return {
|
|
444
|
+
dims,
|
|
445
|
+
fallbackSrc: fallbackVariant(manifest.variants, config.formats),
|
|
446
|
+
sources: wrap ? available : [],
|
|
447
|
+
srcset: available[0]?.srcset ?? "",
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Formats an `<img src>` may point at without narrowing who can see the image.
|
|
453
|
+
*
|
|
454
|
+
* `src` is the last resort — reached only by a client that understood neither `srcset` nor any
|
|
455
|
+
* `<source type>`, which in practice means it understands JPEG and PNG and little else.
|
|
456
|
+
*/
|
|
457
|
+
const UNIVERSAL_FORMATS: readonly string[] = ["jpeg", "png"];
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The largest emitted variant a `src` can safely fall back to, or `undefined` to keep the original.
|
|
461
|
+
*
|
|
462
|
+
* The original is a correct fallback and a terrible one: it is the unresized source, so a 3840 px
|
|
463
|
+
* screenshot leaves a 1.7 MB PNG on the end of the chain. A resized variant of the same universally
|
|
464
|
+
* decodable format is strictly better. When the project emits no such format — `["webp", "avif"]`
|
|
465
|
+
* is a common and reasonable choice — there is nothing safe to point at, and the original stays: a
|
|
466
|
+
* smaller image nobody can decode is not an improvement.
|
|
467
|
+
*/
|
|
468
|
+
function fallbackVariant(variants: ImageVariant[], formats: readonly string[]): string | undefined {
|
|
469
|
+
const format = UNIVERSAL_FORMATS.find((candidate) => formats.includes(candidate));
|
|
470
|
+
if (format === undefined) {
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
let best: ImageVariant | undefined;
|
|
474
|
+
for (const variant of variants) {
|
|
475
|
+
if (variant.format === format && (best === undefined || variant.width > best.width)) {
|
|
476
|
+
best = variant;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return best?.outputPath;
|
|
421
480
|
}
|
|
422
481
|
|
|
423
482
|
/**
|
|
@@ -429,6 +488,61 @@ async function resolveVariants(
|
|
|
429
488
|
* @param {Map<string, ImageManifest>} imageRefs
|
|
430
489
|
* @param {readonly AssetMount[]} mounts
|
|
431
490
|
*/
|
|
491
|
+
const CSS_LENGTH = /^(\d+(?:\.\d+)?)(px|rem)$/;
|
|
492
|
+
|
|
493
|
+
/** A CSS length in px, but only when it is a plain `px`/`rem` literal. `null` for anything else. */
|
|
494
|
+
function pxLength(value: unknown): number | null {
|
|
495
|
+
if (typeof value !== "string") {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
const match = CSS_LENGTH.exec(value.trim());
|
|
499
|
+
if (!match) {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
const n = Number(match[1]);
|
|
503
|
+
return match[2] === "rem" ? n * 16 : n;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* The narrowest px `max-width`/`width` an element at this point in the tree sits inside.
|
|
508
|
+
*
|
|
509
|
+
* Only literal lengths count. A `clamp()`, a percentage or a custom property is a real constraint
|
|
510
|
+
* too, but not one the build can resolve — and a wrong `sizes` is worse than none, because the
|
|
511
|
+
* browser trusts it completely and picks its candidate before layout exists to contradict it.
|
|
512
|
+
*/
|
|
513
|
+
function containerWidthOf(
|
|
514
|
+
node: JxMutableNode | JxDocument,
|
|
515
|
+
inherited: number | null,
|
|
516
|
+
): number | null {
|
|
517
|
+
const style = node.style as Record<string, unknown> | undefined;
|
|
518
|
+
if (!style || typeof style !== "object") {
|
|
519
|
+
return inherited;
|
|
520
|
+
}
|
|
521
|
+
const declared = pxLength(style.maxWidth) ?? pxLength(style.width);
|
|
522
|
+
if (declared === null) {
|
|
523
|
+
return inherited;
|
|
524
|
+
}
|
|
525
|
+
return inherited === null ? declared : Math.min(inherited, declared);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* The `sizes` an image in a `max-width` container needs.
|
|
530
|
+
*
|
|
531
|
+
* `sizes` is a promise about layout, and the browser keeps the browser's side of it absolutely: it
|
|
532
|
+
* picks a candidate from this string before any layout has happened, and never revisits the choice.
|
|
533
|
+
* A single project-wide string therefore cannot be right — jxsuite.com's `(max-width: 768px) 100vw,
|
|
534
|
+
* 50vw` describes a half-width image, while its hero renders full-width inside a 960 px column, so
|
|
535
|
+
* the same string is too small on a wide screen and too large on a narrow one. The container width
|
|
536
|
+
* is layout the compiler already holds, so it is the better answer wherever it exists.
|
|
537
|
+
*/
|
|
538
|
+
function sizesForContainer(containerWidth: number | null): string | undefined {
|
|
539
|
+
if (containerWidth === null) {
|
|
540
|
+
return undefined;
|
|
541
|
+
}
|
|
542
|
+
const width = Math.round(containerWidth);
|
|
543
|
+
return `(max-width: ${width}px) 100vw, ${width}px`;
|
|
544
|
+
}
|
|
545
|
+
|
|
432
546
|
async function transformImgNode(
|
|
433
547
|
node: JxMutableNode | JxDocument,
|
|
434
548
|
config: ImageConfig,
|
|
@@ -437,6 +551,7 @@ async function transformImgNode(
|
|
|
437
551
|
metaCache: ImageMetaCache,
|
|
438
552
|
imageRefs: Map<string, ImageManifest>,
|
|
439
553
|
mounts: readonly AssetMount[],
|
|
554
|
+
containerWidth: number | null = null,
|
|
440
555
|
) {
|
|
441
556
|
if (!node.attributes) {
|
|
442
557
|
node.attributes = {};
|
|
@@ -465,10 +580,24 @@ async function transformImgNode(
|
|
|
465
580
|
)
|
|
466
581
|
: null;
|
|
467
582
|
|
|
583
|
+
/*
|
|
584
|
+
* Precedence: what the author wrote, then what the layout says, then the project-wide default.
|
|
585
|
+
* The project default is a guess that cannot see the container; the container width is not a
|
|
586
|
+
* guess, so it outranks it — and an image with neither keeps whatever the project chose.
|
|
587
|
+
*/
|
|
588
|
+
const ownWidth = containerWidthOf(node, containerWidth);
|
|
589
|
+
const resolvedSizes =
|
|
590
|
+
(attrs.sizes as string | undefined) ?? sizesForContainer(ownWidth) ?? config.sizes;
|
|
591
|
+
|
|
468
592
|
if (variants) {
|
|
469
593
|
if (variants.srcset !== "") {
|
|
470
594
|
attrs.srcset = variants.srcset;
|
|
471
|
-
|
|
595
|
+
if (resolvedSizes !== undefined) {
|
|
596
|
+
attrs.sizes = resolvedSizes;
|
|
597
|
+
}
|
|
598
|
+
if (variants.fallbackSrc !== undefined) {
|
|
599
|
+
attrs.src = variants.fallbackSrc;
|
|
600
|
+
}
|
|
472
601
|
}
|
|
473
602
|
// Intrinsic dimensions prevent layout shift; author-supplied values win.
|
|
474
603
|
if (variants.dims && attrs.width === undefined && attrs.height === undefined) {
|
|
@@ -480,7 +609,7 @@ async function transformImgNode(
|
|
|
480
609
|
Object.assign(attrs, imgLoadingAttrs(attrs, config.lazyLoad));
|
|
481
610
|
|
|
482
611
|
if (variants && variants.sources.length > 0) {
|
|
483
|
-
wrapInPicture(node, variants.sources,
|
|
612
|
+
wrapInPicture(node, variants.sources, resolvedSizes ?? "");
|
|
484
613
|
}
|
|
485
614
|
}
|
|
486
615
|
|
|
@@ -515,7 +644,12 @@ function wrapInPicture(
|
|
|
515
644
|
node.attributes = {};
|
|
516
645
|
node.children = [
|
|
517
646
|
...sources.map((source) => ({
|
|
518
|
-
attributes: {
|
|
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
|
+
},
|
|
519
653
|
tagName: "source",
|
|
520
654
|
})),
|
|
521
655
|
img,
|