@jxsuite/compiler 2.0.7 → 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/src/site/i18n.ts CHANGED
@@ -12,7 +12,11 @@
12
12
  * @docs framework/site/i18n
13
13
  */
14
14
 
15
- import { canonicalizeLocale, localeDirection } from "@jxsuite/schema/locale";
15
+ import {
16
+ canonicalizeLocale,
17
+ localeDirection,
18
+ localeOfRoute as localeForRoute,
19
+ } from "@jxsuite/schema/locale";
16
20
  import type { ResolvedI18n, TextDirection } from "@jxsuite/schema/locale";
17
21
 
18
22
  /*
@@ -26,30 +30,10 @@ import type { ResolvedI18n, TextDirection } from "@jxsuite/schema/locale";
26
30
  */
27
31
  export { resolveI18n } from "@jxsuite/schema/locale";
28
32
  export type { LocaleRouting, ResolvedI18n } from "@jxsuite/schema/locale";
29
-
30
- /**
31
- * The locale a URL belongs to.
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
- }
33
+ /* `localeOfRoute` lives beside `resolveI18n` in `@jxsuite/schema/locale` for the reason that one
34
+ does: the studio and the cloud preview both have to answer "what language is this URL" and
35
+ neither can import this package. Re-exported so nothing in the build had to change its import. */
36
+ export { localeOfRoute } from "@jxsuite/schema/locale";
53
37
 
54
38
  /**
55
39
  * Routes sitting outside the locale tree under `prefix-always`.
@@ -239,7 +223,7 @@ export function translationSets(
239
223
  if (route.translationKey !== undefined) {
240
224
  declared.add(route.urlPattern);
241
225
  }
242
- const locale = localeOfRoute(route.urlPattern, i18n);
226
+ const locale = localeForRoute(route.urlPattern, i18n);
243
227
  if (locale === null) {
244
228
  continue;
245
229
  }
@@ -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 (widths.length === 0 || !widths.includes(meta.width)) {
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(node, config, projectRoot, cache, metaCache, imageRefs, mounts);
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(child, config, projectRoot, cache, metaCache, imageRefs, mounts);
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 { dims, sources: wrap ? available : [], srcset: available[0]?.srcset ?? "" };
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
- attrs.sizes ??= config.sizes;
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, config.sizes);
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: { sizes: inheritedSizes, srcset: source.srcset, type: source.type },
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
- * Layout-resolver.js — Layout loading and slot distribution at compile time
2
+ * The build's filesystem half of layout resolution.
3
3
  *
4
- * Resolves $layout references, loads layout JSON files, and distributes page content into layout
5
- * <slot> elements. This is the compile-time equivalent of the runtime's distributeSlots()
6
- * algorithm.
7
- *
8
- * Per site-architecture spec §5:
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 type { JxDocument, JxElement } from "@jxsuite/schema/types";
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, wrapping the page content in the layout structure.
35
+ * Resolve a page's layout against a project on disk.
23
36
  *
24
- * @param {JxDocument} pageDoc - The raw page JSON document
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
- // Determine which layout to use
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
- // Sort: static routes first, then by specificity (more segments = more specific)
180
- routes.sort((a, b) => {
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
- // Remove the source extension
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;