@jxsuite/compiler 2.0.8 → 3.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/README.md +8 -8
- package/dist/cli.js +193 -189
- package/dist/compiler.js +110 -110
- package/package.json +11 -10
- package/src/shared.ts +60 -35
- 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/i18n.ts +10 -26
- 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/site-build.ts +102 -32
- package/src/targets/compile-element.ts +51 -1
- package/src/types.ts +5 -11
- package/src/site/head-merger.ts +0 -251
|
@@ -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,
|
|
@@ -1,210 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The build's filesystem half of layout resolution.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* - Layouts are JSON files in the layouts/ directory
|
|
11
|
-
* - Pages reference layouts via "$layout": "./layouts/base.json"
|
|
12
|
-
* - The page's children are distributed into the layout's <slot> elements
|
|
13
|
-
* - Named slots use attributes.slot on page children
|
|
4
|
+
* The rule — which layout a page uses, how its children land in the layout's slots, which
|
|
5
|
+
* properties the page extends onto it — is `@jxsuite/site/layout`, because the studio's canvas and
|
|
6
|
+
* the cloud's live preview have to reach the same answer and neither can import this package. What
|
|
7
|
+
* is left here is the part that genuinely needs a disk: turning `./layouts/base.json` into bytes,
|
|
8
|
+
* and saying which file was wrong when it is.
|
|
14
9
|
*/
|
|
15
10
|
|
|
16
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
17
12
|
import { parseJxDocument } from "@jxsuite/schema/parse";
|
|
18
13
|
import { resolve } from "node:path";
|
|
19
|
-
import
|
|
14
|
+
import { resolveLayout as resolveLayoutWith } from "@jxsuite/site/layout";
|
|
15
|
+
import type { LayoutLoader } from "@jxsuite/site/layout";
|
|
16
|
+
import type { JxDocument } from "@jxsuite/schema/types";
|
|
17
|
+
|
|
18
|
+
/** A {@link LayoutLoader} over the project's own directory. */
|
|
19
|
+
export function nodeLayoutLoader(projectRoot: string): LayoutLoader {
|
|
20
|
+
return (layoutRef: string) => {
|
|
21
|
+
const layoutPath = resolve(projectRoot, layoutRef);
|
|
22
|
+
if (!existsSync(layoutPath)) {
|
|
23
|
+
throw new Error(`Layout not found: ${layoutRef} (resolved to ${layoutPath})`);
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
return parseJxDocument(readFileSync(layoutPath, "utf8"), layoutPath);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
const err = error as Error;
|
|
29
|
+
throw new Error(`Invalid layout JSON at ${layoutPath}: ${err.message}`, { cause: error });
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
20
33
|
|
|
21
34
|
/**
|
|
22
|
-
* Resolve a page's layout
|
|
35
|
+
* Resolve a page's layout against a project on disk.
|
|
23
36
|
*
|
|
24
|
-
* @param {JxDocument} pageDoc - The raw page
|
|
37
|
+
* @param {JxDocument} pageDoc - The raw page document
|
|
25
38
|
* @param {Record<string, unknown>} projectConfig - Site configuration (for defaults.layout)
|
|
26
39
|
* @param {string} projectRoot - Project root directory
|
|
27
|
-
* @returns {JxDocument} The merged document (layout wrapping page content)
|
|
40
|
+
* @returns {Promise<JxDocument>} The merged document (layout wrapping page content)
|
|
28
41
|
*/
|
|
29
42
|
export function resolveLayout(
|
|
30
43
|
pageDoc: JxDocument,
|
|
31
44
|
projectConfig: Record<string, unknown>,
|
|
32
45
|
projectRoot: string,
|
|
33
|
-
) {
|
|
34
|
-
|
|
35
|
-
const layoutRef =
|
|
36
|
-
pageDoc.$layout ?? (projectConfig.defaults as Record<string, unknown>)?.layout ?? null;
|
|
37
|
-
|
|
38
|
-
if (!layoutRef) {
|
|
39
|
-
// No layout — return page as-is
|
|
40
|
-
return pageDoc;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Load the layout file
|
|
44
|
-
const layoutPath = resolve(projectRoot, layoutRef as string);
|
|
45
|
-
if (!existsSync(layoutPath)) {
|
|
46
|
-
throw new Error(`Layout not found: ${layoutRef} (resolved to ${layoutPath})`);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** @type {JxDocument} */
|
|
50
|
-
let layoutDoc;
|
|
51
|
-
try {
|
|
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 });
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Check for nested layouts (layout inheriting from another layout)
|
|
59
|
-
if (layoutDoc.$layout) {
|
|
60
|
-
layoutDoc = resolveLayout(layoutDoc, projectConfig, projectRoot);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Distribute page children into layout slots
|
|
64
|
-
const rawChildren = pageDoc.children ?? [];
|
|
65
|
-
const pageChildren =
|
|
66
|
-
typeof rawChildren === "string"
|
|
67
|
-
? [rawChildren]
|
|
68
|
-
: Array.isArray(rawChildren)
|
|
69
|
-
? rawChildren
|
|
70
|
-
: ([] as (JxElement | string)[]);
|
|
71
|
-
const merged = deepClone(layoutDoc);
|
|
72
|
-
|
|
73
|
-
distributeSlots(merged, pageChildren);
|
|
74
|
-
|
|
75
|
-
// Merge page-level properties onto the resolved document
|
|
76
|
-
// Page state extends layout state
|
|
77
|
-
if (pageDoc.state) {
|
|
78
|
-
merged.state = { ...merged.state, ...pageDoc.state };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Page $media extends layout $media
|
|
82
|
-
if (pageDoc.$media) {
|
|
83
|
-
merged.$media = { ...merged.$media, ...pageDoc.$media };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Page style extends layout style
|
|
87
|
-
if (pageDoc.style) {
|
|
88
|
-
merged.style = { ...merged.style, ...pageDoc.style };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Page attributes extend layout attributes
|
|
92
|
-
if (pageDoc.attributes) {
|
|
93
|
-
merged.attributes = { ...merged.attributes, ...pageDoc.attributes };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Preserve page-level metadata
|
|
97
|
-
if (pageDoc.$head) {
|
|
98
|
-
merged._pageHead = pageDoc.$head;
|
|
99
|
-
}
|
|
100
|
-
if (pageDoc.title) {
|
|
101
|
-
merged._pageTitle = pageDoc.title;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Remove $layout from merged doc (already resolved)
|
|
105
|
-
delete merged.$layout;
|
|
106
|
-
|
|
107
|
-
return merged;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Distribute children into <slot> elements within a layout document tree. This is the compile-time
|
|
112
|
-
* equivalent of the runtime's distributeSlots().
|
|
113
|
-
*
|
|
114
|
-
* Algorithm:
|
|
115
|
-
*
|
|
116
|
-
* 1. Find all <slot> elements in the layout tree
|
|
117
|
-
* 2. For each child with attributes.slot, distribute to the matching named slot
|
|
118
|
-
* 3. Remaining children go into the default (unnamed) slot
|
|
119
|
-
* 4. Replace each <slot> element with its distributed children
|
|
120
|
-
*
|
|
121
|
-
* @param {JxElement} node - Layout document tree (mutated in place)
|
|
122
|
-
* @param {(JxElement | string)[]} children - Page children to distribute
|
|
123
|
-
*/
|
|
124
|
-
function distributeSlots(node: JxElement, children: (JxElement | string)[]) {
|
|
125
|
-
if (!node || typeof node !== "object") {
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
if (!Array.isArray(node.children)) {
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Collect named and default children
|
|
133
|
-
const named = new Map<string, (JxElement | string)[]>(); // Slot name → children[]
|
|
134
|
-
const defaults: (JxElement | string)[] = []; // Children without a slot target
|
|
135
|
-
|
|
136
|
-
for (const child of children) {
|
|
137
|
-
if (child && typeof child === "object" && child.attributes?.slot) {
|
|
138
|
-
const slotName = child.attributes.slot as string;
|
|
139
|
-
if (!named.has(slotName)) {
|
|
140
|
-
named.set(slotName, []);
|
|
141
|
-
}
|
|
142
|
-
(named.get(slotName) as (JxElement | string)[]).push(child);
|
|
143
|
-
} else {
|
|
144
|
-
defaults.push(child);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Walk the tree and replace <slot> elements
|
|
149
|
-
fillSlots(node, named, defaults);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Recursively walk the tree and replace <slot> elements with distributed content.
|
|
154
|
-
*
|
|
155
|
-
* @param {JxElement} node
|
|
156
|
-
* @param {Map<string, (JxElement | string)[]>} named
|
|
157
|
-
* @param {(JxElement | string)[]} defaults
|
|
158
|
-
*/
|
|
159
|
-
function fillSlots(
|
|
160
|
-
node: JxElement,
|
|
161
|
-
named: Map<string, (JxElement | string)[]>,
|
|
162
|
-
defaults: (JxElement | string)[],
|
|
163
|
-
) {
|
|
164
|
-
if (!node || typeof node !== "object") {
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
if (!Array.isArray(node.children)) {
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const newChildren: (JxElement | string)[] = [];
|
|
172
|
-
|
|
173
|
-
for (const child of node.children) {
|
|
174
|
-
if (child && typeof child === "object" && child.tagName === "slot") {
|
|
175
|
-
const slotName = child.attributes?.name;
|
|
176
|
-
|
|
177
|
-
if (slotName && named.has(slotName as string)) {
|
|
178
|
-
// Named slot — replace with matching children
|
|
179
|
-
newChildren.push(...(named.get(slotName as string) as (JxElement | string)[]));
|
|
180
|
-
named.delete(slotName as string); // Consumed
|
|
181
|
-
} else if (!slotName && defaults.length > 0) {
|
|
182
|
-
// Default slot — replace with unassigned children
|
|
183
|
-
newChildren.push(...defaults);
|
|
184
|
-
// Don't clear defaults — only one default slot should exist,
|
|
185
|
-
// But if there are multiple, the first one wins
|
|
186
|
-
} else {
|
|
187
|
-
// No matching content — keep slot's fallback children
|
|
188
|
-
if (Array.isArray(child.children)) {
|
|
189
|
-
newChildren.push(...child.children);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
} else {
|
|
193
|
-
// Not a slot — recurse into it
|
|
194
|
-
fillSlots(child as JxElement, named, defaults);
|
|
195
|
-
newChildren.push(child);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
node.children = newChildren;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Deep clone a JSON-serializable object.
|
|
204
|
-
*
|
|
205
|
-
* @param {JxDocument} obj
|
|
206
|
-
* @returns {JxDocument}
|
|
207
|
-
*/
|
|
208
|
-
function deepClone(obj: JxDocument) {
|
|
209
|
-
return structuredClone(obj);
|
|
46
|
+
): Promise<JxDocument> {
|
|
47
|
+
return resolveLayoutWith(pageDoc, projectConfig, nodeLayoutLoader(projectRoot));
|
|
210
48
|
}
|
|
@@ -9,10 +9,16 @@
|
|
|
9
9
|
* pages/about/index.json → /about pages/blog/[slug].json → /blog/:slug (dynamic)
|
|
10
10
|
* pages/docs/[...path].json → /docs/* (catch-all) pages/_component.json → NOT routed (underscore
|
|
11
11
|
* prefix)
|
|
12
|
+
*
|
|
13
|
+
* The conventions themselves live in `@jxsuite/site/routes`, not here. The studio and the cloud
|
|
14
|
+
* preview both have to answer "what URL is this file" and neither can import this package — its
|
|
15
|
+
* dependency graph carries `sharp` and `esbuild` — so what remains in this file is the part that
|
|
16
|
+
* genuinely needs a filesystem: the walk, the `$layout` peek, and `$paths` expansion.
|
|
12
17
|
*/
|
|
13
18
|
|
|
14
19
|
import { readFileSync, readdirSync } from "node:fs";
|
|
15
20
|
import { parseJxDocument } from "@jxsuite/schema/parse";
|
|
21
|
+
import { compareRoutes, fileToRoute as routeShape } from "@jxsuite/site/routes";
|
|
16
22
|
import { extname, join, relative, resolve } from "node:path";
|
|
17
23
|
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
18
24
|
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
@@ -176,16 +182,8 @@ export async function discoverPages(pagesDir: string, registry?: FormatRegistry)
|
|
|
176
182
|
const pageExtensions = new Set([".json", ...(registry?.documentExtensions("page") ?? [])]);
|
|
177
183
|
await walkDir(pagesDir, pagesDir, routes, pageExtensions, registry);
|
|
178
184
|
|
|
179
|
-
//
|
|
180
|
-
routes.sort(
|
|
181
|
-
if (a.isDynamic !== b.isDynamic) {
|
|
182
|
-
return a.isDynamic ? 1 : -1;
|
|
183
|
-
}
|
|
184
|
-
if (a.isCatchAll !== b.isCatchAll) {
|
|
185
|
-
return a.isCatchAll ? 1 : -1;
|
|
186
|
-
}
|
|
187
|
-
return a.urlPattern.localeCompare(b.urlPattern);
|
|
188
|
-
});
|
|
185
|
+
// Static routes first, then dynamic, then catch-all (spec §4.4) — the shared ordering.
|
|
186
|
+
routes.sort(compareRoutes);
|
|
189
187
|
|
|
190
188
|
return routes;
|
|
191
189
|
}
|
|
@@ -248,45 +246,7 @@ async function walkDir(
|
|
|
248
246
|
* @returns {Promise<Route>}
|
|
249
247
|
*/
|
|
250
248
|
async function fileToRoute(relativePath: string, absolutePath: string, registry?: FormatRegistry) {
|
|
251
|
-
|
|
252
|
-
const ext = extname(relativePath);
|
|
253
|
-
let urlPath = ext ? relativePath.slice(0, -ext.length) : relativePath;
|
|
254
|
-
|
|
255
|
-
// Normalize path separators
|
|
256
|
-
urlPath = urlPath.split("\\").join("/");
|
|
257
|
-
|
|
258
|
-
// Index files map to their parent directory
|
|
259
|
-
if (urlPath.endsWith("/index")) {
|
|
260
|
-
urlPath = urlPath.slice(0, -6) || "/";
|
|
261
|
-
} else if (urlPath === "index") {
|
|
262
|
-
urlPath = "/";
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Ensure leading slash
|
|
266
|
-
if (!urlPath.startsWith("/")) {
|
|
267
|
-
urlPath = `/${urlPath}`;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Extract parameters from bracket syntax
|
|
271
|
-
const params: string[] = [];
|
|
272
|
-
let isDynamic = false;
|
|
273
|
-
let isCatchAll = false;
|
|
274
|
-
|
|
275
|
-
// Convert [param] → :param and [...param] → *
|
|
276
|
-
const urlPattern = urlPath.replaceAll(
|
|
277
|
-
/\[\.\.\.(\w+)\]|\[(\w+)\]/g,
|
|
278
|
-
(_match: string, spread: string, named: string) => {
|
|
279
|
-
if (spread) {
|
|
280
|
-
isCatchAll = true;
|
|
281
|
-
isDynamic = true;
|
|
282
|
-
params.push(spread);
|
|
283
|
-
return "*";
|
|
284
|
-
}
|
|
285
|
-
isDynamic = true;
|
|
286
|
-
params.push(named);
|
|
287
|
-
return `:${named}`;
|
|
288
|
-
},
|
|
289
|
-
);
|
|
249
|
+
const { isCatchAll, isDynamic, params, urlPattern } = routeShape(relativePath);
|
|
290
250
|
|
|
291
251
|
// Peek at the page to extract $layout if present
|
|
292
252
|
let $layout: string | null = null;
|