@jxsuite/compiler 5.1.1 → 5.1.3

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