@jxsuite/compiler 5.1.2 → 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 +220 -351
  2. package/dist/compiler.js +126 -257
  3. package/package.json +4 -4
  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
@@ -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,6 +63,7 @@ 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";
@@ -64,7 +73,9 @@ import { mergeHead, renderHead } 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,
@@ -77,7 +88,6 @@ import {
77
88
  evaluateStaticTemplate,
78
89
  isComponentFullyStatic,
79
90
  isSingleExpression,
80
- isTemplateString,
81
91
  liftPropsAttributes,
82
92
  preRenderComponentHtml,
83
93
  pureSchemeOf,
@@ -131,19 +141,32 @@ import { getImageCacheDir, loadCache, saveCache } from "./image-cache.ts";
131
141
  import type { ImageConfig } from "./image-optimizer.ts";
132
142
  import type { ImageMetaCache } from "./image-transform.ts";
133
143
  import type {
144
+ JsonObject,
134
145
  JsonValue,
135
146
  JxAttributeValue,
136
147
  JxElement,
137
148
  JxHeadEntry,
138
149
  JxMappedArray,
139
- JxMutableNode,
140
150
  JxStateDefinition,
141
151
  JxStyle,
142
152
  ProjectConfig,
143
153
  } from "@jxsuite/schema/types";
144
- import type { SiteRoute } from "../types.ts";
154
+ import type { JxScope } from "@jxsuite/runtime/types";
145
155
  import type { CacheManifest } from "./image-cache.js";
146
156
 
157
+ declare module "@jxsuite/schema/format-registry" {
158
+ /**
159
+ * The template hooks a site build adds to an export sidecar's `serialize` options
160
+ * (specs/extensions.md §7). The format builds a document's scope with `buildScope` and hands that
161
+ * scope back to `evaluateTemplate` unread, so both are typed by the compiler's static scope.
162
+ */
163
+ interface SerializeExportOptions {
164
+ buildScope?: (state: Record<string, JxStateDefinition>) => JxScope;
165
+ /** Evaluate one template string against that scope; `undefined` keeps it as written. */
166
+ evaluateTemplate?: (value: string, scope: JxScope) => JxScope[string] | undefined;
167
+ }
168
+ }
169
+
147
170
  /**
148
171
  * Build an entire Jx site from a project directory.
149
172
  *
@@ -289,7 +312,7 @@ export async function buildSite(
289
312
  sidecarBundles.set(assetPath, { entryPath, specifier });
290
313
  return assetPath;
291
314
  } catch (error) {
292
- errors.push(`Sidecar "${specifier}": ${(error as Error).message}`);
315
+ errors.push(`Sidecar "${specifier}": ${errorMessage(error)}`);
293
316
  return specifier;
294
317
  }
295
318
  };
@@ -304,7 +327,7 @@ export async function buildSite(
304
327
  } catch (error) {
305
328
  // A missing dependency is a hard error, like an unresolvable sidecar: the alternative is a
306
329
  // Page that looks fine in dev and loses its stylesheet on deploy.
307
- errors.push(`$head specifier "${specifier}": ${(error as Error).message}`);
330
+ errors.push(`$head specifier "${specifier}": ${errorMessage(error)}`);
308
331
  return specifier;
309
332
  }
310
333
  };
@@ -328,8 +351,8 @@ export async function buildSite(
328
351
  if (!entry.project) {
329
352
  return true;
330
353
  }
331
- const value = projectConfig[entry.project.key] as Record<string, unknown> | undefined;
332
- return value !== undefined && typeof value === "object" && Object.keys(value).length > 0;
354
+ const value = projectConfig[entry.project.key];
355
+ return isNonNullObject(value) && Object.keys(value).length > 0;
333
356
  });
334
357
  if (activeMounts.length > 0 && !projectConfig.build.adapter) {
335
358
  const sections = activeMounts
@@ -386,11 +409,9 @@ export async function buildSite(
386
409
  const assetRefs = new Set<string>();
387
410
 
388
411
  // ── 4. Expand dynamic routes ────────────────────────────────────────────
389
- const extensionHead = registry
390
- ? await collectExtensionHead(registry, projectConfig, projectRoot)
391
- : [];
412
+ const extensionHead = await collectExtensionHead(registry, projectConfig, projectRoot);
392
413
 
393
- const routes = await expandDynamicRoutes(
414
+ const expandedRoutes = await expandDynamicRoutes(
394
415
  staticRoutes,
395
416
  projectRoot,
396
417
  sections,
@@ -404,10 +425,13 @@ export async function buildSite(
404
425
  * of one entry share an id (§13.3), so a page that asked for `hello` without saying in which
405
426
  * language got whichever translation was loaded first — the English copy under a French route,
406
427
  * silently, on a page whose `<html lang>` said otherwise.
428
+ *
429
+ * Stamped onto each route object in place, so every holder of a route (the extension `emit`
430
+ * capability receives this table) sees the same `locale` member.
407
431
  */
408
- for (const route of routes) {
409
- (route as { locale?: string | null }).locale = localeOfRoute(route.urlPattern, i18n);
410
- }
432
+ const routes = expandedRoutes.map((route) =>
433
+ Object.assign(route, { locale: localeOfRoute(route.urlPattern, i18n) }),
434
+ );
411
435
  log(` ${routes.length} route(s) after expansion`);
412
436
 
413
437
  let fileCount = 0;
@@ -443,19 +467,31 @@ export async function buildSite(
443
467
  }
444
468
  }
445
469
 
