@jxsuite/compiler 5.1.2 → 6.0.1

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 +218 -346
  2. package/dist/compiler.js +130 -258
  3. package/package.json +11 -10
  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 +693 -329
  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 +16 -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 +421 -279
  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 +156 -111
  37. package/src/targets/compile-element.ts +227 -198
  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
@@ -20,7 +20,15 @@ import {
20
20
  } from "node:fs";
21
21
  import { createHash } from "node:crypto";
22
22
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
23
- import { isMappedArray, isRef } from "@jxsuite/schema/guards";
23
+ import {
24
+ isJsonObject,
25
+ isMappedArray,
26
+ isNonNullObject,
27
+ isRef,
28
+ isString,
29
+ isTemplateString,
30
+ } from "@jxsuite/schema/guards";
31
+ import { errorMessage } from "@jxsuite/schema/parse";
24
32
  import {
25
33
  isNpmSpecifier,
26
34
  npmAssetPath,
@@ -55,16 +63,19 @@ import {
55
63
  } from "./headers-emitter.ts";
56
64
  import { buildProjectExtensionRegistry } from "./format-host.ts";
57
65
  import { loadProjectSections } from "./project-sections.ts";
66
+ import type { ProjectSections } from "./project-sections.ts";
58
67
  import { collectAssetRefs, copyMountedAssets, loadAssetMounts } from "./asset-mounts.ts";
59
68
  import type { AssetMount } from "@jxsuite/schema/asset-paths";
60
69
  import type { FormatEntry, FormatRegistry } from "@jxsuite/schema/format-registry";
61
70
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
62
71
  import { resolveLayout } from "./layout-resolver.ts";
63
- import { mergeHead, renderHead } from "@jxsuite/site/head-merger";
72
+ import { mergeHead, renderHead, withPageDescription } from "@jxsuite/site/head-merger";
64
73
  import { unregisteredHeadRelations } from "./link-relations.ts";
65
74
  import { injectContext } from "./context-injection.ts";
66
75
  import { compile, compileServer, compileSiteServer } from "../compiler.ts";
76
+ import type { CompileOptions } from "../compiler.ts";
67
77
  import { compileElement } from "../targets/compile-element.ts";
78
+ import type { CompileElementOptions } from "../targets/compile-element.ts";
68
79
  import type { SiteConnectorSpec, SiteMountSpec } from "../targets/compile-server.ts";
69
80
  import {
70
81
  buildComponentCSS,
@@ -73,19 +84,22 @@ import {
73
84
  collectServerEntries,
74
85
  collectStyles,
75
86
  colorSchemePrePaintScript,
87
+ createSlotFill,
88
+ definitionHasSlot,
76
89
  escapeStyleText,
77
90
  evaluateStaticTemplate,
78
91
  isComponentFullyStatic,
79
92
  isSingleExpression,
80
- isTemplateString,
81
93
  liftPropsAttributes,
94
+ nextSlotId,
82
95
  preRenderComponentHtml,
83
96
  pureSchemeOf,
84
97
  renderStaticNode,
85
98
  resolveHostStyle,
86
99
  resolveRefValue,
87
100
  } from "../shared.ts";
88
- import type { ComponentPrerenderContext } from "../shared.ts";
101
+ import type { ComponentPrerenderContext, StaticChild } from "../shared.ts";
102
+ import { SLOT_ATTR } from "@jxsuite/runtime";
89
103
  import { resolvePrototypes } from "./prototype-resolver.ts";
90
104
  import { transformImageNodes } from "./image-transform.ts";
91
105
  import { collectCspSources, emptyCspSources } from "./csp.ts";
@@ -131,19 +145,32 @@ import { getImageCacheDir, loadCache, saveCache } from "./image-cache.ts";
131
145
  import type { ImageConfig } from "./image-optimizer.ts";
132
146
  import type { ImageMetaCache } from "./image-transform.ts";
133
147
  import type {
148
+ JsonObject,
134
149
  JsonValue,
135
150
  JxAttributeValue,
136
151
  JxElement,
137
152
  JxHeadEntry,
138
153
  JxMappedArray,
139
- JxMutableNode,
140
154
  JxStateDefinition,
141
155
  JxStyle,
142
156
  ProjectConfig,
143
157
  } from "@jxsuite/schema/types";
144
- import type { SiteRoute } from "../types.ts";
158
+ import type { JxScope } from "@jxsuite/runtime/types";
145
159
  import type { CacheManifest } from "./image-cache.js";
146
160
 
161
+ declare module "@jxsuite/schema/format-registry" {
162
+ /**
163
+ * The template hooks a site build adds to an export sidecar's `serialize` options
164
+ * (specs/extensions.md §7). The format builds a document's scope with `buildScope` and hands that
165
+ * scope back to `evaluateTemplate` unread, so both are typed by the compiler's static scope.
166
+ */
167
+ interface SerializeExportOptions {
168
+ buildScope?: (state: Record<string, JxStateDefinition>) => JxScope;
169
+ /** Evaluate one template string against that scope; `undefined` keeps it as written. */
170
+ evaluateTemplate?: (value: string, scope: JxScope) => JxScope[string] | undefined;
171
+ }
172
+ }
173
+
147
174
  /**
148
175
  * Build an entire Jx site from a project directory.
149
176
  *
@@ -289,7 +316,7 @@ export async function buildSite(
289
316
  sidecarBundles.set(assetPath, { entryPath, specifier });
290
317
  return assetPath;
291
318
  } catch (error) {
292
- errors.push(`Sidecar "${specifier}": ${(error as Error).message}`);
319
+ errors.push(`Sidecar "${specifier}": ${errorMessage(error)}`);
293
320
  return specifier;
294
321
  }
295
322
  };
@@ -304,7 +331,7 @@ export async function buildSite(
304
331
  } catch (error) {
305
332
  // A missing dependency is a hard error, like an unresolvable sidecar: the alternative is a
306
333
  // Page that looks fine in dev and loses its stylesheet on deploy.
307
- errors.push(`$head specifier "${specifier}": ${(error as Error).message}`);
334
+ errors.push(`$head specifier "${specifier}": ${errorMessage(error)}`);
308
335
  return specifier;
309
336
  }
310
337
  };
@@ -328,8 +355,8 @@ export async function buildSite(
328
355
  if (!entry.project) {
329
356
  return true;
330
357
  }
331
- const value = projectConfig[entry.project.key] as Record<string, unknown> | undefined;
332
- return value !== undefined && typeof value === "object" && Object.keys(value).length > 0;
358
+ const value = projectConfig[entry.project.key];
359
+ return isNonNullObject(value) && Object.keys(value).length > 0;
333
360
  });
334
361
  if (activeMounts.length > 0 && !projectConfig.build.adapter) {
335
362
  const sections = activeMounts
@@ -386,11 +413,9 @@ export async function buildSite(
386
413
  const assetRefs = new Set<string>();
387
414
 
388
415
  // ── 4. Expand dynamic routes ────────────────────────────────────────────
389
- const extensionHead = registry
390
- ? await collectExtensionHead(registry, projectConfig, projectRoot)
391
- : [];
416
+ const extensionHead = await collectExtensionHead(registry, projectConfig, projectRoot);
392
417
 
393
- const routes = await expandDynamicRoutes(
418
+ const expandedRoutes = await expandDynamicRoutes(
394
419
  staticRoutes,
395
420
  projectRoot,
396
421
  sections,
@@ -404,10 +429,13 @@ export async function buildSite(
404
429
  * of one entry share an id (§13.3), so a page that asked for `hello` without saying in which
405
430
  * language got whichever translation was loaded first — the English copy under a French route,
406
431
  * silently, on a page whose `<html lang>` said otherwise.
432
+ *
433
+ * Stamped onto each route object in place, so every holder of a route (the extension `emit`
434
+ * capability receives this table) sees the same `locale` member.
407
435
  */
408
- for (const route of routes) {
409
- (route as { locale?: string | null }).locale = localeOfRoute(route.urlPattern, i18n);
410
- }
436
+ const routes = expandedRoutes.map((route) =>
437
+ Object.assign(route, { locale: localeOfRoute(route.urlPattern, i18n) }),
438
+ );
411
439
  log(` ${routes.length} route(s) after expansion`);
412
440
 
413
441
  let fileCount = 0;
@@ -443,19 +471,31 @@ export async function buildSite(
443
471
  }
444
472
  }
445
473
 
474
+ /*
475
+ * A component is a JavaScript module the page loads, and the page rewrite (`rewriteHtmlBase`) never
476
+ * reads it: the `/assets/` URLs the compiler writes into it for a `$src` function (a static import,
477
+ * or the dynamic one a `$lazy` function becomes) are re-rooted here, or they name files a site
478
+ * served from a path does not have.
479
+ */
480
+ const componentBase = siteBasePath(projectConfig.url);
481
+
446
482
  for (const file of componentFiles) {
447
483
  try {
448
484
  const componentPath = resolve(componentsDir, file);
449
- const result = await compileElement(componentPath, {
450
- ...(projectConfig.$media ? { $media: projectConfig.$media } : {}),
485
+ const elementOptions: CompileElementOptions = {
451
486
  formats: formatRegistry,
452
487
  resolveElementPath: (refPath: string, currentDir: string | null) => {
453
488
  const depPath = currentDir ? resolve(currentDir, refPath) : resolve(refPath);
454
489
  const depTag = componentTagByPath.get(depPath);
455
490
  return depTag ? `./${depTag}.js` : refPath.replace(/\.json$/, ".js");
456
491
  },
457
- rewriteSrc: rewriteSidecarSrc,
458
- });
492
+ rewriteSrc: (specifier: string, docDir: string | null) =>
493
+ withBase(componentBase, rewriteSidecarSrc(specifier, docDir)),
494
+ };
495
+ if (projectConfig.$media) {
496
+ elementOptions.$media = projectConfig.$media;
497
+ }
498
+ const result = await compileElement(componentPath, elementOptions);
459
499
  for (const f of result.files) {
460
500
  // Name the output after the tag. `injectComponentScripts` emits
461
501
  // `<script src="/components/<tag>.js">` and the CSS sidecar is written as `<tag>.css`, so
@@ -511,9 +551,9 @@ export async function buildSite(
511
551
  }
512
552
  }
513
553
  } catch (error) {
514
- const err = error as Error;
515
- errors.push(`Error compiling component ${file}: ${err.message}`);
516
- console.error(`Error compiling component ${file}: ${err.message}`);
554
+ const message = errorMessage(error);
555
+ errors.push(`Error compiling component ${file}: ${message}`);
556
+ console.error(`Error compiling component ${file}: ${message}`);
517
557
  }
518
558
  }
519
559
  log(
@@ -579,15 +619,12 @@ export async function buildSite(
579
619
  const declaredKeys =
580
620
  i18n === null
581
621
  ? new Map<string, string>()
582
- : await readTranslationKeys(
583
- concreteRoutes as { sourcePath: string; urlPattern: string }[],
584
- formatRegistry,
585
- );
622
+ : await readTranslationKeys(concreteRoutes, formatRegistry);
586
623
  const keyedRoutes = concreteRoutes.map((r) => ({
587
624
  translationKey: declaredKeys.get(r.urlPattern),
588
625
  urlPattern: r.urlPattern,
589
626
  }));
590
- const alternateMap = localeAlternates(keyedRoutes, i18n, siteUrl ?? "", trailingSlash);
627
+ const alternateMap = localeAlternates(keyedRoutes, i18n, siteUrl, trailingSlash);
591
628
  /*
592
629
  * The same sets, site-absolute, for `$page.alternates`. Computed from the whole table for the
593
630
  * same reason the alternates are — and separately from them because a switcher must work in a
@@ -683,7 +720,7 @@ export async function buildSite(
683
720
  try {
684
721
  log(` Compiling ${route.urlPattern} ...`);
685
722
  const result = await compilePage(
686
- route as unknown as SiteRoute,
723
+ route,
687
724
  projectConfig,
688
725
  projectRoot,
689
726
  sections,
@@ -773,7 +810,7 @@ export async function buildSite(
773
810
  * template was, which is precisely the signal `<lastmod>` exists to give.
774
811
  */
775
812
  lastmod:
776
- typeof route.sourceMtime === "string" && route.sourceMtime !== ""
813
+ isString(route.sourceMtime) && route.sourceMtime !== ""
777
814
  ? route.sourceMtime
778
815
  : toRfc3339(statSync(route.sourcePath).mtime),
779
816
  loc: siteAbsoluteUrl(servedRoute(route.urlPattern, trailingSlash), siteUrl),
@@ -793,25 +830,26 @@ export async function buildSite(
793
830
  continue;
794
831
  }
795
832
  try {
796
- const content = (await fmt.call("serialize", result.doc, {
833
+ const content = await fmt.call("serialize", result.doc, {
797
834
  buildScope: (state: Record<string, JxStateDefinition>) =>
798
835
  buildInitialScope(state, null),
799
836
  componentDefs,
800
- evaluateTemplate: (value: string, scope: Record<string, unknown>) => {
837
+ evaluateTemplate: (value: string, scope: JxScope) => {
801
838
  if (!isTemplateString(value)) {
802
839
  return;
803
840
  }
804
841
  return evaluateStaticTemplate(value, scope) ?? value;
805
842
  },
806
843
  mode: "export",
807
- })) as string;
844
+ });
808
845
  if (content) {
809
846
  const sidecarPath = outPath.replace(/\.html$/, fmt.extensions[0]!);
810
847
  sidecars.push({ content, path: sidecarPath, type: fmt.mediaTypeEssence });
811
848
  }
812
849
  } catch (error) {
813
- const err = error as Error;
814
- errors.push(`Error exporting ${fmt.name} for ${route.urlPattern}: ${err.message}`);
850
+ errors.push(
851
+ `Error exporting ${fmt.name} for ${route.urlPattern}: ${errorMessage(error)}`,
852
+ );
815
853
  }
816
854
  }
817
855
  for (const sidecar of sidecars) {
@@ -847,8 +885,7 @@ export async function buildSite(
847
885
  fileCount += 1;
848
886
  }
849
887
  } catch (error) {
850
- const err = error as Error;
851
- const msg = `Error compiling ${route.urlPattern}: ${err.message}`;
888
+ const msg = `Error compiling ${route.urlPattern}: ${errorMessage(error)}`;
852
889
  errors.push(msg);
853
890
  console.error(msg);
854
891
  }
@@ -920,7 +957,7 @@ export async function buildSite(
920
957
  fileCount += 1;
921
958
  log(` Generated dist/${workerName} (bundled, ${deduped.size} server function(s))`);
922
959
  } catch (error) {
923
- const msg = `Error bundling dist/${workerName}: ${(error as Error).message}`;
960
+ const msg = `Error bundling dist/${workerName}: ${errorMessage(error)}`;
924
961
  errors.push(msg);
925
962
  console.error(msg);
926
963
  }
@@ -987,7 +1024,7 @@ export async function buildSite(
987
1024
  fileCount += 1;
988
1025
  log(` ${bundle.specifier} → ${assetPath}`);
989
1026
  } catch (error) {
990
- const msg = `Error bundling sidecar "${bundle.specifier}": ${(error as Error).message}`;
1027
+ const msg = `Error bundling sidecar "${bundle.specifier}": ${errorMessage(error)}`;
991
1028
  errors.push(msg);
992
1029
  console.error(msg);
993
1030
  }
@@ -1012,9 +1049,7 @@ export async function buildSite(
1012
1049
  fileCount += 1;
1013
1050
  log(` ${specifiers.length} element(s) → ${assetPath}`);
1014
1051
  } catch (error) {
1015
- const msg = `Error bundling npm $elements [${specifiers.join(", ")}]: ${
1016
- (error as Error).message
1017
- }`;
1052
+ const msg = `Error bundling npm $elements [${specifiers.join(", ")}]: ${errorMessage(error)}`;
1018
1053
  errors.push(msg);
1019
1054
  console.error(msg);
1020
1055
  }
@@ -1044,23 +1079,23 @@ export async function buildSite(
1044
1079
  // Runs later and can shadow emitted files — same semantics as sitemap.xml.
1045
1080
  for (const entry of registry.emitters()) {
1046
1081
  const sectionKey = entry.project?.key;
1047
- const sectionValue = sectionKey
1048
- ? (projectConfig as unknown as Record<string, unknown>)[sectionKey]
1049
- : undefined;
1082
+ const sectionValue = sectionKey ? projectConfig[sectionKey] : undefined;
1050
1083
  if (
1051
1084
  sectionKey &&
1052
1085
  (sectionValue == null ||
1053
- (typeof sectionValue === "object" && Object.keys(sectionValue).length === 0))
1086
+ (isNonNullObject(sectionValue) && Object.keys(sectionValue).length === 0))
1054
1087
  ) {
1055
1088
  continue; // Section owner with nothing declared — same gating as sections/mounts
1056
1089
  }
1057
1090
  try {
1058
- const emitted = (await entry.call("emit", sectionValue ?? null, {
1091
+ /* The emit contract (extensions.md §8.4) answers outDir-relative files, or null for none; each
1092
+ `path` is still checked for traversal below before anything is written. */
1093
+ const emitted = await entry.call("emit", sectionValue ?? null, {
1059
1094
  projectConfig,
1060
1095
  root: projectRoot,
1061
1096
  routes,
1062
1097
  sections,
1063
- })) as { path: string; content: string | Uint8Array }[] | null;
1098
+ });
1064
1099
  for (const file of emitted ?? []) {
1065
1100
  const target = resolve(outDir, file.path.replace(/^\//, ""));
1066
1101
  if (!target.startsWith(outDir + sep)) {
@@ -1072,7 +1107,7 @@ export async function buildSite(
1072
1107
  log(` ${entry.name} emitted ${file.path}`);
1073
1108
  }
1074
1109
  } catch (error) {
1075
- const msg = `Error in ${entry.name}.emit: ${(error as Error).message}`;
1110
+ const msg = `Error in ${entry.name}.emit: ${errorMessage(error)}`;
1076
1111
  errors.push(msg);
1077
1112
  console.error(msg);
1078
1113
  }
@@ -1170,9 +1205,12 @@ export async function buildSite(
1170
1205
  errors.push(error);
1171
1206
  console.error(error);
1172
1207
  }
1173
- writeFileSync(join(outDir, output.path as string), output.source, "utf8");
1174
- fileCount += 1;
1175
- log(sw === false ? " sw.js — tombstone (unregisters and clears)" : " sw.js");
1208
+ // `path` is null only when there is nothing to emit; both builders always name sw.js.
1209
+ if (output.path !== null) {
1210
+ writeFileSync(join(outDir, output.path), output.source, "utf8");
1211
+ fileCount += 1;
1212
+ log(sw === false ? " sw.js — tombstone (unregisters and clears)" : " sw.js");
1213
+ }
1176
1214
  }
1177
1215
  }
1178
1216
 
@@ -1229,8 +1267,11 @@ export async function buildSite(
1229
1267
  if (projectConfig.copy) {
1230
1268
  log("Copying mapped files...");
1231
1269
  for (const [src, dest] of Object.entries(projectConfig.copy)) {
1232
- const srcPath = resolve(projectRoot, src as string);
1233
- const destPath = resolve(outDir, dest as string);
1270
+ if (!isString(dest)) {
1271
+ throw new TypeError(`project.json copy["${src}"] must be a destination path string`);
1272
+ }
1273
+ const srcPath = resolve(projectRoot, src);
1274
+ const destPath = resolve(outDir, dest);
1234
1275
  mkdirSync(dirname(destPath), { recursive: true });
1235
1276
  cpSync(srcPath, destPath);
1236
1277
  }
@@ -1245,15 +1286,22 @@ export async function buildSite(
1245
1286
  return { errors, files: fileCount, routes: routes.length };
1246
1287
  }
1247
1288
 
1289
+ /** The route `compilePage` renders: a URL and the page document that produces it. */
1290
+ interface PageRoute {
1291
+ urlPattern: string;
1292
+ sourcePath: string;
1293
+ _pathParams?: Record<string, string>;
1294
+ }
1295
+
1248
1296
  /**
1249
1297
  * Compile a single page within the site build context.
1250
1298
  *
1251
1299
  * Pipeline: load JSON → resolve layout → inject context → merge head → compile
1252
1300
  *
1253
- * @param {SiteRoute} route
1301
+ * @param {PageRoute} route
1254
1302
  * @param {ProjectConfig} projectConfig
1255
1303
  * @param {string} projectRoot
1256
- * @param {Record<string, unknown>} [sections] - Extension-loaded project sections
1304
+ * @param {ProjectSections} sections - Extension-loaded project sections
1257
1305
  * @param {import("./image-cache.js").CacheManifest | null} [imageCache]
1258
1306
  * @param {Map<string, JxElement>} [componentDefs]
1259
1307
  * @param {ImageMetaCache | null} [imageMetaCache] - Set when images.service is "cloudflare"
@@ -1268,16 +1316,16 @@ export async function buildSite(
1268
1316
  * `rewriteNpmAsset`, was a function that could never run pretending to be a safety net.
1269
1317
  */
1270
1318
  async function compilePage(
1271
- route: SiteRoute,
1319
+ route: PageRoute,
1272
1320
  projectConfig: ResolvedProjectConfig,
1273
1321
  projectRoot: string,
1274
- sections: Record<string, unknown>,
1322
+ sections: ProjectSections,
1275
1323
  imageCache: CacheManifest | null,
1276
1324
  componentDefs: Map<string, JxElement>,
1277
1325
  imageMetaCache: ImageMetaCache | null,
1278
- formatRegistry: FormatRegistry | undefined,
1279
- registry: ExtensionRegistry | undefined,
1280
- rewriteSidecarSrc: ((specifier: string, docDir: string | null) => string) | undefined,
1326
+ formatRegistry: FormatRegistry,
1327
+ registry: ExtensionRegistry,
1328
+ rewriteSidecarSrc: (specifier: string, docDir: string | null) => string,
1281
1329
  assetMounts: readonly AssetMount[],
1282
1330
  extensionHead: readonly JxHeadEntry[],
1283
1331
  rewriteNpmAsset: (specifier: string) => string,
@@ -1291,14 +1339,21 @@ async function compilePage(
1291
1339
  const basePath = siteBasePath(projectConfig.url);
1292
1340
 
1293
1341
  // Load the raw page document (.json natively, other formats via the registry)
1294
- const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
1342
+ const pageDoc = await readPageDocument(route.sourcePath, formatRegistry);
1295
1343
 
1296
1344
  // Resolve layout (wraps page in layout with slot distribution)
1297
1345
  const layoutDoc = await resolveLayout(pageDoc, projectConfig, projectRoot);
1298
1346
 
1299
1347
  // Extract head arrays before they get lost in the merge
1300
- const pageHead = (pageDoc.$head ?? layoutDoc._pageHead ?? []) as JxHeadEntry[];
1301
- const layoutHead = (layoutDoc.$head ?? []) as JxHeadEntry[];
1348
+ /* SAFETY: `_pageHead` is written only by `resolveLayout`, which copies the page's `$head` (a
1349
+ JxHeadEntry[]) into it when the page has one. */
1350
+ const pageHead = withPageDescription(
1351
+ (pageDoc.$head ?? layoutDoc._pageHead ?? []) as JxHeadEntry[],
1352
+ pageDoc.description,
1353
+ );
1354
+ const layoutHead = layoutDoc.$head ?? [];
1355
+ /* SAFETY: `_pageTitle` is written only by `resolveLayout`, which copies the page's `title` (a
1356
+ string) into it when the page has one. */
1302
1357
  const pageTitle = (pageDoc.title ?? layoutDoc._pageTitle ?? null) as string | null;
1303
1358
 
1304
1359
  // Clean up internal properties
@@ -1315,32 +1370,31 @@ async function compilePage(
1315
1370
  */
1316
1371
  const language = pageLanguage({
1317
1372
  defaults: projectConfig.defaults,
1318
- pageDir: typeof pageDoc.$dir === "string" ? pageDoc.$dir : undefined,
1319
- pageLang: typeof pageDoc.$lang === "string" ? pageDoc.$lang : undefined,
1373
+ pageDir: isString(pageDoc.$dir) ? pageDoc.$dir : undefined,
1374
+ pageLang: isString(pageDoc.$lang) ? pageDoc.$lang : undefined,
1320
1375
  routeLocale: localeOfRoute(route.urlPattern, locale.i18n),
1321
1376
  });
1322
1377
 
1323
1378
  // Resolve generic $prototype entries via .class.json imports (and lower registry classes)
1324
1379
  await resolvePrototypes(layoutDoc, route, projectRoot, {
1325
1380
  config: projectConfig,
1381
+ registerBundle: (specifier: string) => {
1382
+ rewriteSidecarSrc(specifier, null);
1383
+ },
1384
+ registry,
1326
1385
  sections,
1327
- ...(registry === undefined ? {} : { registry }),
1328
- ...(rewriteSidecarSrc === undefined
1329
- ? {}
1330
- : {
1331
- registerBundle: (specifier: string) => {
1332
- rewriteSidecarSrc(specifier, null);
1333
- },
1334
- }),
1335
1386
  });
1336
1387
 
1337
1388
  // Build scope from resolved state so template strings in title/$head can be evaluated
1338
1389
  const scope = buildInitialScope(layoutDoc.state ?? {});
1339
1390
 
1340
1391
  // Determine the page title — resolve template strings against the scope
1341
- let title = pageTitle ?? projectConfig.name ?? "Jx Site";
1342
- if (typeof title === "string" && isTemplateString(title)) {
1343
- title = (evaluateStaticTemplate(title, scope) as string | null) ?? (title as string);
1392
+ let title = pageTitle ?? projectConfig.name;
1393
+ if (isTemplateString(title)) {
1394
+ /* SAFETY: the result stands in for the title text it was written as, and its one consumer,
1395
+ `mergeHead`, places it as the `<title>` element's only child, which `renderHead` joins into
1396
+ the markup as text, so a non-string result renders as its string form. */
1397
+ title = (evaluateStaticTemplate(title, scope) as string | null) ?? title;
1344
1398
  }
1345
1399
 
1346
1400
  // Resolve template strings in $head entries
@@ -1376,7 +1430,9 @@ async function compilePage(
1376
1430
  media: { ...projectConfig.$media, ...layoutDoc.$media },
1377
1431
  rules: [],
1378
1432
  };
1379
- expandComponents(layoutDoc, componentDefs, slotCss, projectConfig.defaults);
1433
+ // One counter per page, so every `data-jx-slots` id on it is unique (spec.md §8.5).
1434
+ const slotIds = { n: 0 };
1435
+ expandComponents(layoutDoc, componentDefs, slotCss, projectConfig.defaults, slotIds);
1380
1436
 
1381
1437
  // Strip resolved timing: "compiler" state entries — they're now baked into the tree
1382
1438
  // And keeping them would cause isDynamic() to misclassify the page as dynamic.
@@ -1393,12 +1449,7 @@ async function compilePage(
1393
1449
  if (key === "$site" || key === "$page") {
1394
1450
  continue;
1395
1451
  }
1396
- if (
1397
- def &&
1398
- typeof def === "object" &&
1399
- !Array.isArray(def) &&
1400
- (def as JxMutableNode).timing === "compiler"
1401
- ) {
1452
+ if (isJsonObject(def) && def.timing === "compiler") {
1402
1453
  delete layoutDoc.state[key];
1403
1454
  } else if (Array.isArray(def)) {
1404
1455
  strippable.add(key);
@@ -1408,7 +1459,7 @@ async function compilePage(
1408
1459
  let rescued = true;
1409
1460
  while (rescued) {
1410
1461
  rescued = false;
1411
- const surviving = { ...layoutDoc.state } as Record<string, unknown>;
1462
+ const surviving = { ...layoutDoc.state };
1412
1463
  for (const key of strippable) {
1413
1464
  delete surviving[key];
1414
1465
  }
@@ -1444,7 +1495,7 @@ async function compilePage(
1444
1495
  const resolvedSiteHead = [
1445
1496
  // The manifest link and theme colour are first-party and unconditional once declared, so they
1446
1497
  // Sit with the extension contributions: below the project's own $head, which still wins.
1447
- ...(manifestHeadEntries(projectConfig) as JxHeadEntry[]),
1498
+ ...manifestHeadEntries(projectConfig),
1448
1499
  ...swHead,
1449
1500
  ...extensionHead,
1450
1501
  ...resolveHeadBareSpecifiers(projectConfig.$head ?? [], rewriteNpmAsset),
@@ -1453,9 +1504,9 @@ async function compilePage(
1453
1504
  // Merge $head from site + layout + page
1454
1505
  const mergedHead = mergeHead(resolvedSiteHead, resolvedLayoutHead, resolvedPageHead, {
1455
1506
  title,
1456
- charset: projectConfig.defaults?.charset ?? "utf8",
1457
- ...(projectConfig.name != null && { siteName: projectConfig.name }),
1458
- ...(projectConfig.url != null && { siteUrl: projectConfig.url }),
1507
+ charset: projectConfig.defaults.charset ?? "utf8",
1508
+ siteName: projectConfig.name,
1509
+ siteUrl: projectConfig.url,
1459
1510
  ...(locale.alternates.length > 0 && { alternates: locale.alternates }),
1460
1511
  /*
1461
1512
  * The canonical link and og:url name the URL the host answers with the page: the route as
@@ -1484,40 +1535,47 @@ async function compilePage(
1484
1535
  * it is governed by `images.lazyLoad`, and declining to generate variants for an image says
1485
1536
  * nothing about when the browser should fetch it.
1486
1537
  */
1487
- if (projectConfig.images) {
1488
- await transformImageNodes(
1489
- layoutDoc,
1490
- projectConfig.images as ImageConfig,
1491
- projectRoot,
1492
- imageCache,
1493
- imageMetaCache ?? undefined,
1494
- assetMounts,
1495
- );
1496
- }
1538
+ /* SAFETY: `loadProjectConfig` spreads its `images` defaults (optimize, widths, formats, quality,
1539
+ sizes, lazyLoad) under the author's block, so every member the optimizer's config requires is
1540
+ present. */
1541
+ const imageConfig = projectConfig.images as ImageConfig;
1542
+ await transformImageNodes(
1543
+ layoutDoc,
1544
+ imageConfig,
1545
+ projectRoot,
1546
+ imageCache,
1547
+ imageMetaCache ?? undefined,
1548
+ assetMounts,
1549
+ );
1497
1550
 
1498
1551
  // Compile the document using the existing compiler
1499
- const result = await compile(layoutDoc, {
1552
+ const compileOptions: CompileOptions = {
1500
1553
  lang: language.lang,
1501
- /*
1502
- * The page-template tiers emit their own import map, and it defaulted to the CDN — so a page
1503
- * that reached compile-static/compile-client kept loading its runtime from esm.sh even after
1504
- * the self-hosting landed, because `injectComponentScripts` sees a map already present and
1505
- * declines to add its own. Both halves have to name the same URLs.
1506
- */
1507
- ...(runtimeImports["@vue/reactivity"] === undefined
1508
- ? {}
1509
- : { reactivitySrc: runtimeImports["@vue/reactivity"] }),
1510
- ...(runtimeImports["lit-html"] === undefined ? {} : { litHtmlSrc: runtimeImports["lit-html"] }),
1511
1554
  prePaintScheme: false, // Injected via the merged <head> above, not the target template
1512
1555
  projectStyle: projectConfig.style ?? null,
1513
- ...(rewriteSidecarSrc === undefined
1514
- ? {}
1515
- : {
1516
- rewriteSrc: (specifier: string) =>
1517
- rewriteSidecarSrc(specifier, dirname(route.sourcePath as string)),
1518
- }),
1556
+ /*
1557
+ * The page's own module (`app.js`) is JavaScript the page loads, like a component: the page
1558
+ * rewrite does not read it, so its `/assets/` imports are re-rooted on the site base here.
1559
+ */
1560
+ rewriteSrc: (specifier: string) =>
1561
+ withBase(basePath, rewriteSidecarSrc(specifier, dirname(route.sourcePath))),
1519
1562
  title,
1520
- });
1563
+ };
1564
+ /*
1565
+ * The page-template tiers emit their own import map, and it defaulted to the CDN — so a page
1566
+ * that reached compile-static/compile-client kept loading its runtime from esm.sh even after
1567
+ * the self-hosting landed, because `injectComponentScripts` sees a map already present and
1568
+ * declines to add its own. Both halves have to name the same URLs.
1569
+ */
1570
+ const reactivitySrc = runtimeImports["@vue/reactivity"];
1571
+ if (reactivitySrc !== undefined) {
1572
+ compileOptions.reactivitySrc = reactivitySrc;
1573
+ }
1574
+ const litHtmlSrc = runtimeImports["lit-html"];
1575
+ if (litHtmlSrc !== undefined) {
1576
+ compileOptions.litHtmlSrc = litHtmlSrc;
1577
+ }
1578
+ const result = await compile(layoutDoc, compileOptions);
1521
1579
 
1522
1580
  // Inject CSS rules collected from component slot content
1523
1581
  if (slotCss.rules.length > 0) {
@@ -1549,10 +1607,9 @@ async function compilePage(
1549
1607
  typeof e === "string" && !e.startsWith("./") && !e.startsWith("../");
1550
1608
  const npmElements = [
1551
1609
  ...new Set(
1552
- [
1553
- ...((layoutDoc.$elements ?? []) as (JxElement | string)[]),
1554
- ...((pageDoc.$elements ?? []) as (JxElement | string)[]),
1555
- ].filter((e) => isNpmElementEntry(e)),
1610
+ [...(layoutDoc.$elements ?? []), ...(pageDoc.$elements ?? [])].filter((e) =>
1611
+ isNpmElementEntry(e),
1612
+ ),
1556
1613
  ),
1557
1614
  ];
1558
1615
  if (npmElements.length > 0) {
@@ -1561,9 +1618,9 @@ async function compilePage(
1561
1618
 
1562
1619
  // Compile server handler if applicable (skip when provider bundles site-wide)
1563
1620
  let serverHandler: string | null = null;
1564
- if (!projectConfig.build?.adapter) {
1621
+ if (!projectConfig.build.adapter) {
1565
1622
  try {
1566
- const serverResult = await compileServer(route.sourcePath as string);
1623
+ const serverResult = await compileServer(route.sourcePath);
1567
1624
  if (serverResult) {
1568
1625
  serverHandler = serverResult;
1569
1626
  }
@@ -1600,19 +1657,19 @@ async function compilePage(
1600
1657
  * @param {FormatEntry[]} activeMounts - Server-mount classes with active sections
1601
1658
  * @param {ExtensionRegistry} registry
1602
1659
  * @param {ProjectConfig} projectConfig
1603
- * @returns {{ mounts: SiteMountSpec[]; connectors: SiteConnectorSpec[] }}
1660
+ * @returns {WorkerMountSpecs}
1604
1661
  */
1605
1662
  function buildMountSpecs(
1606
1663
  activeMounts: FormatEntry[],
1607
1664
  registry: ExtensionRegistry,
1608
1665
  projectConfig: ProjectConfig,
1609
- ): { mounts: SiteMountSpec[]; connectors: SiteConnectorSpec[] } {
1666
+ ): WorkerMountSpecs {
1610
1667
  if (activeMounts.length === 0) {
1611
1668
  return { connectors: [], mounts: [] };
1612
1669
  }
1613
1670
  const base = siteBasePath(projectConfig.url);
1614
1671
 
1615
- const sections: Record<string, unknown> = {};
1672
+ const sections: Record<string, ProjectConfig[string]> = {};
1616
1673
  for (const contribution of registry.projectContributions()) {
1617
1674
  const { key } = contribution.project!;
1618
1675
  if (key in projectConfig) {
@@ -1623,7 +1680,7 @@ function buildMountSpecs(
1623
1680
  const mounts: SiteMountSpec[] = activeMounts.map((entry) => {
1624
1681
  const server = entry.server!;
1625
1682
  const { module } = server;
1626
- if (typeof module !== "string" || module === "") {
1683
+ if (!isString(module) || module === "") {
1627
1684
  throw new Error(
1628
1685
  `Extension class "${entry.name}": server.module (a bare import specifier) is required ` +
1629
1686
  `to generate a deployable worker`,
@@ -1635,7 +1692,7 @@ function buildMountSpecs(
1635
1692
  const mountBase = withBase(base, server.basePath);
1636
1693
  return {
1637
1694
  basePath: mountBase,
1638
- className: (entry.classDef.title as string | undefined) ?? entry.name,
1695
+ className: entry.classDef.title ?? entry.name,
1639
1696
  module,
1640
1697
  options: { basePath: mountBase, sections },
1641
1698
  order: server.order ?? 100,
@@ -1646,12 +1703,12 @@ function buildMountSpecs(
1646
1703
  // Vocabulary, §12) marks that provider's class for import.
1647
1704
  const usedProviders = new Set<string>();
1648
1705
  for (const value of Object.values(sections)) {
1649
- if (!value || typeof value !== "object" || Array.isArray(value)) {
1706
+ if (!isJsonObject(value)) {
1650
1707
  continue;
1651
1708
  }
1652
- for (const sectionEntry of Object.values(value as Record<string, unknown>)) {
1653
- const provider = (sectionEntry as { provider?: unknown } | null)?.provider;
1654
- if (typeof provider === "string") {
1709
+ for (const sectionEntry of Object.values(value)) {
1710
+ const provider = isJsonObject(sectionEntry) ? sectionEntry.provider : undefined;
1711
+ if (isString(provider)) {
1655
1712
  usedProviders.add(provider);
1656
1713
  }
1657
1714
  }
@@ -1664,14 +1721,14 @@ function buildMountSpecs(
1664
1721
  continue;
1665
1722
  }
1666
1723
  const { module } = entry.connector!;
1667
- if (typeof module !== "string" || module === "") {
1724
+ if (!isString(module) || module === "") {
1668
1725
  throw new Error(
1669
1726
  `Connector class "${entry.name}": connector.module (a bare import specifier) is ` +
1670
1727
  `required to generate a deployable worker`,
1671
1728
  );
1672
1729
  }
1673
1730
  connectors.push({
1674
- className: (entry.classDef.title as string | undefined) ?? entry.name,
1731
+ className: entry.classDef.title ?? entry.name,
1675
1732
  module,
1676
1733
  provider,
1677
1734
  });
@@ -1680,6 +1737,12 @@ function buildMountSpecs(
1680
1737
  return { connectors, mounts };
1681
1738
  }
1682
1739
 
1740
+ /** What the generated worker imports and mounts, as `buildMountSpecs` derives it. */
1741
+ interface WorkerMountSpecs {
1742
+ mounts: SiteMountSpec[];
1743
+ connectors: SiteConnectorSpec[];
1744
+ }
1745
+
1683
1746
  /**
1684
1747
  * Gather every extension's `<head>` contribution, once, before the first page is built.
1685
1748
  *
@@ -1696,72 +1759,89 @@ async function collectExtensionHead(
1696
1759
  const out: JxHeadEntry[] = [];
1697
1760
  for (const entry of registry.headProviders()) {
1698
1761
  const key = entry.project?.key;
1699
- const sectionValue = key === undefined ? null : (projectConfig as Record<string, unknown>)[key];
1762
+ const sectionValue = key === undefined ? null : projectConfig[key];
1700
1763
  if (key !== undefined && (sectionValue === undefined || sectionValue === null)) {
1701
1764
  continue;
1702
1765
  }
1703
1766
  try {
1704
- const contributed = (await entry.call("head", sectionValue ?? null, {
1767
+ const contributed = await entry.call("head", sectionValue ?? null, {
1705
1768
  projectConfig,
1706
1769
  root: projectRoot,
1707
- })) as JxHeadEntry[] | null;
1770
+ });
1708
1771
  out.push(...(contributed ?? []));
1709
1772
  } catch (error) {
1710
- console.warn(`${entry.name} head capability failed: ${(error as Error).message}`);
1773
+ console.warn(`${entry.name} head capability failed: ${errorMessage(error)}`);
1711
1774
  }
1712
1775
  }
1713
1776
  return out;
1714
1777
  }
1715
1778
 
1716
1779
  /**
1717
- * Resolve template strings in $head entries against the compiled scope.
1780
+ * Resolve template strings anywhere inside a structured data block (a JSON-LD `textContent`).
1718
1781
  *
1719
- * @param {JxHeadEntry[]} headEntries
1720
- * @param {Record<string, unknown>} scope
1721
- * @returns {JxHeadEntry[]}
1782
+ * @param {JsonValue} value
1783
+ * @param {JxScope} scope
1784
+ * @returns {JsonValue}
1722
1785
  */
1723
- function resolveTemplatesDeep(value: unknown, scope: Record<string, unknown>): unknown {
1724
- if (typeof value === "string") {
1725
- return isTemplateString(value) ? (evaluateStaticTemplate(value, scope) ?? value) : value;
1726
- }
1786
+ function resolveTemplatesDeep(value: JsonValue, scope: JxScope): JsonValue {
1727
1787
  if (Array.isArray(value)) {
1728
1788
  return value.map((v) => resolveTemplatesDeep(v, scope));
1729
1789
  }
1730
- if (value !== null && typeof value === "object") {
1790
+ if (isJsonObject(value)) {
1731
1791
  return Object.fromEntries(
1732
1792
  Object.entries(value).map(([k, v]) => [k, resolveTemplatesDeep(v, scope)]),
1733
1793
  );
1734
1794
  }
1795
+ if (isTemplateString(value)) {
1796
+ /* SAFETY: the block is JSON that `renderHead` serializes with JSON.stringify, and the result
1797
+ takes the place of the string it was written as, so it is serialized exactly as a value
1798
+ authored at that position would be. */
1799
+ return (evaluateStaticTemplate(value, scope) ?? value) as JsonValue;
1800
+ }
1735
1801
  return value;
1736
1802
  }
1737
1803
 
1738
- function resolveHeadTemplates(headEntries: JxHeadEntry[], scope: Record<string, unknown>) {
1804
+ /**
1805
+ * Resolve template strings in $head entries against the compiled scope.
1806
+ *
1807
+ * @param {JxHeadEntry[]} headEntries
1808
+ * @param {JxScope} scope
1809
+ * @returns {JxHeadEntry[]}
1810
+ */
1811
+ function resolveHeadTemplates(headEntries: JxHeadEntry[], scope: JxScope) {
1739
1812
  return headEntries.map((entry: JxHeadEntry) => {
1740
- if (!entry || typeof entry !== "object") {
1813
+ // A document's `$head` is authored JSON, so a member is not guaranteed to be an entry object.
1814
+ if (!isNonNullObject(entry)) {
1741
1815
  return entry;
1742
1816
  }
1743
1817
  const resolved = { ...entry };
1744
1818
  if (resolved.attributes) {
1745
1819
  resolved.attributes = { ...resolved.attributes };
1746
1820
  for (const [k, v] of Object.entries(resolved.attributes)) {
1747
- if (typeof v === "string" && isTemplateString(v)) {
1821
+ if (isTemplateString(v)) {
1822
+ /* SAFETY: the result replaces the attribute value it was written as, and `renderHead`
1823
+ writes every attribute value other than `true` through `String()`. */
1748
1824
  resolved.attributes[k] =
1749
- (evaluateStaticTemplate(v, scope) as string | boolean | null) ??
1750
- (v as string | boolean);
1825
+ (evaluateStaticTemplate(v, scope) as string | boolean | null) ?? v;
1751
1826
  }
1752
1827
  }
1753
1828
  }
1754
- if (typeof resolved.textContent === "string" && isTemplateString(resolved.textContent)) {
1829
+ if (isTemplateString(resolved.textContent)) {
1830
+ /* SAFETY: the result replaces the text it was written as, and `renderHead` interpolates a
1831
+ non-object `textContent` into the markup as text (an object one is serialized as JSON). */
1755
1832
  resolved.textContent =
1756
1833
  (evaluateStaticTemplate(resolved.textContent, scope) as string | null) ??
1757
1834
  resolved.textContent;
1758
- } else if (resolved.textContent !== null && typeof resolved.textContent === "object") {
1759
- // A structured data block (§8.5): templates inside it resolve too, or a JSON-LD object could
1760
- // Never reference the page it describes.
1761
- resolved.textContent = resolveTemplatesDeep(resolved.textContent, scope) as Record<
1762
- string,
1763
- unknown
1764
- >;
1835
+ } else if (isNonNullObject(resolved.textContent)) {
1836
+ /*
1837
+ * A structured data block (§8.5): templates inside it resolve too, or a JSON-LD object could
1838
+ * never reference the page it describes.
1839
+ *
1840
+ * SAFETY: `resolveTemplatesDeep` keeps a container's shape (an object resolves to an object,
1841
+ * an array to an array, and JSON-LD admits either at the top), and `renderHead` serializes
1842
+ * any object `textContent` with JSON.stringify.
1843
+ */
1844
+ resolved.textContent = resolveTemplatesDeep(resolved.textContent, scope) as JsonObject;
1765
1845
  }
1766
1846
  return resolved;
1767
1847
  });
@@ -1782,13 +1862,14 @@ function resolveHeadBareSpecifiers(
1782
1862
  rewrite: (specifier: string) => string,
1783
1863
  ) {
1784
1864
  return headEntries.map((entry: JxHeadEntry) => {
1785
- if (!entry || typeof entry !== "object" || !entry.attributes) {
1865
+ // Authored JSON, as in `resolveHeadTemplates`: a member is not guaranteed to be an object.
1866
+ if (!isNonNullObject(entry) || !entry.attributes) {
1786
1867
  return entry;
1787
1868
  }
1788
1869
  const resolved = { ...entry, attributes: { ...entry.attributes } };
1789
1870
  for (const key of ["href", "src"]) {
1790
1871
  const val = resolved.attributes[key];
1791
- if (typeof val === "string" && isBareSpecifier(val)) {
1872
+ if (isString(val) && isBareSpecifier(val)) {
1792
1873
  resolved.attributes[key] = rewrite(val);
1793
1874
  }
1794
1875
  }
@@ -1843,59 +1924,70 @@ function inertAttribute(resolved: string): string {
1843
1924
  * Deep-clone a map template, resolving template strings and $ref values against the given scope.
1844
1925
  *
1845
1926
  * @param {JxElement} template
1846
- * @param {Record<string, unknown>} scope
1927
+ * @param {JxScope} scope
1847
1928
  * @returns {JxElement}
1848
1929
  */
1849
- function expandMapTemplate(template: JxElement, scope: Record<string, unknown>): JxElement {
1850
- if (!template || typeof template !== "object") {
1930
+ function expandMapTemplate(template: JxElement, scope: JxScope): JxElement {
1931
+ // A map template is authored JSON, so a child reached by recursion may not be an object.
1932
+ if (!isNonNullObject(template)) {
1851
1933
  return template;
1852
1934
  }
1853
- const node = {} as JxElement;
1935
+ const node: JxElement = {};
1854
1936
  for (const [k, v] of Object.entries(template)) {
1855
- if (k === "children" && Array.isArray(v)) {
1856
- node.children = (v as (string | JxElement)[]).map((child) => {
1857
- if (typeof child === "string") {
1937
+ // Each branch reads `template[k]` through its declared member, which is the same value as `v`.
1938
+ if (k === "children" && Array.isArray(template.children)) {
1939
+ node.children = template.children.map((child) => {
1940
+ if (isString(child)) {
1858
1941
  return child;
1859
1942
  }
1860
1943
  return expandMapTemplate(child, scope);
1861
1944
  });
1862
- } else if (k === "style" && v && typeof v === "object") {
1863
- const style: JxStyle = { ...(v as JxStyle) };
1945
+ } else if (k === "style" && isNonNullObject(template.style)) {
1946
+ const style: JxStyle = { ...template.style };
1864
1947
  for (const [sk, sv] of Object.entries(style)) {
1865
1948
  if (isTemplateString(sv)) {
1866
- // Template evaluation yields a substituted scalar for style values.
1949
+ /* SAFETY: template evaluation yields a substituted scalar for the declaration it was
1950
+ written as, and the style compilers interpolate declaration values into CSS text. */
1867
1951
  style[sk] = (evaluateMapTemplate(sv, scope) as string | number | undefined) ?? sv;
1868
1952
  }
1869
1953
  }
1870
1954
  node.style = style;
1871
- } else if (k === "attributes" && v && typeof v === "object") {
1872
- const attrs = { ...(v as Record<string, JxAttributeValue>) };
1955
+ } else if (k === "attributes" && isNonNullObject(template.attributes)) {
1956
+ const attrs = { ...template.attributes };
1873
1957
  for (const [ak, av] of Object.entries(attrs)) {
1874
1958
  if (isTemplateString(av)) {
1959
+ /* SAFETY: the result replaces the attribute value it was written as, and the compile
1960
+ targets write attribute values into markup as their string form. */
1875
1961
  const resolved = evaluateMapTemplate(av, scope) as JxAttributeValue | undefined;
1876
1962
  attrs[ak] =
1877
- typeof resolved === "string" && resolved.includes("${")
1963
+ isString(resolved) && resolved.includes("${")
1878
1964
  ? inertAttribute(resolved)
1879
1965
  : (resolved ?? av);
1880
1966
  }
1881
1967
  }
1882
1968
  node.attributes = attrs;
1883
- } else if (k === "$props" && v && typeof v === "object") {
1884
- const props = { ...(v as Record<string, JsonValue>) };
1969
+ } else if (k === "$props" && isNonNullObject(template.$props)) {
1970
+ const props = { ...template.$props };
1885
1971
  for (const [pk, pv] of Object.entries(props)) {
1886
1972
  if (isTemplateString(pv)) {
1973
+ /* SAFETY: props leave the build only as JSON (`expandComponents` writes them into
1974
+ `data-jx-props` with JSON.stringify), so a result is serialized exactly as a value
1975
+ authored at that prop would be. */
1887
1976
  const resolved = evaluateMapTemplate(pv, scope) as JsonValue | undefined;
1888
1977
  // Null = evaluation error → keep template string; undefined = missing data → use null
1889
1978
  props[pk] = resolved !== null ? (resolved ?? null) : pv;
1890
1979
  }
1891
1980
  }
1892
1981
  node.$props = props;
1893
- } else if (typeof v === "string" && isTemplateString(v)) {
1982
+ } else if (isTemplateString(v)) {
1894
1983
  const resolved = evaluateMapTemplate(v, scope);
1895
- if (k === "textContent" && typeof resolved === "string" && resolved.includes("${")) {
1984
+ if (k === "textContent" && isString(resolved) && resolved.includes("${")) {
1896
1985
  node.innerHTML = inertTextHtml(resolved);
1897
1986
  } else {
1898
- node[k] = resolved ?? v;
1987
+ /* SAFETY: the result takes the place of the template string at the same key, which is the
1988
+ substitution a map template asks for; a member the compile targets emit as text or an
1989
+ attribute is interpolated there as its string form. */
1990
+ node[k] = (resolved ?? v) as JxElement[string];
1899
1991
  }
1900
1992
  } else {
1901
1993
  node[k] = v;
@@ -1909,29 +2001,21 @@ function expandMapTemplate(template: JxElement, scope: Record<string, unknown>):
1909
2001
  * `state`, and `$map` as local variables.
1910
2002
  *
1911
2003
  * @param {string} str
1912
- * @param {Record<string, unknown>} scope
1913
- * @returns {unknown}
2004
+ * @param {JxScope} scope
2005
+ * @returns {JxScope[string]} Whatever the expression yields, or null when it throws
1914
2006
  */
1915
- function evaluateMapTemplate(str: string, scope: Record<string, unknown>) {
2007
+ function evaluateMapTemplate(str: string, scope: JxScope): JxScope[string] {
1916
2008
  try {
1917
- const item = (scope.$map as Record<string, unknown>)?.item;
1918
- const index = (scope.$map as Record<string, unknown>)?.index;
1919
- if (isSingleExpression(str)) {
1920
- const fn = new Function("state", "$map", "item", "index", `return (${str.slice(2, -1)})`) as (
1921
- state: Record<string, unknown>,
1922
- $map: unknown,
1923
- item: unknown,
1924
- index: unknown,
1925
- ) => unknown;
1926
- return fn(scope, scope.$map, item, index);
1927
- }
1928
- const fn = new Function("state", "$map", "item", "index", `return \`${str}\``) as (
1929
- state: Record<string, unknown>,
1930
- $map: unknown,
1931
- item: unknown,
1932
- index: unknown,
1933
- ) => unknown;
1934
- return fn(scope, scope.$map, item, index);
2009
+ const frame = scope.$map;
2010
+ const item = isNonNullObject(frame) && "item" in frame ? frame.item : undefined;
2011
+ const index = isNonNullObject(frame) && "index" in frame ? frame.index : undefined;
2012
+ const body = isSingleExpression(str) ? `return (${str.slice(2, -1)})` : `return \`${str}\``;
2013
+ /* SAFETY: `new Function` compiles exactly the four named parameters, and its body returns one
2014
+ expression's value, which may be any scope value. */
2015
+ const fn = new Function("state", "$map", "item", "index", body) as (
2016
+ ...args: JxScope[string][]
2017
+ ) => JxScope[string];
2018
+ return fn(scope, frame, item, index);
1935
2019
  } catch {
1936
2020
  return null;
1937
2021
  }
@@ -1942,14 +2026,15 @@ function evaluateMapTemplate(str: string, scope: Record<string, unknown>) {
1942
2026
  * place — evaluates ${...} in innerHTML, textContent, style values, and attribute values.
1943
2027
  *
1944
2028
  * @param {JxElement | string} node
1945
- * @param {Record<string, unknown>} scope
2029
+ * @param {JxScope} scope
1946
2030
  */
1947
- function resolveDocTemplates(node: JxElement | string, scope: Record<string, unknown>) {
1948
- if (!node || typeof node !== "object") {
2031
+ function resolveDocTemplates(node: JxElement | string, scope: JxScope) {
2032
+ // Children are authored JSON, so a member reached by recursion may be neither node nor string.
2033
+ if (!isNonNullObject(node)) {
1949
2034
  return;
1950
2035
  }
1951
2036
 
1952
- if (typeof node.innerHTML === "string" && isTemplateString(node.innerHTML)) {
2037
+ if (isTemplateString(node.innerHTML)) {
1953
2038
  const resolved = evaluateStaticTemplate(node.innerHTML, scope);
1954
2039
  if (resolved != null) {
1955
2040
  // Encode any remaining `${` as HTML entities so the compile phase won't
@@ -1958,44 +2043,55 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
1958
2043
  node.innerHTML = String(resolved).replaceAll("${", "&#36;{");
1959
2044
  }
1960
2045
  }
1961
- if (typeof node.textContent === "string" && isTemplateString(node.textContent)) {
1962
- const resolved = evaluateStaticTemplate(node.textContent, scope);
1963
- if (typeof resolved === "string" && resolved.includes("${")) {
2046
+ const text = node.textContent;
2047
+ if (isTemplateString(text)) {
2048
+ const resolved = evaluateStaticTemplate(text, scope);
2049
+ if (isString(resolved) && resolved.includes("${")) {
1964
2050
  // The resolved value is data (an excerpt, a heading, a title) that happens to contain `${`, not
1965
2051
  // A template. Left as `textContent` the compile phase would read it as one again and bind it
1966
2052
  // To an expression that throws or renders nothing, so it stays text as escaped `innerHTML`.
1967
2053
  node.innerHTML = inertTextHtml(resolved);
1968
2054
  delete node.textContent;
1969
2055
  } else {
1970
- node.textContent = (resolved as string | null) ?? (node.textContent as string | null);
2056
+ /* SAFETY: the result is the text the template stands for, and the compile targets
2057
+ interpolate `textContent` into the markup, so a non-string result renders as its string
2058
+ form. */
2059
+ node.textContent = (resolved as string | null) ?? text;
1971
2060
  }
1972
2061
  }
1973
- if (node.style && typeof node.style === "object") {
2062
+ if (isNonNullObject(node.style)) {
1974
2063
  for (const [k, v] of Object.entries(node.style)) {
1975
- if (typeof v === "string" && isTemplateString(v)) {
2064
+ if (isTemplateString(v)) {
2065
+ /* SAFETY: the result replaces the declaration value it was written as, and the page style
2066
+ pass interpolates declaration values into CSS text. */
1976
2067
  node.style[k] =
1977
- (evaluateStaticTemplate(v, scope) as string | number | JxStyle | undefined) ??
1978
- (v as string | number | JxStyle);
2068
+ (evaluateStaticTemplate(v, scope) as string | number | JxStyle | undefined) ?? v;
1979
2069
  }
1980
2070
  }
1981
2071
  }
1982
- if (node.attributes && typeof node.attributes === "object") {
2072
+ if (isNonNullObject(node.attributes)) {
1983
2073
  for (const [k, v] of Object.entries(node.attributes)) {
1984
- if (typeof v === "string" && isTemplateString(v)) {
2074
+ if (isTemplateString(v)) {
2075
+ /* SAFETY: the result replaces the attribute value it was written as, and the compile
2076
+ targets write attribute values into markup as their string form. */
1985
2077
  const resolved = evaluateStaticTemplate(v, scope) as JxAttributeValue | null;
1986
2078
  // A resolved value is data: a `${` left in it must not become a template a second time. An
1987
2079
  // Entity cannot be written into an attribute, so the sequence is split with a zero-width space.
1988
2080
  node.attributes[k] =
1989
- typeof resolved === "string" && resolved.includes("${")
2081
+ isString(resolved) && resolved.includes("${")
1990
2082
  ? inertAttribute(resolved)
1991
2083
  : (resolved ?? v);
1992
2084
  }
1993
2085
  }
1994
2086
  }
1995
- if (node.$props && typeof node.$props === "object") {
1996
- for (const [k, v] of Object.entries(node.$props)) {
1997
- if (typeof v === "string" && isTemplateString(v)) {
1998
- node.$props[k] = (evaluateStaticTemplate(v, scope) as JsonValue | null) ?? v;
2087
+ if (isNonNullObject(node.$props)) {
2088
+ const props = node.$props;
2089
+ for (const [k, v] of Object.entries(props)) {
2090
+ if (isTemplateString(v)) {
2091
+ /* SAFETY: props leave the build only as JSON (`expandComponents` writes them into
2092
+ `data-jx-props` with JSON.stringify), so a result is serialized exactly as a value
2093
+ authored at that prop would be. */
2094
+ props[k] = (evaluateStaticTemplate(v, scope) as JsonValue | null) ?? v;
1999
2095
  }
2000
2096
  }
2001
2097
  }
@@ -2013,9 +2109,11 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
2013
2109
  return;
2014
2110
  }
2015
2111
  }
2016
- if (typeof rawChildren === "string" && isTemplateString(rawChildren)) {
2112
+ if (isTemplateString(rawChildren)) {
2017
2113
  const resolved = evaluateStaticTemplate(rawChildren, scope);
2018
2114
  if (Array.isArray(resolved)) {
2115
+ /* SAFETY: a children template that yields an array stands for the child list itself, and
2116
+ every member is walked by `resolveDocTemplates` below, which skips non-objects. */
2019
2117
  const resolvedNodes = resolved as (JxElement | string)[];
2020
2118
  node.children = resolvedNodes;
2021
2119
  for (const child of resolvedNodes) {
@@ -2026,9 +2124,11 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
2026
2124
  let i = 0;
2027
2125
  while (i < node.children.length) {
2028
2126
  const child = node.children[i]!;
2029
- if (typeof child === "string" && isTemplateString(child)) {
2127
+ if (isTemplateString(child)) {
2030
2128
  const resolved = evaluateStaticTemplate(child, scope);
2031
2129
  if (Array.isArray(resolved)) {
2130
+ /* SAFETY: a child template that yields an array stands for the children it splices in,
2131
+ and every member is walked by `resolveDocTemplates` below, which skips non-objects. */
2032
2132
  const resolvedNodes = resolved as (JxElement | string)[];
2033
2133
  node.children.splice(i, 1, ...resolvedNodes);
2034
2134
  for (const spliced of resolvedNodes) {
@@ -2072,26 +2172,25 @@ function referencesStateKey(haystack: string, key: string): boolean {
2072
2172
  * build time, or null otherwise (leave the array node for client-side rendering).
2073
2173
  *
2074
2174
  * @param {JxMappedArray} arrayDef
2075
- * @param {Record<string, unknown>} scope
2175
+ * @param {JxScope} scope
2076
2176
  * @returns {(JxElement | string)[] | null}
2077
2177
  */
2078
2178
  function expandMappedArrayStatic(
2079
2179
  arrayDef: JxMappedArray,
2080
- scope: Record<string, unknown>,
2180
+ scope: JxScope,
2081
2181
  ): (JxElement | string)[] | null {
2082
2182
  const itemsSrc = arrayDef.items;
2083
- let items: unknown = null;
2084
- if (isRef(itemsSrc)) {
2085
- items = resolveRefValue(itemsSrc.$ref, scope);
2086
- } else if (Array.isArray(itemsSrc)) {
2087
- items = itemsSrc;
2088
- }
2183
+ // A non-array, non-ref `items` falls through to the array check below and expands to nothing.
2184
+ const items = isRef(itemsSrc) ? resolveRefValue(itemsSrc.$ref, scope) : itemsSrc;
2089
2185
  const mapTemplate = arrayDef.map;
2090
2186
  if (!Array.isArray(items) || !mapTemplate) {
2091
2187
  return null;
2092
2188
  }
2093
- return (items as unknown[]).map((item: unknown, index) => {
2094
- const childScope = Object.create(scope) as Record<string, unknown>;
2189
+ const rows: readonly JxScope[string][] = items;
2190
+ return rows.map((item, index) => {
2191
+ /* SAFETY: `Object.create(scope)` is a new object with no own keys whose prototype is `scope`,
2192
+ so every key it answers is a binding of `scope` until the row's own are set below. */
2193
+ const childScope = Object.create(scope) as JxScope;
2095
2194
  childScope.$map = { index, item };
2096
2195
  childScope["$map/item"] = item;
2097
2196
  childScope["$map/index"] = index;
@@ -2113,23 +2212,26 @@ interface SlotCssCollector {
2113
2212
  * preRenderComponentHtml, marks static/prerendered. Slot children get their static styles collected
2114
2213
  * into `slotCss` (assigning jxs-N classes) before being serialized to HTML.
2115
2214
  *
2116
- * @param {JxElement | string} node
2215
+ * @param {JxElement | string | (JxElement | string)[]} node - A nested array (authored JSON can
2216
+ * carry one among children) is walked member by member
2117
2217
  * @param {Map<string, JxElement>} componentDefs
2118
2218
  * @param {SlotCssCollector} [slotCss]
2119
2219
  * @param {ProjectConfig["defaults"]} [defaults] - Read for `defaults.shadow` (spec.md §16.6)
2220
+ * @param {{ n: number }} [slotIds] - The page's `data-jx-slots` counter
2120
2221
  */
2121
2222
  function expandComponents(
2122
- node: JxElement | string,
2223
+ node: JxElement | string | (JxElement | string)[],
2123
2224
  componentDefs: Map<string, JxElement>,
2124
2225
  slotCss?: SlotCssCollector,
2125
2226
  defaults?: ProjectConfig["defaults"],
2227
+ slotIds?: { n: number },
2126
2228
  ) {
2127
- if (!node || typeof node !== "object") {
2229
+ if (!isNonNullObject(node)) {
2128
2230
  return;
2129
2231
  }
2130
2232
  if (Array.isArray(node)) {
2131
- for (const n of node as (JxElement | string)[]) {
2132
- expandComponents(n, componentDefs, slotCss, defaults);
2233
+ for (const n of node) {
2234
+ expandComponents(n, componentDefs, slotCss, defaults, slotIds);
2133
2235
  }
2134
2236
  return;
2135
2237
  }
@@ -2137,11 +2239,16 @@ function expandComponents(
2137
2239
  // Recurse into children first (bottom-up expansion)
2138
2240
  if (Array.isArray(node.children)) {
2139
2241
  for (const child of node.children) {
2140
- expandComponents(child, componentDefs, slotCss, defaults);
2242
+ expandComponents(child, componentDefs, slotCss, defaults, slotIds);
2141
2243
  }
2142
2244
  }
2143
2245
 
2144
- const def = componentDefs.get(node.tagName as string);
2246
+ // Only a literal tag can name a registered component; a tag expression never matches one.
2247
+ const { tagName } = node;
2248
+ if (!isString(tagName)) {
2249
+ return;
2250
+ }
2251
+ const def = componentDefs.get(tagName);
2145
2252
  if (def) {
2146
2253
  // Literal `props.*` attribute keys become $props (`liftPropsAttributes` says why) and leave the
2147
2254
  // Attributes, so they do not reach the emitted HTML. Explicit $props wins on key conflicts.
@@ -2160,17 +2267,12 @@ function expandComponents(
2160
2267
  * was given. Instances written inside `def` itself are another matter — those are reached
2161
2268
  * below, through `preRenderComponentHtml`, with `node` as the first frame of the path.
2162
2269
  */
2163
- const slotContent =
2164
- Array.isArray(node.children) && node.children.length > 0
2165
- ? node.children
2166
- .map((c: JxElement | string) => {
2167
- if (slotCss && c && typeof c === "object") {
2168
- collectStyles(c, slotCss.rules, slotCss.media, "", slotCss.counter, "jxs");
2169
- }
2170
- return renderStaticNode(c, {}, null);
2171
- })
2172
- .join("\n")
2173
- : null;
2270
+ const renderSlotted = (c: StaticChild) => {
2271
+ if (slotCss && isNonNullObject(c) && !Array.isArray(c)) {
2272
+ collectStyles(c, slotCss.rules, slotCss.media, "", slotCss.counter, "jxs");
2273
+ }
2274
+ return renderStaticNode(c, {}, null);
2275
+ };
2174
2276
 
2175
2277
  /*
2176
2278
  * In shadow mode the slotted children are NOT substituted into the prerendered markup: they
@@ -2185,10 +2287,29 @@ function expandComponents(
2185
2287
  const context: ComponentPrerenderContext = {
2186
2288
  componentDefs,
2187
2289
  defaults,
2188
- path: [{ key: JSON.stringify(props ?? {}), tag: node.tagName as string }],
2290
+ path: [{ key: JSON.stringify(props ?? {}), tag: tagName }],
2291
+ slotIds,
2189
2292
  };
2190
- const innerHTML = preRenderComponentHtml(def, props, shadow ? null : slotContent, context);
2191
2293
  const isStatic = isComponentFullyStatic(def);
2294
+ const { children } = node;
2295
+ /* Light mode places the children by slot name (spec.md §8.5). A live instance whose definition
2296
+ has a slot is stamped and its groups bracketed, so its element recovers them on upgrade. */
2297
+ const slotId =
2298
+ !shadow && !isStatic && definitionHasSlot(def.children) ? nextSlotId(context) : null;
2299
+ const slotContent = shadow
2300
+ ? (Array.isArray(children) ? children : [])
2301
+ .map((c: StaticChild) => renderSlotted(c))
2302
+ .join("\n")
2303
+ : null;
2304
+ const innerHTML = preRenderComponentHtml(
2305
+ def,
2306
+ props,
2307
+ shadow ? null : createSlotFill(children, {}, renderSlotted, slotId),
2308
+ context,
2309
+ );
2310
+ if (slotId !== null) {
2311
+ node.attributes = { ...node.attributes, [SLOT_ATTR]: String(slotId) };
2312
+ }
2192
2313
 
2193
2314
  /*
2194
2315
  * A declarative shadow root: markup the parser materializes before any script runs, which the
@@ -2198,7 +2319,7 @@ function expandComponents(
2198
2319
  */
2199
2320
  node.innerHTML = shadow
2200
2321
  ? `<template shadowrootmode="${shadow}">` +
2201
- `<link rel="stylesheet" href="/components/${node.tagName}.css">` +
2322
+ `<link rel="stylesheet" href="/components/${tagName}.css">` +
2202
2323
  `${innerHTML}</template>${slotContent ?? ""}`
2203
2324
  : innerHTML;
2204
2325
  delete node.children;
@@ -2216,6 +2337,9 @@ function expandComponents(
2216
2337
  */
2217
2338
  const resolvedStyle = resolveHostStyle(def.style, buildInstanceScope(def, props));
2218
2339
  if (Object.keys(resolvedStyle).length > 0) {
2340
+ /* SAFETY: every value in `resolvedStyle` replaces a template-string declaration of
2341
+ `def.style`, and the page style pass interpolates declaration values into CSS text, so the
2342
+ merged object is a style in every way the build reads one. */
2219
2343
  node.style = { ...node.style, ...resolvedStyle } as JxStyle;
2220
2344
  }
2221
2345
 
@@ -2293,8 +2417,10 @@ function injectComponentScripts(
2293
2417
  * Emitted last in <head>, exactly where the links were, so the cascade is unchanged.
2294
2418
  */
2295
2419
  const componentCss = usedTags
2296
- .map((tag: string) => cssMap.get(tag))
2297
- .filter((css): css is string => css !== undefined && css !== "")
2420
+ .flatMap((tag: string) => {
2421
+ const css = cssMap.get(tag);
2422
+ return css === undefined || css === "" ? [] : [css];
2423
+ })
2298
2424
  .join("\n");
2299
2425
  const styleBlock =
2300
2426
  componentCss === "" ? "" : `<style>\n${escapeStyleText(componentCss)}\n </style>`;
@@ -2480,25 +2606,41 @@ function routeToOutputPath(urlPattern: string, outDir: string, trailingSlash: st
2480
2606
  * An HTML meta-refresh is a _client-side_ redirect: the browser fetches the source, then navigates.
2481
2607
  * That is a reasonable stand-in for a 301 or a 303 on a host that ignores `_redirects`, and it is
2482
2608
  * actively wrong for everything else.
2609
+ *
2610
+ * Keyed by the status's decimal string so an authored `"status": "301"` (schema-invalid, but
2611
+ * project.json is not validated at build time) still matches the way an object-key lookup would.
2483
2612
  */
2484
- const REDIRECT_HTML_POLICY: Record<number, { html: boolean; canonical: boolean; why: string }> = {
2613
+ const REDIRECT_HTML_POLICY = new Map<string, RedirectHtmlPolicy>([
2485
2614
  // The permanent case: a canonical link is exactly the right signal.
2486
- 301: { canonical: true, html: true, why: "" },
2615
+ ["301", { canonical: true, html: true, why: "" }],
2487
2616
  // Temporary — a canonical link would assert the permanence the status denies.
2488
- 302: { canonical: false, html: true, why: "" },
2617
+ ["302", { canonical: false, html: true, why: "" }],
2489
2618
  // "See other, with GET" is what a meta-refresh already does.
2490
- 303: { canonical: false, html: true, why: "" },
2491
- 307: {
2492
- canonical: false,
2493
- html: false,
2494
- why: "307 preserves the request method and body; a meta-refresh silently converts POST to GET",
2495
- },
2496
- 308: {
2497
- canonical: false,
2498
- html: false,
2499
- why: "308 preserves the request method and body; a meta-refresh silently converts POST to GET",
2500
- },
2501
- };
2619
+ ["303", { canonical: false, html: true, why: "" }],
2620
+ [
2621
+ "307",
2622
+ {
2623
+ canonical: false,
2624
+ html: false,
2625
+ why: "307 preserves the request method and body; a meta-refresh silently converts POST to GET",
2626
+ },
2627
+ ],
2628
+ [
2629
+ "308",
2630
+ {
2631
+ canonical: false,
2632
+ html: false,
2633
+ why: "308 preserves the request method and body; a meta-refresh silently converts POST to GET",
2634
+ },
2635
+ ],
2636
+ ]);
2637
+
2638
+ /** One redirection status's HTML-fallback rule, in {@link REDIRECT_HTML_POLICY}. */
2639
+ interface RedirectHtmlPolicy {
2640
+ html: boolean;
2641
+ canonical: boolean;
2642
+ why: string;
2643
+ }
2502
2644
 
2503
2645
  /**
2504
2646
  * Emit `dist/_redirects` plus an HTML fallback for the literal sources whose status has one.
@@ -2508,7 +2650,7 @@ const REDIRECT_HTML_POLICY: Record<number, { html: boolean; canonical: boolean;
2508
2650
  * string | { destination: string; status?: number } | { destination: string; rewrite: true }
2509
2651
  * >} redirects
2510
2652
  * @param {string} outDir
2511
- * @param {{ compiledUrls?: Set<string>; trailingSlash?: string }} [opts]
2653
+ * @param {{ compiledUrls?: Set<string>; trailingSlash?: string | null }} [opts]
2512
2654
  * @returns {{ files: number; errors: string[] }}
2513
2655
  */
2514
2656
  function generateRedirects(
@@ -2517,7 +2659,7 @@ function generateRedirects(
2517
2659
  string | { destination: string; status?: number } | { destination: string; rewrite: true }
2518
2660
  >,
2519
2661
  outDir: string,
2520
- opts: { compiledUrls?: Set<string>; trailingSlash?: string; basePath?: string } = {},
2662
+ opts: { compiledUrls?: Set<string>; trailingSlash?: string | null; basePath?: string } = {},
2521
2663
  ) {
2522
2664
  let files = 0;
2523
2665
  const errors: string[] = [];
@@ -2527,18 +2669,18 @@ function generateRedirects(
2527
2669
  const trailingSlash = opts.trailingSlash ?? "always";
2528
2670
 
2529
2671
  for (const [source, target] of Object.entries(redirects)) {
2530
- const dest = typeof target === "object" ? target.destination : target;
2531
- const isRewrite = typeof target === "object" && "rewrite" in target && target.rewrite;
2672
+ const dest = isString(target) ? target : target.destination;
2673
+ const isRewrite = !isString(target) && "rewrite" in target && target.rewrite;
2532
2674
  const status = isRewrite
2533
2675
  ? 200
2534
- : typeof target === "object" && "status" in target
2676
+ : !isString(target) && "status" in target
2535
2677
  ? (target.status ?? 301)
2536
2678
  : 301;
2537
2679
 
2538
- if (!isRewrite && REDIRECT_HTML_POLICY[status] === undefined) {
2680
+ if (!isRewrite && !REDIRECT_HTML_POLICY.has(String(status))) {
2539
2681
  errors.push(
2540
2682
  `Redirect "${source}" has status ${status}, which is not an RFC 9110 §15.4 redirection ` +
2541
- `status. Use one of ${Object.keys(REDIRECT_HTML_POLICY).join(", ")}, or ` +
2683
+ `status. Use one of ${[...REDIRECT_HTML_POLICY.keys()].join(", ")}, or ` +
2542
2684
  `{ destination, rewrite: true } for a proxy.`,
2543
2685
  );
2544
2686
  continue;
@@ -2571,7 +2713,7 @@ function generateRedirects(
2571
2713
  if (isRewrite) {
2572
2714
  continue;
2573
2715
  }
2574
- const policy = REDIRECT_HTML_POLICY[status]!;
2716
+ const policy = REDIRECT_HTML_POLICY.get(String(status))!;
2575
2717
  if (!policy.html) {
2576
2718
  continue;
2577
2719
  }