@jxsuite/compiler 0.28.0 → 0.28.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 +166 -249
- package/dist/compiler.js +100 -188
- package/package.json +5 -5
- package/src/cli.ts +6 -6
- package/src/compile-cli.ts +6 -4
- package/src/compiler.ts +35 -29
- package/src/shared.ts +402 -231
- package/src/site/content-loader.ts +54 -29
- package/src/site/context-injection.ts +13 -9
- package/src/site/format-host.ts +26 -15
- package/src/site/head-merger.ts +37 -18
- package/src/site/image-cache.ts +13 -12
- package/src/site/image-optimizer.ts +32 -23
- package/src/site/image-transform.ts +172 -81
- package/src/site/layout-resolver.ts +33 -18
- package/src/site/pages-discovery.ts +61 -39
- package/src/site/prototype-resolver.ts +45 -21
- package/src/site/site-build.ts +232 -183
- package/src/site/site-loader.ts +71 -52
- package/src/targets/compile-class.ts +70 -51
- package/src/targets/compile-client.ts +271 -227
- package/src/targets/compile-element.ts +230 -146
- package/src/targets/compile-server.ts +16 -62
- package/src/targets/compile-static.ts +27 -19
- package/src/targets/compile-image-endpoint.ts +0 -138
|
@@ -7,62 +7,93 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
|
-
import {
|
|
11
|
-
import { resolve, extname, basename } from "node:path";
|
|
10
|
+
import { basename, extname, resolve } from "node:path";
|
|
12
11
|
import {
|
|
13
|
-
processImage,
|
|
14
12
|
buildSrcset,
|
|
15
|
-
contentHash,
|
|
16
13
|
configHash,
|
|
14
|
+
contentHash,
|
|
17
15
|
getImageMetadata,
|
|
16
|
+
processImage,
|
|
18
17
|
} from "./image-optimizer.ts";
|
|
19
|
-
import { getCached,
|
|
18
|
+
import { getCached, getImageCacheDir, setCached } from "./image-cache.ts";
|
|
20
19
|
|
|
21
|
-
import type { ImageConfig } from "./image-optimizer.ts";
|
|
22
|
-
import type {
|
|
23
|
-
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
20
|
+
import type { ImageConfig, ImageManifest } from "./image-optimizer.ts";
|
|
21
|
+
import type { JxDocument, JxMutableNode } from "@jxsuite/schema/types";
|
|
24
22
|
import type { CacheManifest } from "./image-cache.ts";
|
|
25
|
-
import type { JxDocument } from "@jxsuite/schema/types";
|
|
26
23
|
|
|
27
24
|
const SKIP_EXTENSIONS = new Set([".svg", ".gif"]);
|
|
28
25
|
const EXTERNAL_PREFIXES = ["http://", "https://", "data:", "//"];
|
|
29
26
|
|
|
30
27
|
/** Per-build memo of original image dimensions + content hash (cloudflare mode). */
|
|
31
|
-
export type ImageMetaCache = Map<
|
|
28
|
+
export type ImageMetaCache = Map<
|
|
29
|
+
string,
|
|
30
|
+
{ width: number | null; height: number | null; hash: string }
|
|
31
|
+
>;
|
|
32
|
+
|
|
33
|
+
let sharpWarned = false;
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
36
|
* Resolve original image dimensions and content hash for cloudflare mode. Reads only the image
|
|
35
|
-
* header via Sharp — no variants are generated.
|
|
37
|
+
* header via Sharp — no variants are generated. Dimensions degrade to null when Sharp is
|
|
38
|
+
* unavailable (e.g. native binary won't load): srcsets are still emitted (fit=scale-down guards
|
|
39
|
+
* against upscaling), only the width/height attributes are skipped.
|
|
36
40
|
*
|
|
37
41
|
* @param {string} absoluteSrc
|
|
38
42
|
* @param {ImageMetaCache} metaCache
|
|
39
|
-
* @returns {Promise<{ width: number; height: number; hash: string }>}
|
|
43
|
+
* @returns {Promise<{ width: number | null; height: number | null; hash: string }>}
|
|
40
44
|
*/
|
|
41
45
|
async function resolveCfMeta(absoluteSrc: string, metaCache: ImageMetaCache) {
|
|
42
46
|
let meta = metaCache.get(absoluteSrc);
|
|
43
47
|
if (!meta) {
|
|
44
|
-
const
|
|
45
|
-
|
|
48
|
+
const hash = contentHash(absoluteSrc);
|
|
49
|
+
try {
|
|
50
|
+
const m = await getImageMetadata(absoluteSrc);
|
|
51
|
+
meta = { width: m.width, height: m.height, hash };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (!sharpWarned) {
|
|
54
|
+
sharpWarned = true;
|
|
55
|
+
console.warn(
|
|
56
|
+
`Could not read image dimensions (${(error as Error).message}) — ` +
|
|
57
|
+
`emitting srcsets without width/height attributes.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
meta = { width: null, height: null, hash };
|
|
61
|
+
}
|
|
46
62
|
metaCache.set(absoluteSrc, meta);
|
|
47
63
|
}
|
|
48
64
|
return meta;
|
|
49
65
|
}
|
|
50
66
|
|
|
51
67
|
/**
|
|
52
|
-
* Build a srcset of `/
|
|
53
|
-
* original image width.
|
|
68
|
+
* Build a srcset of Cloudflare `/cdn-cgi/image/` transform-via-URL entries for the configured
|
|
69
|
+
* widths that fit within the original image width. `format=auto` lets Cloudflare negotiate
|
|
70
|
+
* AVIF/WebP per browser; `fit=scale-down` guards against upscaling. Requires Image Transformations
|
|
71
|
+
* to be enabled on the serving zone (does not work on *.pages.dev).
|
|
54
72
|
*
|
|
55
|
-
* @param {string} src -
|
|
56
|
-
* @param {
|
|
73
|
+
* @param {string} src - Site-relative src (e.g. "/images/hero.png") or allowlisted https URL
|
|
74
|
+
* @param {ImageConfig} config
|
|
57
75
|
* @param {number} originalWidth
|
|
58
|
-
* @param {string} hash - 8-char content hash
|
|
76
|
+
* @param {string | null} hash - 8-char content hash for cache busting (null for remote sources,
|
|
77
|
+
* whose bytes aren't available at build time)
|
|
59
78
|
* @returns {string}
|
|
60
79
|
*/
|
|
61
|
-
function buildCloudflareSrcset(
|
|
62
|
-
|
|
80
|
+
function buildCloudflareSrcset(
|
|
81
|
+
src: string,
|
|
82
|
+
config: ImageConfig,
|
|
83
|
+
originalWidth: number,
|
|
84
|
+
hash: string | null,
|
|
85
|
+
) {
|
|
86
|
+
const quality = config.quality?.webp ?? 80;
|
|
87
|
+
const separator = src.startsWith("/") ? "" : "/";
|
|
88
|
+
const suffix = hash ? `?v=${hash}` : "";
|
|
89
|
+
return config.widths
|
|
63
90
|
.filter((w) => w <= originalWidth)
|
|
64
|
-
.
|
|
65
|
-
.map(
|
|
91
|
+
.toSorted((a, b) => a - b)
|
|
92
|
+
.map(
|
|
93
|
+
(w) =>
|
|
94
|
+
`/cdn-cgi/image/width=${w},quality=${quality},fit=scale-down,format=auto` +
|
|
95
|
+
`${separator}${src}${suffix} ${w}w`,
|
|
96
|
+
)
|
|
66
97
|
.join(", ");
|
|
67
98
|
}
|
|
68
99
|
|
|
@@ -86,10 +117,14 @@ async function resolveManifest(
|
|
|
86
117
|
|
|
87
118
|
if (cached) {
|
|
88
119
|
const allExist = cached.variants.every((v) => existsSync(v.absolutePath));
|
|
89
|
-
if (allExist)
|
|
120
|
+
if (allExist) {
|
|
121
|
+
return cached;
|
|
122
|
+
}
|
|
90
123
|
}
|
|
91
124
|
|
|
92
|
-
if (!cached)
|
|
125
|
+
if (!cached) {
|
|
126
|
+
console.log(` Optimizing ${basename(absoluteSrc)}...`);
|
|
127
|
+
}
|
|
93
128
|
const manifest = await processImage(absoluteSrc, getImageCacheDir(projectRoot), config);
|
|
94
129
|
setCached(cache, key, src, manifest);
|
|
95
130
|
return manifest;
|
|
@@ -102,43 +137,59 @@ async function resolveManifest(
|
|
|
102
137
|
* @returns {boolean}
|
|
103
138
|
*/
|
|
104
139
|
function shouldSkip(src: string) {
|
|
105
|
-
if (typeof src !== "string")
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
140
|
+
if (typeof src !== "string") {
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
if (!src) {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
if (src.includes("${")) {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
if (EXTERNAL_PREFIXES.some((p) => src.startsWith(p))) {
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
if (SKIP_EXTENSIONS.has(extname(src).toLowerCase())) {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
110
155
|
return false;
|
|
111
156
|
}
|
|
112
157
|
|
|
113
158
|
/**
|
|
114
159
|
* Check if a remote src is eligible for cloudflare-service optimization: an https URL whose
|
|
115
160
|
* hostname is in the project's `images.remoteDomains` allowlist. Content data (e.g. CSV columns)
|
|
116
|
-
* routinely references externally hosted images — allowlisted hosts flow through the /
|
|
117
|
-
*
|
|
161
|
+
* routinely references externally hosted images — allowlisted hosts flow through the /cdn-cgi/image
|
|
162
|
+
* transform URL like local assets; everything else passes through untouched. The zone must permit
|
|
163
|
+
* resizing from the remote origin (Images → Transformations → Sources).
|
|
118
164
|
*
|
|
119
165
|
* @param {string} src
|
|
120
166
|
* @param {ImageConfig} config
|
|
121
167
|
* @returns {boolean}
|
|
122
168
|
*/
|
|
123
169
|
function isAllowedRemote(src: string, config: ImageConfig) {
|
|
124
|
-
if (config.service !== "cloudflare")
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (
|
|
170
|
+
if (config.service !== "cloudflare") {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
if (!config.remoteDomains?.length) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
if (typeof src !== "string" || !src.startsWith("https://")) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
if (src.includes("${")) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
128
182
|
try {
|
|
129
183
|
const url = new URL(src);
|
|
130
|
-
if (SKIP_EXTENSIONS.has(extname(url.pathname).toLowerCase()))
|
|
184
|
+
if (SKIP_EXTENSIONS.has(extname(url.pathname).toLowerCase())) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
131
187
|
return config.remoteDomains.includes(url.hostname);
|
|
132
188
|
} catch {
|
|
133
189
|
return false;
|
|
134
190
|
}
|
|
135
191
|
}
|
|
136
192
|
|
|
137
|
-
/** 8-char cache-busting hash for a remote URL (original bytes aren't available at build time). */
|
|
138
|
-
function urlHash(src: string) {
|
|
139
|
-
return createHash("sha256").update(src).digest("hex").slice(0, 8);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
193
|
/**
|
|
143
194
|
* Resolve a src path to an absolute filesystem path.
|
|
144
195
|
*
|
|
@@ -176,9 +227,11 @@ export async function transformImageNodes(
|
|
|
176
227
|
cache: CacheManifest | null,
|
|
177
228
|
metaCache?: ImageMetaCache,
|
|
178
229
|
) {
|
|
179
|
-
const imageRefs
|
|
230
|
+
const imageRefs = new Map<string, ImageManifest>();
|
|
180
231
|
|
|
181
|
-
if (!config.optimize)
|
|
232
|
+
if (!config.optimize) {
|
|
233
|
+
return { imageRefs };
|
|
234
|
+
}
|
|
182
235
|
|
|
183
236
|
const meta = metaCache ?? new Map();
|
|
184
237
|
await walkAndTransform(doc, config, projectRoot, cache, meta, imageRefs);
|
|
@@ -202,7 +255,9 @@ async function walkAndTransform(
|
|
|
202
255
|
metaCache: ImageMetaCache,
|
|
203
256
|
imageRefs: Map<string, ImageManifest>,
|
|
204
257
|
) {
|
|
205
|
-
if (!node || typeof node !== "object")
|
|
258
|
+
if (!node || typeof node !== "object") {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
206
261
|
|
|
207
262
|
if (node.tagName === "img") {
|
|
208
263
|
await transformImgNode(node, config, projectRoot, cache, metaCache, imageRefs);
|
|
@@ -221,7 +276,9 @@ async function walkAndTransform(
|
|
|
221
276
|
|
|
222
277
|
if (Array.isArray(node.children)) {
|
|
223
278
|
for (const child of node.children) {
|
|
224
|
-
if (typeof child === "string")
|
|
279
|
+
if (typeof child === "string") {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
225
282
|
await walkAndTransform(child, config, projectRoot, cache, metaCache, imageRefs);
|
|
226
283
|
}
|
|
227
284
|
}
|
|
@@ -243,43 +300,59 @@ async function transformImgNode(
|
|
|
243
300
|
metaCache: ImageMetaCache,
|
|
244
301
|
imageRefs: Map<string, ImageManifest>,
|
|
245
302
|
) {
|
|
246
|
-
if (!node.attributes)
|
|
303
|
+
if (!node.attributes) {
|
|
304
|
+
node.attributes = {};
|
|
305
|
+
}
|
|
247
306
|
|
|
248
307
|
const src = node.attributes.src ?? node.src;
|
|
308
|
+
// Bound ($ref) or missing srcs cannot be optimized at build time.
|
|
309
|
+
if (typeof src !== "string") {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
249
312
|
const remote = isAllowedRemote(src, config);
|
|
250
|
-
if (!remote && shouldSkip(src))
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
313
|
+
if (!remote && shouldSkip(src)) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (node.attributes["data-no-optimize"] !== undefined) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
255
319
|
|
|
256
320
|
let srcset;
|
|
257
321
|
let original: { width: number; height: number } | undefined;
|
|
258
322
|
|
|
259
323
|
if (remote) {
|
|
260
324
|
// Original dimensions are unknown without fetching — emit every configured width and let
|
|
261
|
-
//
|
|
262
|
-
srcset = buildCloudflareSrcset(src, config
|
|
263
|
-
} else if (config.service === "cloudflare") {
|
|
264
|
-
const meta = await resolveCfMeta(absoluteSrc as string, metaCache);
|
|
265
|
-
srcset = buildCloudflareSrcset(src, config.widths, meta.width, meta.hash);
|
|
266
|
-
original = meta;
|
|
325
|
+
// Fit=scale-down avoid upscaling past the source size.
|
|
326
|
+
srcset = buildCloudflareSrcset(src, config, Infinity, null);
|
|
267
327
|
} else {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
manifest = await resolveManifest(absoluteSrc as string, src, config, projectRoot, cache!);
|
|
272
|
-
imageRefs.set(absoluteSrc as string, manifest);
|
|
328
|
+
const absoluteSrc = resolveImagePath(src, projectRoot);
|
|
329
|
+
if (!existsSync(absoluteSrc)) {
|
|
330
|
+
return;
|
|
273
331
|
}
|
|
274
332
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
333
|
+
if (config.service === "cloudflare") {
|
|
334
|
+
const meta = await resolveCfMeta(absoluteSrc, metaCache);
|
|
335
|
+
srcset = buildCloudflareSrcset(src, config, meta.width ?? Infinity, meta.hash);
|
|
336
|
+
if (meta.width != null && meta.height != null) {
|
|
337
|
+
original = { width: meta.width, height: meta.height };
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
let manifest = imageRefs.get(absoluteSrc);
|
|
341
|
+
|
|
342
|
+
if (!manifest) {
|
|
343
|
+
manifest = await resolveManifest(absoluteSrc, src, config, projectRoot, cache!);
|
|
344
|
+
imageRefs.set(absoluteSrc, manifest);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0];
|
|
348
|
+
srcset = buildSrcset(manifest.variants, preferredFormat);
|
|
349
|
+
({ original } = manifest);
|
|
350
|
+
}
|
|
278
351
|
}
|
|
279
352
|
|
|
280
353
|
if (srcset) {
|
|
281
354
|
node.attributes.srcset = srcset;
|
|
282
|
-
node.attributes.sizes
|
|
355
|
+
node.attributes.sizes ??= config.sizes;
|
|
283
356
|
}
|
|
284
357
|
|
|
285
358
|
if (!node.attributes.width && original?.width) {
|
|
@@ -323,32 +396,44 @@ async function transformInnerHtmlImages(
|
|
|
323
396
|
const replacements = [];
|
|
324
397
|
|
|
325
398
|
for (const m of html.matchAll(IMG_TAG_RE)) {
|
|
326
|
-
const tag = m
|
|
327
|
-
const attrs = m
|
|
399
|
+
const [tag] = m;
|
|
400
|
+
const [, attrs] = m;
|
|
328
401
|
|
|
329
|
-
if (SRCSET_ATTR_RE.test(attrs))
|
|
330
|
-
|
|
402
|
+
if (SRCSET_ATTR_RE.test(attrs)) {
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (DATA_NO_OPT_RE.test(attrs)) {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
331
408
|
|
|
332
409
|
const srcMatch = attrs.match(SRC_ATTR_RE);
|
|
333
|
-
if (!srcMatch)
|
|
410
|
+
if (!srcMatch) {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
334
413
|
|
|
335
414
|
// Attribute values in pre-rendered innerHTML are entity-escaped — decode for processing
|
|
336
415
|
const src = srcMatch[1].replaceAll("&", "&");
|
|
337
416
|
const remote = isAllowedRemote(src, config);
|
|
338
|
-
if (!remote && shouldSkip(src))
|
|
417
|
+
if (!remote && shouldSkip(src)) {
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
339
420
|
|
|
340
421
|
const absoluteSrc = remote ? null : resolveImagePath(src, projectRoot);
|
|
341
|
-
if (absoluteSrc && !existsSync(absoluteSrc))
|
|
422
|
+
if (absoluteSrc && !existsSync(absoluteSrc)) {
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
342
425
|
|
|
343
426
|
let srcset;
|
|
344
427
|
let original: { width: number; height: number } | undefined;
|
|
345
428
|
|
|
346
429
|
if (remote) {
|
|
347
|
-
srcset = buildCloudflareSrcset(src, config
|
|
430
|
+
srcset = buildCloudflareSrcset(src, config, Infinity, null);
|
|
348
431
|
} else if (config.service === "cloudflare") {
|
|
349
432
|
const meta = await resolveCfMeta(absoluteSrc as string, metaCache);
|
|
350
|
-
srcset = buildCloudflareSrcset(src, config
|
|
351
|
-
|
|
433
|
+
srcset = buildCloudflareSrcset(src, config, meta.width ?? Infinity, meta.hash);
|
|
434
|
+
if (meta.width != null && meta.height != null) {
|
|
435
|
+
original = { width: meta.width, height: meta.height };
|
|
436
|
+
}
|
|
352
437
|
} else {
|
|
353
438
|
let manifest = imageRefs.get(absoluteSrc as string);
|
|
354
439
|
if (!manifest) {
|
|
@@ -358,9 +443,11 @@ async function transformInnerHtmlImages(
|
|
|
358
443
|
|
|
359
444
|
const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0];
|
|
360
445
|
srcset = buildSrcset(manifest.variants, preferredFormat);
|
|
361
|
-
original = manifest
|
|
446
|
+
({ original } = manifest);
|
|
447
|
+
}
|
|
448
|
+
if (!srcset) {
|
|
449
|
+
continue;
|
|
362
450
|
}
|
|
363
|
-
if (!srcset) continue;
|
|
364
451
|
|
|
365
452
|
let extra = ` srcset="${srcset}" sizes="${config.sizes}"`;
|
|
366
453
|
if (!/\bwidth=/.test(attrs) && original?.width) {
|
|
@@ -370,8 +457,12 @@ async function transformInnerHtmlImages(
|
|
|
370
457
|
extra += ` height="${original.height}"`;
|
|
371
458
|
}
|
|
372
459
|
if (config.lazyLoad && !/\bloading="eager"/.test(attrs)) {
|
|
373
|
-
if (!/\bloading=/.test(attrs))
|
|
374
|
-
|
|
460
|
+
if (!/\bloading=/.test(attrs)) {
|
|
461
|
+
extra += ` loading="lazy"`;
|
|
462
|
+
}
|
|
463
|
+
if (!/\bdecoding=/.test(attrs)) {
|
|
464
|
+
extra += ` decoding="async"`;
|
|
465
|
+
}
|
|
375
466
|
}
|
|
376
467
|
|
|
377
468
|
replacements.push({ match: tag, replacement: `<img${attrs}${extra}>` });
|
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
* - Named slots use attributes.slot on page children
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { parseJxDocument } from "@jxsuite/schema/parse";
|
|
17
18
|
import { resolve } from "node:path";
|
|
18
|
-
import type {
|
|
19
|
+
import type { JxDocument, JxElement } from "@jxsuite/schema/types";
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Resolve a page's layout, wrapping the page content in the layout structure.
|
|
@@ -48,10 +49,10 @@ export function resolveLayout(
|
|
|
48
49
|
/** @type {JxDocument} */
|
|
49
50
|
let layoutDoc;
|
|
50
51
|
try {
|
|
51
|
-
layoutDoc =
|
|
52
|
-
} catch (
|
|
53
|
-
const err =
|
|
54
|
-
throw new Error(`Invalid layout JSON at ${layoutPath}: ${err.message}
|
|
52
|
+
layoutDoc = parseJxDocument(readFileSync(layoutPath, "utf8"), layoutPath);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const err = error as Error;
|
|
55
|
+
throw new Error(`Invalid layout JSON at ${layoutPath}: ${err.message}`, { cause: error });
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
// Check for nested layouts (layout inheriting from another layout)
|
|
@@ -93,8 +94,12 @@ export function resolveLayout(
|
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
// Preserve page-level metadata
|
|
96
|
-
if (pageDoc.$head)
|
|
97
|
-
|
|
97
|
+
if (pageDoc.$head) {
|
|
98
|
+
merged._pageHead = pageDoc.$head;
|
|
99
|
+
}
|
|
100
|
+
if (pageDoc.title) {
|
|
101
|
+
merged._pageTitle = pageDoc.title;
|
|
102
|
+
}
|
|
98
103
|
|
|
99
104
|
// Remove $layout from merged doc (already resolved)
|
|
100
105
|
delete merged.$layout;
|
|
@@ -117,17 +122,23 @@ export function resolveLayout(
|
|
|
117
122
|
* @param {(JxElement | string)[]} children - Page children to distribute
|
|
118
123
|
*/
|
|
119
124
|
function distributeSlots(node: JxElement, children: (JxElement | string)[]) {
|
|
120
|
-
if (!node || typeof node !== "object")
|
|
121
|
-
|
|
125
|
+
if (!node || typeof node !== "object") {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!Array.isArray(node.children)) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
122
131
|
|
|
123
132
|
// Collect named and default children
|
|
124
|
-
const named
|
|
125
|
-
const defaults: (JxElement | string)[] = []; //
|
|
133
|
+
const named = new Map<string, (JxElement | string)[]>(); // Slot name → children[]
|
|
134
|
+
const defaults: (JxElement | string)[] = []; // Children without a slot target
|
|
126
135
|
|
|
127
136
|
for (const child of children) {
|
|
128
137
|
if (child && typeof child === "object" && child.attributes?.slot) {
|
|
129
138
|
const slotName = child.attributes.slot as string;
|
|
130
|
-
if (!named.has(slotName))
|
|
139
|
+
if (!named.has(slotName)) {
|
|
140
|
+
named.set(slotName, []);
|
|
141
|
+
}
|
|
131
142
|
(named.get(slotName) as (JxElement | string)[]).push(child);
|
|
132
143
|
} else {
|
|
133
144
|
defaults.push(child);
|
|
@@ -150,8 +161,12 @@ function fillSlots(
|
|
|
150
161
|
named: Map<string, (JxElement | string)[]>,
|
|
151
162
|
defaults: (JxElement | string)[],
|
|
152
163
|
) {
|
|
153
|
-
if (!node || typeof node !== "object")
|
|
154
|
-
|
|
164
|
+
if (!node || typeof node !== "object") {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!Array.isArray(node.children)) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
155
170
|
|
|
156
171
|
const newChildren: (JxElement | string)[] = [];
|
|
157
172
|
|
|
@@ -162,12 +177,12 @@ function fillSlots(
|
|
|
162
177
|
if (slotName && named.has(slotName as string)) {
|
|
163
178
|
// Named slot — replace with matching children
|
|
164
179
|
newChildren.push(...(named.get(slotName as string) as (JxElement | string)[]));
|
|
165
|
-
named.delete(slotName as string); //
|
|
180
|
+
named.delete(slotName as string); // Consumed
|
|
166
181
|
} else if (!slotName && defaults.length > 0) {
|
|
167
182
|
// Default slot — replace with unassigned children
|
|
168
183
|
newChildren.push(...defaults);
|
|
169
184
|
// Don't clear defaults — only one default slot should exist,
|
|
170
|
-
//
|
|
185
|
+
// But if there are multiple, the first one wins
|
|
171
186
|
} else {
|
|
172
187
|
// No matching content — keep slot's fallback children
|
|
173
188
|
if (Array.isArray(child.children)) {
|
|
@@ -191,5 +206,5 @@ function fillSlots(
|
|
|
191
206
|
* @returns {JxDocument}
|
|
192
207
|
*/
|
|
193
208
|
function deepClone(obj: JxDocument) {
|
|
194
|
-
return
|
|
209
|
+
return structuredClone(obj);
|
|
195
210
|
}
|