470
+ /*
471
+ * A component is a JavaScript module the page loads, and the page rewrite (`rewriteHtmlBase`) never
472
+ * reads it: the `/assets/` URLs the compiler writes into it for a `$src` function (a static import,
473
+ * or the dynamic one a `$lazy` function becomes) are re-rooted here, or they name files a site
474
+ * served from a path does not have.
475
+ */
476
+ const componentBase = siteBasePath(projectConfig.url);
477
+
446
478
  for (const file of componentFiles) {
447
479
  try {
448
480
  const componentPath = resolve(componentsDir, file);
449
- const result = await compileElement(componentPath, {
450
- ...(projectConfig.$media ? { $media: projectConfig.$media } : {}),
481
+ const elementOptions: CompileElementOptions = {
451
482
  formats: formatRegistry,
452
483
  resolveElementPath: (refPath: string, currentDir: string | null) => {
453
484
  const depPath = currentDir ? resolve(currentDir, refPath) : resolve(refPath);
454
485
  const depTag = componentTagByPath.get(depPath);
455
486
  return depTag ? `./${depTag}.js` : refPath.replace(/\.json$/, ".js");
456
487
  },
457
- rewriteSrc: rewriteSidecarSrc,
458
- });
488
+ rewriteSrc: (specifier: string, docDir: string | null) =>
489
+ withBase(componentBase, rewriteSidecarSrc(specifier, docDir)),
490
+ };
491
+ if (projectConfig.$media) {
492
+ elementOptions.$media = projectConfig.$media;
493
+ }
494
+ const result = await compileElement(componentPath, elementOptions);
459
495
  for (const f of result.files) {
460
496
  // Name the output after the tag. `injectComponentScripts` emits
461
497
  // `<script src="/components/<tag>.js">` and the CSS sidecar is written as `<tag>.css`, so
@@ -511,9 +547,9 @@ export async function buildSite(
511
547
  }
512
548
  }
513
549
  } 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}`);
550
+ const message = errorMessage(error);
551
+ errors.push(`Error compiling component ${file}: ${message}`);
552
+ console.error(`Error compiling component ${file}: ${message}`);
517
553
  }
518
554
  }
519
555
  log(
@@ -579,15 +615,12 @@ export async function buildSite(
579
615
  const declaredKeys =
580
616
  i18n === null
581
617
  ? new Map<string, string>()
582
- : await readTranslationKeys(
583
- concreteRoutes as { sourcePath: string; urlPattern: string }[],
584
- formatRegistry,
585
- );
618
+ : await readTranslationKeys(concreteRoutes, formatRegistry);
586
619
  const keyedRoutes = concreteRoutes.map((r) => ({
587
620
  translationKey: declaredKeys.get(r.urlPattern),
588
621
  urlPattern: r.urlPattern,
589
622
  }));
590
- const alternateMap = localeAlternates(keyedRoutes, i18n, siteUrl ?? "", trailingSlash);
623
+ const alternateMap = localeAlternates(keyedRoutes, i18n, siteUrl, trailingSlash);
591
624
  /*
592
625
  * The same sets, site-absolute, for `$page.alternates`. Computed from the whole table for the
593
626
  * same reason the alternates are — and separately from them because a switcher must work in a
@@ -683,7 +716,7 @@ export async function buildSite(
683
716
  try {
684
717
  log(` Compiling ${route.urlPattern} ...`);
685
718
  const result = await compilePage(
686
- route as unknown as SiteRoute,
719
+ route,
687
720
  projectConfig,
688
721
  projectRoot,
689
722
  sections,
@@ -773,7 +806,7 @@ export async function buildSite(
773
806
  * template was, which is precisely the signal `<lastmod>` exists to give.
774
807
  */
775
808
  lastmod:
776
- typeof route.sourceMtime === "string" && route.sourceMtime !== ""
809
+ isString(route.sourceMtime) && route.sourceMtime !== ""
777
810
  ? route.sourceMtime
778
811
  : toRfc3339(statSync(route.sourcePath).mtime),
779
812
  loc: siteAbsoluteUrl(servedRoute(route.urlPattern, trailingSlash), siteUrl),
@@ -793,25 +826,26 @@ export async function buildSite(
793
826
  continue;
794
827
  }
795
828
  try {
796
- const content = (await fmt.call("serialize", result.doc, {
829
+ const content = await fmt.call("serialize", result.doc, {
797
830
  buildScope: (state: Record<string, JxStateDefinition>) =>
798
831
  buildInitialScope(state, null),
799
832
  componentDefs,
800
- evaluateTemplate: (value: string, scope: Record<string, unknown>) => {
833
+ evaluateTemplate: (value: string, scope: JxScope) => {
801
834
  if (!isTemplateString(value)) {
802
835
  return;
803
836
  }
804
837
  return evaluateStaticTemplate(value, scope) ?? value;
805
838
  },
806
839
  mode: "export",
807
- })) as string;
840
+ });
808
841
  if (content) {
809
842
  const sidecarPath = outPath.replace(/\.html$/, fmt.extensions[0]!);
810
843
  sidecars.push({ content, path: sidecarPath, type: fmt.mediaTypeEssence });
811
844
  }
812
845
  } catch (error) {
813
- const err = error as Error;
814
- errors.push(`Error exporting ${fmt.name} for ${route.urlPattern}: ${err.message}`);
846
+ errors.push(
847
+ `Error exporting ${fmt.name} for ${route.urlPattern}: ${errorMessage(error)}`,
848
+ );
815
849
  }
816
850
  }
817
851
  for (const sidecar of sidecars) {
@@ -847,8 +881,7 @@ export async function buildSite(
847
881
  fileCount += 1;
848
882
  }
849
883
  } catch (error) {
850
- const err = error as Error;
851
- const msg = `Error compiling ${route.urlPattern}: ${err.message}`;
884
+ const msg = `Error compiling ${route.urlPattern}: ${errorMessage(error)}`;
852
885
  errors.push(msg);
853
886
  console.error(msg);
854
887
  }
@@ -920,7 +953,7 @@ export async function buildSite(
920
953
  fileCount += 1;
921
954
  log(` Generated dist/${workerName} (bundled, ${deduped.size} server function(s))`);
922
955
  } catch (error) {
923
- const msg = `Error bundling dist/${workerName}: ${(error as Error).message}`;
956
+ const msg = `Error bundling dist/${workerName}: ${errorMessage(error)}`;
924
957
  errors.push(msg);
925
958
  console.error(msg);
926
959
  }
@@ -987,7 +1020,7 @@ export async function buildSite(
987
1020
  fileCount += 1;
988
1021
  log(` ${bundle.specifier} → ${assetPath}`);
989
1022
  } catch (error) {
990
- const msg = `Error bundling sidecar "${bundle.specifier}": ${(error as Error).message}`;
1023
+ const msg = `Error bundling sidecar "${bundle.specifier}": ${errorMessage(error)}`;
991
1024
  errors.push(msg);
992
1025
  console.error(msg);
993
1026
  }
@@ -1012,9 +1045,7 @@ export async function buildSite(
1012
1045
  fileCount += 1;
1013
1046
  log(` ${specifiers.length} element(s) → ${assetPath}`);
1014
1047
  } catch (error) {
1015
- const msg = `Error bundling npm $elements [${specifiers.join(", ")}]: ${
1016
- (error as Error).message
1017
- }`;
1048
+ const msg = `Error bundling npm $elements [${specifiers.join(", ")}]: ${errorMessage(error)}`;
1018
1049
  errors.push(msg);
1019
1050
  console.error(msg);
1020
1051
  }
@@ -1044,23 +1075,23 @@ export async function buildSite(
1044
1075
  // Runs later and can shadow emitted files — same semantics as sitemap.xml.
1045
1076
  for (const entry of registry.emitters()) {
1046
1077
  const sectionKey = entry.project?.key;
1047
- const sectionValue = sectionKey
1048
- ? (projectConfig as unknown as Record<string, unknown>)[sectionKey]
1049
- : undefined;
1078
+ const sectionValue = sectionKey ? projectConfig[sectionKey] : undefined;
1050
1079
  if (
1051
1080
  sectionKey &&
1052
1081
  (sectionValue == null ||
1053
- (typeof sectionValue === "object" && Object.keys(sectionValue).length === 0))
1082
+ (isNonNullObject(sectionValue) && Object.keys(sectionValue).length === 0))
1054
1083
  ) {
1055
1084
  continue; // Section owner with nothing declared — same gating as sections/mounts
1056
1085
  }
1057
1086
  try {
1058
- const emitted = (await entry.call("emit", sectionValue ?? null, {
1087
+ /* The emit contract (extensions.md §8.4) answers outDir-relative files, or null for none; each
1088
+ `path` is still checked for traversal below before anything is written. */
1089
+ const emitted = await entry.call("emit", sectionValue ?? null, {
1059
1090
  projectConfig,
1060
1091
  root: projectRoot,
1061
1092
  routes,
1062
1093
  sections,
1063
- })) as { path: string; content: string | Uint8Array }[] | null;
1094
+ });
1064
1095
  for (const file of emitted ?? []) {
1065
1096
  const target = resolve(outDir, file.path.replace(/^\//, ""));
1066
1097
  if (!target.startsWith(outDir + sep)) {
@@ -1072,7 +1103,7 @@ export async function buildSite(
1072
1103
  log(` ${entry.name} emitted ${file.path}`);
1073
1104
  }
1074
1105
  } catch (error) {
1075
- const msg = `Error in ${entry.name}.emit: ${(error as Error).message}`;
1106
+ const msg = `Error in ${entry.name}.emit: ${errorMessage(error)}`;
1076
1107
  errors.push(msg);
1077
1108
  console.error(msg);
1078
1109
  }
@@ -1170,9 +1201,12 @@ export async function buildSite(
1170
1201
  errors.push(error);
1171
1202
  console.error(error);
1172
1203
  }
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");
1204
+ // `path` is null only when there is nothing to emit; both builders always name sw.js.
1205
+ if (output.path !== null) {
1206
+ writeFileSync(join(outDir, output.path), output.source, "utf8");
1207
+ fileCount += 1;
1208
+ log(sw === false ? " sw.js — tombstone (unregisters and clears)" : " sw.js");
1209
+ }
1176
1210
  }
1177
1211
  }
1178
1212
 
@@ -1229,8 +1263,11 @@ export async function buildSite(
1229
1263
  if (projectConfig.copy) {
1230
1264
  log("Copying mapped files...");
1231
1265
  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);
1266
+ if (!isString(dest)) {
1267
+ throw new TypeError(`project.json copy["${src}"] must be a destination path string`);
1268
+ }
1269
+ const srcPath = resolve(projectRoot, src);
1270
+ const destPath = resolve(outDir, dest);
1234
1271
  mkdirSync(dirname(destPath), { recursive: true });
1235
1272
  cpSync(srcPath, destPath);
1236
1273
  }
@@ -1245,15 +1282,22 @@ export async function buildSite(
1245
1282
  return { errors, files: fileCount, routes: routes.length };
1246
1283
  }
1247
1284
 
1285
+ /** The route `compilePage` renders: a URL and the page document that produces it. */
1286
+ interface PageRoute {
1287
+ urlPattern: string;
1288
+ sourcePath: string;
1289
+ _pathParams?: Record<string, string>;
1290
+ }
1291
+
1248
1292
  /**
1249
1293
  * Compile a single page within the site build context.
1250
1294
  *
1251
1295
  * Pipeline: load JSON → resolve layout → inject context → merge head → compile
1252
1296
  *
1253
- * @param {SiteRoute} route
1297
+ * @param {PageRoute} route
1254
1298
  * @param {ProjectConfig} projectConfig
1255
1299
  * @param {string} projectRoot
1256
- * @param {Record<string, unknown>} [sections] - Extension-loaded project sections
1300
+ * @param {ProjectSections} sections - Extension-loaded project sections
1257
1301
  * @param {import("./image-cache.js").CacheManifest | null} [imageCache]
1258
1302
  * @param {Map<string, JxElement>} [componentDefs]
1259
1303
  * @param {ImageMetaCache | null} [imageMetaCache] - Set when images.service is "cloudflare"
@@ -1268,16 +1312,16 @@ export async function buildSite(
1268
1312
  * `rewriteNpmAsset`, was a function that could never run pretending to be a safety net.
1269
1313
  */
1270
1314
  async function compilePage(
1271
- route: SiteRoute,
1315
+ route: PageRoute,
1272
1316
  projectConfig: ResolvedProjectConfig,
1273
1317
  projectRoot: string,
1274
- sections: Record<string, unknown>,
1318
+ sections: ProjectSections,
1275
1319
  imageCache: CacheManifest | null,
1276
1320
  componentDefs: Map<string, JxElement>,
1277
1321
  imageMetaCache: ImageMetaCache | null,
1278
- formatRegistry: FormatRegistry | undefined,
1279
- registry: ExtensionRegistry | undefined,
1280
- rewriteSidecarSrc: ((specifier: string, docDir: string | null) => string) | undefined,
1322
+ formatRegistry: FormatRegistry,
1323
+ registry: ExtensionRegistry,
1324
+ rewriteSidecarSrc: (specifier: string, docDir: string | null) => string,
1281
1325
  assetMounts: readonly AssetMount[],
1282
1326
  extensionHead: readonly JxHeadEntry[],
1283
1327
  rewriteNpmAsset: (specifier: string) => string,
@@ -1291,14 +1335,18 @@ async function compilePage(
1291
1335
  const basePath = siteBasePath(projectConfig.url);
1292
1336
 
1293
1337
  // Load the raw page document (.json natively, other formats via the registry)
1294
- const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
1338
+ const pageDoc = await readPageDocument(route.sourcePath, formatRegistry);
1295
1339
 
1296
1340
  // Resolve layout (wraps page in layout with slot distribution)
1297
1341
  const layoutDoc = await resolveLayout(pageDoc, projectConfig, projectRoot);
1298
1342
 
1299
1343
  // Extract head arrays before they get lost in the merge
1344
+ /* SAFETY: `_pageHead` is written only by `resolveLayout`, which copies the page's `$head` (a
1345
+ JxHeadEntry[]) into it when the page has one. */
1300
1346
  const pageHead = (pageDoc.$head ?? layoutDoc._pageHead ?? []) as JxHeadEntry[];
1301
- const layoutHead = (layoutDoc.$head ?? []) as JxHeadEntry[];
1347
+ const layoutHead = layoutDoc.$head ?? [];
1348
+ /* SAFETY: `_pageTitle` is written only by `resolveLayout`, which copies the page's `title` (a
1349
+ string) into it when the page has one. */
1302
1350
  const pageTitle = (pageDoc.title ?? layoutDoc._pageTitle ?? null) as string | null;
1303
1351
 
1304
1352
  // Clean up internal properties
@@ -1315,32 +1363,31 @@ async function compilePage(
1315
1363
  */
1316
1364
  const language = pageLanguage({
1317
1365
  defaults: projectConfig.defaults,
1318
- pageDir: typeof pageDoc.$dir === "string" ? pageDoc.$dir : undefined,
1319
- pageLang: typeof pageDoc.$lang === "string" ? pageDoc.$lang : undefined,
1366
+ pageDir: isString(pageDoc.$dir) ? pageDoc.$dir : undefined,
1367
+ pageLang: isString(pageDoc.$lang) ? pageDoc.$lang : undefined,
1320
1368
  routeLocale: localeOfRoute(route.urlPattern, locale.i18n),
1321
1369
  });
1322
1370
 
1323
1371
  // Resolve generic $prototype entries via .class.json imports (and lower registry classes)
1324
1372
  await resolvePrototypes(layoutDoc, route, projectRoot, {
1325
1373
  config: projectConfig,
1374
+ registerBundle: (specifier: string) => {
1375
+ rewriteSidecarSrc(specifier, null);
1376
+ },
1377
+ registry,
1326
1378
  sections,
1327
- ...(registry === undefined ? {} : { registry }),
1328
- ...(rewriteSidecarSrc === undefined
1329
- ? {}
1330
- : {
1331
- registerBundle: (specifier: string) => {
1332
- rewriteSidecarSrc(specifier, null);
1333
- },
1334
- }),
1335
1379
  });
1336
1380
 
1337
1381
  // Build scope from resolved state so template strings in title/$head can be evaluated
1338
1382
  const scope = buildInitialScope(layoutDoc.state ?? {});
1339
1383
 
1340
1384
  // 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);
1385
+ let title = pageTitle ?? projectConfig.name;
1386
+ if (isTemplateString(title)) {
1387
+ /* SAFETY: the result stands in for the title text it was written as, and its one consumer,
1388
+ `mergeHead`, places it as the `<title>` element's only child, which `renderHead` joins into
1389
+ the markup as text, so a non-string result renders as its string form. */
1390
+ title = (evaluateStaticTemplate(title, scope) as string | null) ?? title;
1344
1391
  }
1345
1392
 
1346
1393
  // Resolve template strings in $head entries
@@ -1393,12 +1440,7 @@ async function compilePage(
1393
1440
  if (key === "$site" || key === "$page") {
1394
1441
  continue;
1395
1442
  }
1396
- if (
1397
- def &&
1398
- typeof def === "object" &&
1399
- !Array.isArray(def) &&
1400
- (def as JxMutableNode).timing === "compiler"
1401
- ) {
1443
+ if (isJsonObject(def) && def.timing === "compiler") {
1402
1444
  delete layoutDoc.state[key];
1403
1445
  } else if (Array.isArray(def)) {
1404
1446
  strippable.add(key);
@@ -1408,7 +1450,7 @@ async function compilePage(
1408
1450
  let rescued = true;
1409
1451
  while (rescued) {
1410
1452
  rescued = false;
1411
- const surviving = { ...layoutDoc.state } as Record<string, unknown>;
1453
+ const surviving = { ...layoutDoc.state };
1412
1454
  for (const key of strippable) {
1413
1455
  delete surviving[key];
1414
1456
  }
@@ -1444,7 +1486,7 @@ async function compilePage(
1444
1486
  const resolvedSiteHead = [
1445
1487
  // The manifest link and theme colour are first-party and unconditional once declared, so they
1446
1488
  // Sit with the extension contributions: below the project's own $head, which still wins.
1447
- ...(manifestHeadEntries(projectConfig) as JxHeadEntry[]),
1489
+ ...manifestHeadEntries(projectConfig),
1448
1490
  ...swHead,
1449
1491
  ...extensionHead,
1450
1492
  ...resolveHeadBareSpecifiers(projectConfig.$head ?? [], rewriteNpmAsset),
@@ -1453,9 +1495,9 @@ async function compilePage(
1453
1495
  // Merge $head from site + layout + page
1454
1496
  const mergedHead = mergeHead(resolvedSiteHead, resolvedLayoutHead, resolvedPageHead, {
1455
1497
  title,
1456
- charset: projectConfig.defaults?.charset ?? "utf8",
1457
- ...(projectConfig.name != null && { siteName: projectConfig.name }),
1458
- ...(projectConfig.url != null && { siteUrl: projectConfig.url }),
1498
+ charset: projectConfig.defaults.charset ?? "utf8",
1499
+ siteName: projectConfig.name,
1500
+ siteUrl: projectConfig.url,
1459
1501
  ...(locale.alternates.length > 0 && { alternates: locale.alternates }),
1460
1502
  /*
1461
1503
  * The canonical link and og:url name the URL the host answers with the page: the route as
@@ -1484,40 +1526,47 @@ async function compilePage(
1484
1526
  * it is governed by `images.lazyLoad`, and declining to generate variants for an image says
1485
1527
  * nothing about when the browser should fetch it.
1486
1528
  */
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
- }
1529
+ /* SAFETY: `loadProjectConfig` spreads its `images` defaults (optimize, widths, formats, quality,
1530
+ sizes, lazyLoad) under the author's block, so every member the optimizer's config requires is
1531
+ present. */
1532
+ const imageConfig = projectConfig.images as ImageConfig;
1533
+ await transformImageNodes(
1534
+ layoutDoc,
1535
+ imageConfig,
1536
+ projectRoot,
1537
+ imageCache,
1538
+ imageMetaCache ?? undefined,
1539
+ assetMounts,
1540
+ );
1497
1541
 
1498
1542
  // Compile the document using the existing compiler
1499
- const result = await compile(layoutDoc, {
1543
+ const compileOptions: CompileOptions = {
1500
1544
  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
1545
  prePaintScheme: false, // Injected via the merged <head> above, not the target template
1512
1546
  projectStyle: projectConfig.style ?? null,
1513
- ...(rewriteSidecarSrc === undefined
1514
- ? {}
1515
- : {
1516
- rewriteSrc: (specifier: string) =>
1517
- rewriteSidecarSrc(specifier, dirname(route.sourcePath as string)),
1518
- }),
1547
+ /*
1548
+ * The page's own module (`app.js`) is JavaScript the page loads, like a component: the page
1549
+ * rewrite does not read it, so its `/assets/` imports are re-rooted on the site base here.
1550
+ */
1551
+ rewriteSrc: (specifier: string) =>
1552
+ withBase(basePath, rewriteSidecarSrc(specifier, dirname(route.sourcePath))),
1519
1553
  title,
1520
- });
1554
+ };
1555
+ /*
1556
+ * The page-template tiers emit their own import map, and it defaulted to the CDN — so a page
1557
+ * that reached compile-static/compile-client kept loading its runtime from esm.sh even after
1558
+ * the self-hosting landed, because `injectComponentScripts` sees a map already present and
1559
+ * declines to add its own. Both halves have to name the same URLs.
1560
+ */
1561
+ const reactivitySrc = runtimeImports["@vue/reactivity"];
1562
+ if (reactivitySrc !== undefined) {
1563
+ compileOptions.reactivitySrc = reactivitySrc;
1564
+ }
1565
+ const litHtmlSrc = runtimeImports["lit-html"];
1566
+ if (litHtmlSrc !== undefined) {
1567
+ compileOptions.litHtmlSrc = litHtmlSrc;
1568
+ }
1569
+ const result = await compile(layoutDoc, compileOptions);
1521
1570
 
1522
1571
  // Inject CSS rules collected from component slot content
1523
1572
  if (slotCss.rules.length > 0) {
@@ -1549,10 +1598,9 @@ async function compilePage(
1549
1598
  typeof e === "string" && !e.startsWith("./") && !e.startsWith("../");
1550
1599
  const npmElements = [
1551
1600
  ...new Set(
1552
- [
1553
- ...((layoutDoc.$elements ?? []) as (JxElement | string)[]),
1554
- ...((pageDoc.$elements ?? []) as (JxElement | string)[]),
1555
- ].filter((e) => isNpmElementEntry(e)),
1601
+ [...(layoutDoc.$elements ?? []), ...(pageDoc.$elements ?? [])].filter((e) =>
1602
+ isNpmElementEntry(e),
1603
+ ),
1556
1604
  ),
1557
1605
  ];
1558
1606
  if (npmElements.length > 0) {
@@ -1561,9 +1609,9 @@ async function compilePage(
1561
1609
 
1562
1610
  // Compile server handler if applicable (skip when provider bundles site-wide)
1563
1611
  let serverHandler: string | null = null;
1564
- if (!projectConfig.build?.adapter) {
1612
+ if (!projectConfig.build.adapter) {
1565
1613
  try {
1566
- const serverResult = await compileServer(route.sourcePath as string);
1614
+ const serverResult = await compileServer(route.sourcePath);
1567
1615
  if (serverResult) {
1568
1616
  serverHandler = serverResult;
1569
1617
  }
@@ -1600,19 +1648,19 @@ async function compilePage(
1600
1648
  * @param {FormatEntry[]} activeMounts - Server-mount classes with active sections
1601
1649
  * @param {ExtensionRegistry} registry
1602
1650
  * @param {ProjectConfig} projectConfig
1603
- * @returns {{ mounts: SiteMountSpec[]; connectors: SiteConnectorSpec[] }}
1651
+ * @returns {WorkerMountSpecs}
1604
1652
  */
1605
1653
  function buildMountSpecs(
1606
1654
  activeMounts: FormatEntry[],
1607
1655
  registry: ExtensionRegistry,
1608
1656
  projectConfig: ProjectConfig,
1609
- ): { mounts: SiteMountSpec[]; connectors: SiteConnectorSpec[] } {
1657
+ ): WorkerMountSpecs {
1610
1658
  if (activeMounts.length === 0) {
1611
1659
  return { connectors: [], mounts: [] };
1612
1660
  }
1613
1661
  const base = siteBasePath(projectConfig.url);
1614
1662
 
1615
- const sections: Record<string, unknown> = {};
1663
+ const sections: Record<string, ProjectConfig[string]> = {};
1616
1664
  for (const contribution of registry.projectContributions()) {
1617
1665
  const { key } = contribution.project!;
1618
1666
  if (key in projectConfig) {
@@ -1623,7 +1671,7 @@ function buildMountSpecs(
1623
1671
  const mounts: SiteMountSpec[] = activeMounts.map((entry) => {
1624
1672
  const server = entry.server!;
1625
1673
  const { module } = server;
1626
- if (typeof module !== "string" || module === "") {
1674
+ if (!isString(module) || module === "") {
1627
1675
  throw new Error(
1628
1676
  `Extension class "${entry.name}": server.module (a bare import specifier) is required ` +
1629
1677
  `to generate a deployable worker`,
@@ -1635,7 +1683,7 @@ function buildMountSpecs(
1635
1683
  const mountBase = withBase(base, server.basePath);
1636
1684
  return {
1637
1685
  basePath: mountBase,
1638
- className: (entry.classDef.title as string | undefined) ?? entry.name,
1686
+ className: entry.classDef.title ?? entry.name,
1639
1687
  module,
1640
1688
  options: { basePath: mountBase, sections },
1641
1689
  order: server.order ?? 100,
@@ -1646,12 +1694,12 @@ function buildMountSpecs(
1646
1694
  // Vocabulary, §12) marks that provider's class for import.
1647
1695
  const usedProviders = new Set<string>();
1648
1696
  for (const value of Object.values(sections)) {
1649
- if (!value || typeof value !== "object" || Array.isArray(value)) {
1697
+ if (!isJsonObject(value)) {
1650
1698
  continue;
1651
1699
  }
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") {
1700
+ for (const sectionEntry of Object.values(value)) {
1701
+ const provider = isJsonObject(sectionEntry) ? sectionEntry.provider : undefined;
1702
+ if (isString(provider)) {
1655
1703
  usedProviders.add(provider);
1656
1704
  }
1657
1705
  }
@@ -1664,14 +1712,14 @@ function buildMountSpecs(
1664
1712
  continue;
1665
1713
  }
1666
1714
  const { module } = entry.connector!;
1667
- if (typeof module !== "string" || module === "") {
1715
+ if (!isString(module) || module === "") {
1668
1716
  throw new Error(
1669
1717
  `Connector class "${entry.name}": connector.module (a bare import specifier) is ` +
1670
1718
  `required to generate a deployable worker`,
1671
1719
  );
1672
1720
  }
1673
1721
  connectors.push({
1674
- className: (entry.classDef.title as string | undefined) ?? entry.name,
1722
+ className: entry.classDef.title ?? entry.name,
1675
1723
  module,
1676
1724
  provider,
1677
1725
  });
@@ -1680,6 +1728,12 @@ function buildMountSpecs(
1680
1728
  return { connectors, mounts };
1681
1729
  }
1682
1730
 
1731
+ /** What the generated worker imports and mounts, as `buildMountSpecs` derives it. */
1732
+ interface WorkerMountSpecs {
1733
+ mounts: SiteMountSpec[];
1734
+ connectors: SiteConnectorSpec[];
1735
+ }
1736
+
1683
1737
  /**
1684
1738
  * Gather every extension's `<head>` contribution, once, before the first page is built.
1685
1739
  *
@@ -1696,72 +1750,89 @@ async function collectExtensionHead(
1696
1750
  const out: JxHeadEntry[] = [];
1697
1751
  for (const entry of registry.headProviders()) {
1698
1752
  const key = entry.project?.key;
1699
- const sectionValue = key === undefined ? null : (projectConfig as Record<string, unknown>)[key];
1753
+ const sectionValue = key === undefined ? null : projectConfig[key];
1700
1754
  if (key !== undefined && (sectionValue === undefined || sectionValue === null)) {
1701
1755
  continue;
1702
1756
  }
1703
1757
  try {
1704
- const contributed = (await entry.call("head", sectionValue ?? null, {
1758
+ const contributed = await entry.call("head", sectionValue ?? null, {
1705
1759
  projectConfig,
1706
1760
  root: projectRoot,
1707
- })) as JxHeadEntry[] | null;
1761
+ });
1708
1762
  out.push(...(contributed ?? []));
1709
1763
  } catch (error) {
1710
- console.warn(`${entry.name} head capability failed: ${(error as Error).message}`);
1764
+ console.warn(`${entry.name} head capability failed: ${errorMessage(error)}`);
1711
1765
  }
1712
1766
  }
1713
1767
  return out;
1714
1768
  }
1715
1769
 
1716
1770
  /**
1717
- * Resolve template strings in $head entries against the compiled scope.
1771
+ * Resolve template strings anywhere inside a structured data block (a JSON-LD `textContent`).
1718
1772
  *
1719
- * @param {JxHeadEntry[]} headEntries
1720
- * @param {Record<string, unknown>} scope
1721
- * @returns {JxHeadEntry[]}
1773
+ * @param {JsonValue} value
1774
+ * @param {JxScope} scope
1775
+ * @returns {JsonValue}
1722
1776
  */
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
- }
1777
+ function resolveTemplatesDeep(value: JsonValue, scope: JxScope): JsonValue {
1727
1778
  if (Array.isArray(value)) {
1728
1779
  return value.map((v) => resolveTemplatesDeep(v, scope));
1729
1780
  }
1730
- if (value !== null && typeof value === "object") {
1781
+ if (isJsonObject(value)) {
1731
1782
  return Object.fromEntries(
1732
1783
  Object.entries(value).map(([k, v]) => [k, resolveTemplatesDeep(v, scope)]),
1733
1784
  );
1734
1785
  }
1786
+ if (isTemplateString(value)) {
1787
+ /* SAFETY: the block is JSON that `renderHead` serializes with JSON.stringify, and the result
1788
+ takes the place of the string it was written as, so it is serialized exactly as a value
1789
+ authored at that position would be. */
1790
+ return (evaluateStaticTemplate(value, scope) ?? value) as JsonValue;
1791
+ }
1735
1792
  return value;
1736
1793
  }
1737
1794
 
1738
- function resolveHeadTemplates(headEntries: JxHeadEntry[], scope: Record<string, unknown>) {
1795
+ /**
1796
+ * Resolve template strings in $head entries against the compiled scope.
1797
+ *
1798
+ * @param {JxHeadEntry[]} headEntries
1799
+ * @param {JxScope} scope
1800
+ * @returns {JxHeadEntry[]}
1801
+ */
1802
+ function resolveHeadTemplates(headEntries: JxHeadEntry[], scope: JxScope) {
1739
1803
  return headEntries.map((entry: JxHeadEntry) => {
1740
- if (!entry || typeof entry !== "object") {
1804
+ // A document's `$head` is authored JSON, so a member is not guaranteed to be an entry object.
1805
+ if (!isNonNullObject(entry)) {
1741
1806
  return entry;
1742
1807
  }
1743
1808
  const resolved = { ...entry };
1744
1809
  if (resolved.attributes) {
1745
1810
  resolved.attributes = { ...resolved.attributes };
1746
1811
  for (const [k, v] of Object.entries(resolved.attributes)) {
1747
- if (typeof v === "string" && isTemplateString(v)) {
1812
+ if (isTemplateString(v)) {
1813
+ /* SAFETY: the result replaces the attribute value it was written as, and `renderHead`
1814
+ writes every attribute value other than `true` through `String()`. */
1748
1815
  resolved.attributes[k] =
1749
- (evaluateStaticTemplate(v, scope) as string | boolean | null) ??
1750
- (v as string | boolean);
1816
+ (evaluateStaticTemplate(v, scope) as string | boolean | null) ?? v;
1751
1817
  }
1752
1818
  }
1753
1819
  }
1754
- if (typeof resolved.textContent === "string" && isTemplateString(resolved.textContent)) {
1820
+ if (isTemplateString(resolved.textContent)) {
1821
+ /* SAFETY: the result replaces the text it was written as, and `renderHead` interpolates a
1822
+ non-object `textContent` into the markup as text (an object one is serialized as JSON). */
1755
1823
  resolved.textContent =
1756
1824
  (evaluateStaticTemplate(resolved.textContent, scope) as string | null) ??
1757
1825
  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
- >;
1826
+ } else if (isNonNullObject(resolved.textContent)) {
1827
+ /*
1828
+ * A structured data block (§8.5): templates inside it resolve too, or a JSON-LD object could
1829
+ * never reference the page it describes.
1830
+ *
1831
+ * SAFETY: `resolveTemplatesDeep` keeps a container's shape (an object resolves to an object,
1832
+ * an array to an array, and JSON-LD admits either at the top), and `renderHead` serializes
1833
+ * any object `textContent` with JSON.stringify.
1834
+ */
1835
+ resolved.textContent = resolveTemplatesDeep(resolved.textContent, scope) as JsonObject;
1765
1836
  }
1766
1837
  return resolved;
1767
1838
  });
@@ -1782,13 +1853,14 @@ function resolveHeadBareSpecifiers(
1782
1853
  rewrite: (specifier: string) => string,
1783
1854
  ) {
1784
1855
  return headEntries.map((entry: JxHeadEntry) => {
1785
- if (!entry || typeof entry !== "object" || !entry.attributes) {
1856
+ // Authored JSON, as in `resolveHeadTemplates`: a member is not guaranteed to be an object.
1857
+ if (!isNonNullObject(entry) || !entry.attributes) {
1786
1858
  return entry;
1787
1859
  }
1788
1860
  const resolved = { ...entry, attributes: { ...entry.attributes } };
1789
1861
  for (const key of ["href", "src"]) {
1790
1862
  const val = resolved.attributes[key];
1791
- if (typeof val === "string" && isBareSpecifier(val)) {
1863
+ if (isString(val) && isBareSpecifier(val)) {
1792
1864
  resolved.attributes[key] = rewrite(val);
1793
1865
  }
1794
1866
  }
@@ -1843,59 +1915,70 @@ function inertAttribute(resolved: string): string {
1843
1915
  * Deep-clone a map template, resolving template strings and $ref values against the given scope.
1844
1916
  *
1845
1917
  * @param {JxElement} template
1846
- * @param {Record<string, unknown>} scope
1918
+ * @param {JxScope} scope
1847
1919
  * @returns {JxElement}
1848
1920
  */
1849
- function expandMapTemplate(template: JxElement, scope: Record<string, unknown>): JxElement {
1850
- if (!template || typeof template !== "object") {
1921
+ function expandMapTemplate(template: JxElement, scope: JxScope): JxElement {
1922
+ // A map template is authored JSON, so a child reached by recursion may not be an object.
1923
+ if (!isNonNullObject(template)) {
1851
1924
  return template;
1852
1925
  }
1853
- const node = {} as JxElement;
1926
+ const node: JxElement = {};
1854
1927
  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") {
1928
+ // Each branch reads `template[k]` through its declared member, which is the same value as `v`.
1929
+ if (k === "children" && Array.isArray(template.children)) {
1930
+ node.children = template.children.map((child) => {
1931
+ if (isString(child)) {
1858
1932
  return child;
1859
1933
  }
1860
1934
  return expandMapTemplate(child, scope);
1861
1935
  });
1862
- } else if (k === "style" && v && typeof v === "object") {
1863
- const style: JxStyle = { ...(v as JxStyle) };
1936
+ } else if (k === "style" && isNonNullObject(template.style)) {
1937
+ const style: JxStyle = { ...template.style };
1864
1938
  for (const [sk, sv] of Object.entries(style)) {
1865
1939
  if (isTemplateString(sv)) {
1866
- // Template evaluation yields a substituted scalar for style values.
1940
+ /* SAFETY: template evaluation yields a substituted scalar for the declaration it was
1941
+ written as, and the style compilers interpolate declaration values into CSS text. */
1867
1942
  style[sk] = (evaluateMapTemplate(sv, scope) as string | number | undefined) ?? sv;
1868
1943
  }
1869
1944
  }
1870
1945
  node.style = style;
1871
- } else if (k === "attributes" && v && typeof v === "object") {
1872
- const attrs = { ...(v as Record<string, JxAttributeValue>) };
1946
+ } else if (k === "attributes" && isNonNullObject(template.attributes)) {
1947
+ const attrs = { ...template.attributes };
1873
1948
  for (const [ak, av] of Object.entries(attrs)) {
1874
1949
  if (isTemplateString(av)) {
1950
+ /* SAFETY: the result replaces the attribute value it was written as, and the compile
1951
+ targets write attribute values into markup as their string form. */
1875
1952
  const resolved = evaluateMapTemplate(av, scope) as JxAttributeValue | undefined;
1876
1953
  attrs[ak] =
1877
- typeof resolved === "string" && resolved.includes("${")
1954
+ isString(resolved) && resolved.includes("${")
1878
1955
  ? inertAttribute(resolved)
1879
1956
  : (resolved ?? av);
1880
1957
  }
1881
1958
  }
1882
1959
  node.attributes = attrs;
1883
- } else if (k === "$props" && v && typeof v === "object") {
1884
- const props = { ...(v as Record<string, JsonValue>) };
1960
+ } else if (k === "$props" && isNonNullObject(template.$props)) {
1961
+ const props = { ...template.$props };
1885
1962
  for (const [pk, pv] of Object.entries(props)) {
1886
1963
  if (isTemplateString(pv)) {
1964
+ /* SAFETY: props leave the build only as JSON (`expandComponents` writes them into
1965
+ `data-jx-props` with JSON.stringify), so a result is serialized exactly as a value
1966
+ authored at that prop would be. */
1887
1967
  const resolved = evaluateMapTemplate(pv, scope) as JsonValue | undefined;
1888
1968
  // Null = evaluation error → keep template string; undefined = missing data → use null
1889
1969
  props[pk] = resolved !== null ? (resolved ?? null) : pv;
1890
1970
  }
1891
1971
  }
1892
1972
  node.$props = props;
1893
- } else if (typeof v === "string" && isTemplateString(v)) {
1973
+ } else if (isTemplateString(v)) {
1894
1974
  const resolved = evaluateMapTemplate(v, scope);
1895
- if (k === "textContent" && typeof resolved === "string" && resolved.includes("${")) {
1975
+ if (k === "textContent" && isString(resolved) && resolved.includes("${")) {
1896
1976
  node.innerHTML = inertTextHtml(resolved);
1897
1977
  } else {
1898
- node[k] = resolved ?? v;
1978
+ /* SAFETY: the result takes the place of the template string at the same key, which is the
1979
+ substitution a map template asks for; a member the compile targets emit as text or an
1980
+ attribute is interpolated there as its string form. */
1981
+ node[k] = (resolved ?? v) as JxElement[string];
1899
1982
  }
1900
1983
  } else {
1901
1984
  node[k] = v;
@@ -1909,29 +1992,21 @@ function expandMapTemplate(template: JxElement, scope: Record<string, unknown>):
1909
1992
  * `state`, and `$map` as local variables.
1910
1993
  *
1911
1994
  * @param {string} str
1912
- * @param {Record<string, unknown>} scope
1913
- * @returns {unknown}
1995
+ * @param {JxScope} scope
1996
+ * @returns {JxScope[string]} Whatever the expression yields, or null when it throws
1914
1997
  */
1915
- function evaluateMapTemplate(str: string, scope: Record<string, unknown>) {
1998
+ function evaluateMapTemplate(str: string, scope: JxScope): JxScope[string] {
1916
1999
  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);
2000
+ const frame = scope.$map;
2001
+ const item = isNonNullObject(frame) && "item" in frame ? frame.item : undefined;
2002
+ const index = isNonNullObject(frame) && "index" in frame ? frame.index : undefined;
2003
+ const body = isSingleExpression(str) ? `return (${str.slice(2, -1)})` : `return \`${str}\``;
2004
+ /* SAFETY: `new Function` compiles exactly the four named parameters, and its body returns one
2005
+ expression's value, which may be any scope value. */
2006
+ const fn = new Function("state", "$map", "item", "index", body) as (
2007
+ ...args: JxScope[string][]
2008
+ ) => JxScope[string];
2009
+ return fn(scope, frame, item, index);
1935
2010
  } catch {
1936
2011
  return null;
1937
2012
  }
@@ -1942,14 +2017,15 @@ function evaluateMapTemplate(str: string, scope: Record<string, unknown>) {
1942
2017
  * place — evaluates ${...} in innerHTML, textContent, style values, and attribute values.
1943
2018
  *
1944
2019
  * @param {JxElement | string} node
1945
- * @param {Record<string, unknown>} scope
2020
+ * @param {JxScope} scope
1946
2021
  */
1947
- function resolveDocTemplates(node: JxElement | string, scope: Record<string, unknown>) {
1948
- if (!node || typeof node !== "object") {
2022
+ function resolveDocTemplates(node: JxElement | string, scope: JxScope) {
2023
+ // Children are authored JSON, so a member reached by recursion may be neither node nor string.
2024
+ if (!isNonNullObject(node)) {
1949
2025
  return;
1950
2026
  }
1951
2027
 
1952
- if (typeof node.innerHTML === "string" && isTemplateString(node.innerHTML)) {
2028
+ if (isTemplateString(node.innerHTML)) {
1953
2029
  const resolved = evaluateStaticTemplate(node.innerHTML, scope);
1954
2030
  if (resolved != null) {
1955
2031
  // Encode any remaining `${` as HTML entities so the compile phase won't
@@ -1958,44 +2034,55 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
1958
2034
  node.innerHTML = String(resolved).replaceAll("${", "&#36;{");
1959
2035
  }
1960
2036
  }
1961
- if (typeof node.textContent === "string" && isTemplateString(node.textContent)) {
1962
- const resolved = evaluateStaticTemplate(node.textContent, scope);
1963
- if (typeof resolved === "string" && resolved.includes("${")) {
2037
+ const text = node.textContent;
2038
+ if (isTemplateString(text)) {
2039
+ const resolved = evaluateStaticTemplate(text, scope);
2040
+ if (isString(resolved) && resolved.includes("${")) {
1964
2041
  // The resolved value is data (an excerpt, a heading, a title) that happens to contain `${`, not
1965
2042
  // A template. Left as `textContent` the compile phase would read it as one again and bind it
1966
2043
  // To an expression that throws or renders nothing, so it stays text as escaped `innerHTML`.
1967
2044
  node.innerHTML = inertTextHtml(resolved);
1968
2045
  delete node.textContent;
1969
2046
  } else {
1970
- node.textContent = (resolved as string | null) ?? (node.textContent as string | null);
2047
+ /* SAFETY: the result is the text the template stands for, and the compile targets
2048
+ interpolate `textContent` into the markup, so a non-string result renders as its string
2049
+ form. */
2050
+ node.textContent = (resolved as string | null) ?? text;
1971
2051
  }
1972
2052
  }
1973
- if (node.style && typeof node.style === "object") {
2053
+ if (isNonNullObject(node.style)) {
1974
2054
  for (const [k, v] of Object.entries(node.style)) {
1975
- if (typeof v === "string" && isTemplateString(v)) {
2055
+ if (isTemplateString(v)) {
2056
+ /* SAFETY: the result replaces the declaration value it was written as, and the page style
2057
+ pass interpolates declaration values into CSS text. */
1976
2058
  node.style[k] =
1977
- (evaluateStaticTemplate(v, scope) as string | number | JxStyle | undefined) ??
1978
- (v as string | number | JxStyle);
2059
+ (evaluateStaticTemplate(v, scope) as string | number | JxStyle | undefined) ?? v;
1979
2060
  }
1980
2061
  }
1981
2062
  }
1982
- if (node.attributes && typeof node.attributes === "object") {
2063
+ if (isNonNullObject(node.attributes)) {
1983
2064
  for (const [k, v] of Object.entries(node.attributes)) {
1984
- if (typeof v === "string" && isTemplateString(v)) {
2065
+ if (isTemplateString(v)) {
2066
+ /* SAFETY: the result replaces the attribute value it was written as, and the compile
2067
+ targets write attribute values into markup as their string form. */
1985
2068
  const resolved = evaluateStaticTemplate(v, scope) as JxAttributeValue | null;
1986
2069
  // A resolved value is data: a `${` left in it must not become a template a second time. An
1987
2070
  // Entity cannot be written into an attribute, so the sequence is split with a zero-width space.
1988
2071
  node.attributes[k] =
1989
- typeof resolved === "string" && resolved.includes("${")
2072
+ isString(resolved) && resolved.includes("${")
1990
2073
  ? inertAttribute(resolved)
1991
2074
  : (resolved ?? v);
1992
2075
  }
1993
2076
  }
1994
2077
  }
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;
2078
+ if (isNonNullObject(node.$props)) {
2079
+ const props = node.$props;
2080
+ for (const [k, v] of Object.entries(props)) {
2081
+ if (isTemplateString(v)) {
2082
+ /* SAFETY: props leave the build only as JSON (`expandComponents` writes them into
2083
+ `data-jx-props` with JSON.stringify), so a result is serialized exactly as a value
2084
+ authored at that prop would be. */
2085
+ props[k] = (evaluateStaticTemplate(v, scope) as JsonValue | null) ?? v;
1999
2086
  }
2000
2087
  }
2001
2088
  }
@@ -2013,9 +2100,11 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
2013
2100
  return;
2014
2101
  }
2015
2102
  }
2016
- if (typeof rawChildren === "string" && isTemplateString(rawChildren)) {
2103
+ if (isTemplateString(rawChildren)) {
2017
2104
  const resolved = evaluateStaticTemplate(rawChildren, scope);
2018
2105
  if (Array.isArray(resolved)) {
2106
+ /* SAFETY: a children template that yields an array stands for the child list itself, and
2107
+ every member is walked by `resolveDocTemplates` below, which skips non-objects. */
2019
2108
  const resolvedNodes = resolved as (JxElement | string)[];
2020
2109
  node.children = resolvedNodes;
2021
2110
  for (const child of resolvedNodes) {
@@ -2026,9 +2115,11 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
2026
2115
  let i = 0;
2027
2116
  while (i < node.children.length) {
2028
2117
  const child = node.children[i]!;
2029
- if (typeof child === "string" && isTemplateString(child)) {
2118
+ if (isTemplateString(child)) {
2030
2119
  const resolved = evaluateStaticTemplate(child, scope);
2031
2120
  if (Array.isArray(resolved)) {
2121
+ /* SAFETY: a child template that yields an array stands for the children it splices in,
2122
+ and every member is walked by `resolveDocTemplates` below, which skips non-objects. */
2032
2123
  const resolvedNodes = resolved as (JxElement | string)[];
2033
2124
  node.children.splice(i, 1, ...resolvedNodes);
2034
2125
  for (const spliced of resolvedNodes) {
@@ -2072,26 +2163,25 @@ function referencesStateKey(haystack: string, key: string): boolean {
2072
2163
  * build time, or null otherwise (leave the array node for client-side rendering).
2073
2164
  *
2074
2165
  * @param {JxMappedArray} arrayDef
2075
- * @param {Record<string, unknown>} scope
2166
+ * @param {JxScope} scope
2076
2167
  * @returns {(JxElement | string)[] | null}
2077
2168
  */
2078
2169
  function expandMappedArrayStatic(
2079
2170
  arrayDef: JxMappedArray,
2080
- scope: Record<string, unknown>,
2171
+ scope: JxScope,
2081
2172
  ): (JxElement | string)[] | null {
2082
2173
  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
- }
2174
+ // A non-array, non-ref `items` falls through to the array check below and expands to nothing.
2175
+ const items = isRef(itemsSrc) ? resolveRefValue(itemsSrc.$ref, scope) : itemsSrc;
2089
2176
  const mapTemplate = arrayDef.map;
2090
2177
  if (!Array.isArray(items) || !mapTemplate) {
2091
2178
  return null;
2092
2179
  }
2093
- return (items as unknown[]).map((item: unknown, index) => {
2094
- const childScope = Object.create(scope) as Record<string, unknown>;
2180
+ const rows: readonly JxScope[string][] = items;
2181
+ return rows.map((item, index) => {
2182
+ /* SAFETY: `Object.create(scope)` is a new object with no own keys whose prototype is `scope`,
2183
+ so every key it answers is a binding of `scope` until the row's own are set below. */
2184
+ const childScope = Object.create(scope) as JxScope;
2095
2185
  childScope.$map = { index, item };
2096
2186
  childScope["$map/item"] = item;
2097
2187
  childScope["$map/index"] = index;
@@ -2113,22 +2203,23 @@ interface SlotCssCollector {
2113
2203
  * preRenderComponentHtml, marks static/prerendered. Slot children get their static styles collected
2114
2204
  * into `slotCss` (assigning jxs-N classes) before being serialized to HTML.
2115
2205
  *
2116
- * @param {JxElement | string} node
2206
+ * @param {JxElement | string | (JxElement | string)[]} node - A nested array (authored JSON can
2207
+ * carry one among children) is walked member by member
2117
2208
  * @param {Map<string, JxElement>} componentDefs
2118
2209
  * @param {SlotCssCollector} [slotCss]
2119
2210
  * @param {ProjectConfig["defaults"]} [defaults] - Read for `defaults.shadow` (spec.md §16.6)
2120
2211
  */
2121
2212
  function expandComponents(
2122
- node: JxElement | string,
2213
+ node: JxElement | string | (JxElement | string)[],
2123
2214
  componentDefs: Map<string, JxElement>,
2124
2215
  slotCss?: SlotCssCollector,
2125
2216
  defaults?: ProjectConfig["defaults"],
2126
2217
  ) {
2127
- if (!node || typeof node !== "object") {
2218
+ if (!isNonNullObject(node)) {
2128
2219
  return;
2129
2220
  }
2130
2221
  if (Array.isArray(node)) {
2131
- for (const n of node as (JxElement | string)[]) {
2222
+ for (const n of node) {
2132
2223
  expandComponents(n, componentDefs, slotCss, defaults);
2133
2224
  }
2134
2225
  return;
@@ -2141,7 +2232,12 @@ function expandComponents(
2141
2232
  }
2142
2233
  }
2143
2234
 
2144
- const def = componentDefs.get(node.tagName as string);
2235
+ // Only a literal tag can name a registered component; a tag expression never matches one.
2236
+ const { tagName } = node;
2237
+ if (!isString(tagName)) {
2238
+ return;
2239
+ }
2240
+ const def = componentDefs.get(tagName);
2145
2241
  if (def) {
2146
2242
  // Literal `props.*` attribute keys become $props (`liftPropsAttributes` says why) and leave the
2147
2243
  // Attributes, so they do not reach the emitted HTML. Explicit $props wins on key conflicts.
@@ -2164,7 +2260,7 @@ function expandComponents(
2164
2260
  Array.isArray(node.children) && node.children.length > 0
2165
2261
  ? node.children
2166
2262
  .map((c: JxElement | string) => {
2167
- if (slotCss && c && typeof c === "object") {
2263
+ if (slotCss && isNonNullObject(c)) {
2168
2264
  collectStyles(c, slotCss.rules, slotCss.media, "", slotCss.counter, "jxs");
2169
2265
  }
2170
2266
  return renderStaticNode(c, {}, null);
@@ -2185,7 +2281,7 @@ function expandComponents(
2185
2281
  const context: ComponentPrerenderContext = {
2186
2282
  componentDefs,
2187
2283
  defaults,
2188
- path: [{ key: JSON.stringify(props ?? {}), tag: node.tagName as string }],
2284
+ path: [{ key: JSON.stringify(props ?? {}), tag: tagName }],
2189
2285
  };
2190
2286
  const innerHTML = preRenderComponentHtml(def, props, shadow ? null : slotContent, context);
2191
2287
  const isStatic = isComponentFullyStatic(def);
@@ -2198,7 +2294,7 @@ function expandComponents(
2198
2294
  */
2199
2295
  node.innerHTML = shadow
2200
2296
  ? `<template shadowrootmode="${shadow}">` +
2201
- `<link rel="stylesheet" href="/components/${node.tagName}.css">` +
2297
+ `<link rel="stylesheet" href="/components/${tagName}.css">` +
2202
2298
  `${innerHTML}</template>${slotContent ?? ""}`
2203
2299
  : innerHTML;
2204
2300
  delete node.children;
@@ -2216,6 +2312,9 @@ function expandComponents(
2216
2312
  */
2217
2313
  const resolvedStyle = resolveHostStyle(def.style, buildInstanceScope(def, props));
2218
2314
  if (Object.keys(resolvedStyle).length > 0) {
2315
+ /* SAFETY: every value in `resolvedStyle` replaces a template-string declaration of
2316
+ `def.style`, and the page style pass interpolates declaration values into CSS text, so the
2317
+ merged object is a style in every way the build reads one. */
2219
2318
  node.style = { ...node.style, ...resolvedStyle } as JxStyle;
2220
2319
  }
2221
2320
 
@@ -2293,8 +2392,10 @@ function injectComponentScripts(
2293
2392
  * Emitted last in <head>, exactly where the links were, so the cascade is unchanged.
2294
2393
  */
2295
2394
  const componentCss = usedTags
2296
- .map((tag: string) => cssMap.get(tag))
2297
- .filter((css): css is string => css !== undefined && css !== "")
2395
+ .flatMap((tag: string) => {
2396
+ const css = cssMap.get(tag);
2397
+ return css === undefined || css === "" ? [] : [css];
2398
+ })
2298
2399
  .join("\n");
2299
2400
  const styleBlock =
2300
2401
  componentCss === "" ? "" : `<style>\n${escapeStyleText(componentCss)}\n </style>`;
@@ -2480,25 +2581,41 @@ function routeToOutputPath(urlPattern: string, outDir: string, trailingSlash: st
2480
2581
  * An HTML meta-refresh is a _client-side_ redirect: the browser fetches the source, then navigates.
2481
2582
  * That is a reasonable stand-in for a 301 or a 303 on a host that ignores `_redirects`, and it is
2482
2583
  * actively wrong for everything else.
2584
+ *
2585
+ * Keyed by the status's decimal string so an authored `"status": "301"` (schema-invalid, but
2586
+ * project.json is not validated at build time) still matches the way an object-key lookup would.
2483
2587
  */
2484
- const REDIRECT_HTML_POLICY: Record<number, { html: boolean; canonical: boolean; why: string }> = {
2588
+ const REDIRECT_HTML_POLICY = new Map<string, RedirectHtmlPolicy>([
2485
2589
  // The permanent case: a canonical link is exactly the right signal.
2486
- 301: { canonical: true, html: true, why: "" },
2590
+ ["301", { canonical: true, html: true, why: "" }],
2487
2591
  // Temporary — a canonical link would assert the permanence the status denies.
2488
- 302: { canonical: false, html: true, why: "" },
2592
+ ["302", { canonical: false, html: true, why: "" }],
2489
2593
  // "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
- };
2594
+ ["303", { canonical: false, html: true, why: "" }],
2595
+ [
2596
+ "307",
2597
+ {
2598
+ canonical: false,
2599
+ html: false,
2600
+ why: "307 preserves the request method and body; a meta-refresh silently converts POST to GET",
2601
+ },
2602
+ ],
2603
+ [
2604
+ "308",
2605
+ {
2606
+ canonical: false,
2607
+ html: false,
2608
+ why: "308 preserves the request method and body; a meta-refresh silently converts POST to GET",
2609
+ },
2610
+ ],
2611
+ ]);
2612
+
2613
+ /** One redirection status's HTML-fallback rule, in {@link REDIRECT_HTML_POLICY}. */
2614
+ interface RedirectHtmlPolicy {
2615
+ html: boolean;
2616
+ canonical: boolean;
2617
+ why: string;
2618
+ }
2502
2619
 
2503
2620
  /**
2504
2621
  * Emit `dist/_redirects` plus an HTML fallback for the literal sources whose status has one.
@@ -2508,7 +2625,7 @@ const REDIRECT_HTML_POLICY: Record<number, { html: boolean; canonical: boolean;
2508
2625
  * string | { destination: string; status?: number } | { destination: string; rewrite: true }
2509
2626
  * >} redirects
2510
2627
  * @param {string} outDir
2511
- * @param {{ compiledUrls?: Set<string>; trailingSlash?: string }} [opts]
2628
+ * @param {{ compiledUrls?: Set<string>; trailingSlash?: string | null }} [opts]
2512
2629
  * @returns {{ files: number; errors: string[] }}
2513
2630
  */
2514
2631
  function generateRedirects(
@@ -2517,7 +2634,7 @@ function generateRedirects(
2517
2634
  string | { destination: string; status?: number } | { destination: string; rewrite: true }
2518
2635
  >,
2519
2636
  outDir: string,
2520
- opts: { compiledUrls?: Set<string>; trailingSlash?: string; basePath?: string } = {},
2637
+ opts: { compiledUrls?: Set<string>; trailingSlash?: string | null; basePath?: string } = {},
2521
2638
  ) {
2522
2639
  let files = 0;
2523
2640
  const errors: string[] = [];
@@ -2527,18 +2644,18 @@ function generateRedirects(
2527
2644
  const trailingSlash = opts.trailingSlash ?? "always";
2528
2645
 
2529
2646
  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;
2647
+ const dest = isString(target) ? target : target.destination;
2648
+ const isRewrite = !isString(target) && "rewrite" in target && target.rewrite;
2532
2649
  const status = isRewrite
2533
2650
  ? 200
2534
- : typeof target === "object" && "status" in target
2651
+ : !isString(target) && "status" in target
2535
2652
  ? (target.status ?? 301)
2536
2653
  : 301;
2537
2654
 
2538
- if (!isRewrite && REDIRECT_HTML_POLICY[status] === undefined) {
2655
+ if (!isRewrite && !REDIRECT_HTML_POLICY.has(String(status))) {
2539
2656
  errors.push(
2540
2657
  `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 ` +
2658
+ `status. Use one of ${[...REDIRECT_HTML_POLICY.keys()].join(", ")}, or ` +
2542
2659
  `{ destination, rewrite: true } for a proxy.`,
2543
2660
  );
2544
2661
  continue;
@@ -2571,7 +2688,7 @@ function generateRedirects(
2571
2688
  if (isRewrite) {
2572
2689
  continue;
2573
2690
  }
2574
- const policy = REDIRECT_HTML_POLICY[status]!;
2691
+ const policy = REDIRECT_HTML_POLICY.get(String(status))!;
2575
2692
  if (!policy.html) {
2576
2693
  continue;
2577
2694
  }