@jxsuite/compiler 1.4.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,16 +20,28 @@ import {
20
20
  } from "node:fs";
21
21
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
22
22
  import { isMappedArray, isRef } from "@jxsuite/schema/guards";
23
- import { isNpmSpecifier, sidecarAssetPath } from "@jxsuite/schema/asset-paths";
23
+ import { isNpmSpecifier, npmAssetPath, sidecarAssetPath } from "@jxsuite/schema/asset-paths";
24
24
  import {
25
25
  CLIENT_EXTERNALS,
26
26
  bundleEntry,
27
27
  bundleWorkerSource,
28
28
  isBundleableSrc,
29
+ bundleSource,
29
30
  resolveSidecarEntry,
30
31
  } from "./bundler.ts";
31
32
  import { loadProjectConfig } from "./site-loader.ts";
32
- import { discoverPages, expandDynamicRoutes, readPageDocument } from "./pages-discovery.ts";
33
+ import {
34
+ discoverPages,
35
+ expandDynamicRoutes,
36
+ readPageDocument,
37
+ readTranslationKeys,
38
+ } from "./pages-discovery.ts";
39
+ import {
40
+ buildHeaderRules,
41
+ contentTypeRules,
42
+ writeHeaders,
43
+ writeNoJekyll,
44
+ } from "./headers-emitter.ts";
33
45
  import { buildProjectExtensionRegistry } from "./format-host.ts";
34
46
  import { loadProjectSections } from "./project-sections.ts";
35
47
  import { collectAssetRefs, copyMountedAssets, loadAssetMounts } from "./asset-mounts.ts";
@@ -38,13 +50,12 @@ import type { FormatEntry, FormatRegistry } from "@jxsuite/schema/format-registr
38
50
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
39
51
  import { resolveLayout } from "./layout-resolver.ts";
40
52
  import { mergeHead, renderHead } from "./head-merger.ts";
53
+ import { unregisteredHeadRelations } from "./link-relations.ts";
41
54
  import { injectContext } from "./context-injection.ts";
42
55
  import { compile, compileServer, compileSiteServer } from "../compiler.ts";
43
56
  import { compileElement } from "../targets/compile-element.ts";
44
57
  import type { SiteConnectorSpec, SiteMountSpec } from "../targets/compile-server.ts";
45
58
  import {
46
- DEFAULT_LIT_HTML_SRC,
47
- DEFAULT_REACTIVITY_SRC,
48
59
  buildComponentCSS,
49
60
  buildInitialScope,
50
61
  collectServerEntries,
@@ -61,6 +72,37 @@ import {
61
72
  } from "../shared.ts";
62
73
  import { resolvePrototypes } from "./prototype-resolver.ts";
63
74
  import { transformImageNodes } from "./image-transform.ts";
75
+ import { collectCspSources, emptyCspSources } from "./csp.ts";
76
+ import { resolveShadowMode } from "../shadow.ts";
77
+ import {
78
+ buildServiceWorker,
79
+ normalizeServiceWorker,
80
+ registrationScript,
81
+ tombstoneServiceWorker,
82
+ } from "./service-worker.ts";
83
+ import {
84
+ buildManifest,
85
+ buildSecurityTxt,
86
+ manifestHeadEntries,
87
+ writeWellKnown,
88
+ } from "./well-known.ts";
89
+ import {
90
+ localeAlternates,
91
+ localeOfRoute,
92
+ pageLanguage,
93
+ resolveI18n,
94
+ translationSets,
95
+ undeclaredLocalePrefix,
96
+ unprefixedRoutes,
97
+ } from "./i18n.ts";
98
+ import type { LocaleAlternate, PageLocaleContext } from "./i18n.ts";
99
+ import {
100
+ isPrefixSpecifier,
101
+ renderImportMap,
102
+ resolveClientRuntime,
103
+ writeClientRuntime,
104
+ writeRuntimeSubpaths,
105
+ } from "./client-runtime.ts";
64
106
  import { getImageCacheDir, loadCache, saveCache } from "./image-cache.ts";
65
107
  import type { ImageConfig } from "./image-optimizer.ts";
66
108
  import type { ImageMetaCache } from "./image-transform.ts";
@@ -121,6 +163,69 @@ export async function buildSite(
121
163
  // Hints) are rewritten to their /assets/ URL during compilation, registered here, and bundled
122
164
  // In step 6d (spec.md §12). Keyed by asset path so duplicate specifiers bundle once.
123
165
  const sidecarBundles = new Map<string, { entryPath: string; specifier: string }>();
166
+ /*
167
+ * Files a `$head` entry names by bare specifier — a package's stylesheet, most often. Copied
168
+ * rather than bundled.
169
+ *
170
+ * These used to be rewritten to `/node_modules/<specifier>`, which resolves in dev, where the
171
+ * server serves that path, and 404s in production, where nothing copies node_modules into dist.
172
+ */
173
+ const npmAssets = new Map<string, string>();
174
+ /*
175
+ * One entry per distinct SET of npm `$elements`, bundled self-contained. Keyed by the set rather
176
+ * than by specifier so two pages registering the same components share one file, and so a package
177
+ * that ships its own framework copy inlines it exactly once per page instead of once per
178
+ * component. See `injectNpmElementScripts` for why sharing Jx's copy is not an option.
179
+ */
180
+ const elementBundles = new Map<string, string[]>();
181
+ const registerElementBundle = (specifiers: string[]): string => {
182
+ const sorted = specifiers.toSorted();
183
+ const key = Bun.hash(sorted.join("\u0000")).toString(36);
184
+ const assetPath = `/assets/elements-${key}.js`;
185
+ elementBundles.set(assetPath, sorted);
186
+ return assetPath;
187
+ };
188
+ /*
189
+ * Bundles and copies land in one URL directory, so one map arbitrates it. Two different files
190
+ * that slug to the same name is a build error, not a silent last-writer-wins overwrite.
191
+ */
192
+ /*
193
+ * The two modules the import map names. Resolved up front so every page's map is identical, and
194
+ * written at the end only if some page actually emitted one.
195
+ */
196
+ const clientRuntime = resolveClientRuntime();
197
+ const runtimeImports = clientRuntime.imports;
198
+ const runtimeAssetsUsed = new Set<string>();
199
+ /*
200
+ * Scanned from every finished page, so the hashes name the exact bytes shipped rather than what
201
+ * some emission site believed it wrote.
202
+ */
203
+ const cspSources = emptyCspSources();
204
+
205
+ /*
206
+ * Locale routing (§13). Validated once: a malformed BCP 47 tag is a build error, because a
207
+ * locale is a URL prefix, an `hreflang` value and an `<html lang>` at once — a typo does not
208
+ * degrade, it produces a site claiming a language that does not exist.
209
+ */
210
+ const { errors: i18nErrors, i18n } = resolveI18n(projectConfig);
211
+ for (const error of i18nErrors) {
212
+ errors.push(error);
213
+ console.error(error);
214
+ }
215
+ for (const warning of clientRuntime.warnings) {
216
+ console.warn(warning);
217
+ }
218
+
219
+ const assetClaims = new Map<string, { entryPath: string; specifier: string }>();
220
+ const claimAsset = (assetPath: string, entryPath: string, specifier: string) => {
221
+ const existing = assetClaims.get(assetPath);
222
+ if (existing !== undefined && existing.entryPath !== entryPath) {
223
+ throw new Error(
224
+ `"${specifier}" and "${existing.specifier}" both map to ${assetPath} — rename one`,
225
+ );
226
+ }
227
+ assetClaims.set(assetPath, { entryPath, specifier });
228
+ };
124
229
  const rewriteSidecarSrc = (specifier: string, docDir: string | null): string => {
125
230
  if (!isBundleableSrc(specifier)) {
126
231
  return specifier;
@@ -140,12 +245,7 @@ export async function buildSite(
140
245
  ? specifier
141
246
  : `./${relative(projectRoot, entryPath)}`;
142
247
  const assetPath = sidecarAssetPath(assetKey);
143
- const existing = sidecarBundles.get(assetPath);
144
- if (existing && existing.entryPath !== entryPath) {
145
- throw new Error(
146
- `"${specifier}" and "${existing.specifier}" both bundle to ${assetPath} — rename one`,
147
- );
148
- }
248
+ claimAsset(assetPath, entryPath, specifier);
149
249
  sidecarBundles.set(assetPath, { entryPath, specifier });
150
250
  return assetPath;
151
251
  } catch (error) {
@@ -154,6 +254,21 @@ export async function buildSite(
154
254
  }
155
255
  };
156
256
 
257
+ const rewriteNpmAsset = (specifier: string): string => {
258
+ const assetPath = npmAssetPath(specifier);
259
+ try {
260
+ const entryPath = resolveSidecarEntry(`npm:${specifier}`, projectRoot, projectRoot);
261
+ claimAsset(assetPath, entryPath, specifier);
262
+ npmAssets.set(assetPath, entryPath);
263
+ return assetPath;
264
+ } catch (error) {
265
+ // A missing dependency is a hard error, like an unresolvable sidecar: the alternative is a
266
+ // Page that looks fine in dev and loses its stylesheet on deploy.
267
+ errors.push(`$head specifier "${specifier}": ${(error as Error).message}`);
268
+ return specifier;
269
+ }
270
+ };
271
+
157
272
  // ── 1b. Build the extension registry from project.json#/extensions ─────
158
273
  const registry = await buildProjectExtensionRegistry(projectRoot, projectConfig);
159
274
  const formatRegistry = registry.formats;
@@ -231,13 +346,28 @@ export async function buildSite(
231
346
  const assetRefs = new Set<string>();
232
347
 
233
348
  // ── 4. Expand dynamic routes ────────────────────────────────────────────
349
+ const extensionHead = registry
350
+ ? await collectExtensionHead(registry, projectConfig, projectRoot)
351
+ : [];
352
+
234
353
  const routes = await expandDynamicRoutes(
235
354
  staticRoutes,
236
355
  projectRoot,
237
356
  sections,
238
357
  registry,
239
358
  projectConfig,
359
+ i18n,
240
360
  );
361
+ /*
362
+ * Every route carries the locale it serves, resolved once here rather than re-derived by each
363
+ * reader. It is what scopes a `ContentEntry` lookup on a localized collection: two translations
364
+ * of one entry share an id (§13.3), so a page that asked for `hello` without saying in which
365
+ * language got whichever translation was loaded first — the English copy under a French route,
366
+ * silently, on a page whose `<html lang>` said otherwise.
367
+ */
368
+ for (const route of routes) {
369
+ (route as { locale?: string | null }).locale = localeOfRoute(route.urlPattern, i18n);
370
+ }
241
371
  log(` ${routes.length} route(s) after expansion`);
242
372
 
243
373
  let fileCount = 0;
@@ -256,20 +386,44 @@ export async function buildSite(
256
386
  const componentOutDir = resolve(outDir, "components");
257
387
  mkdirSync(componentOutDir, { recursive: true });
258
388
 
389
+ // Emitted modules are named after the component's `tagName` — the name the page's loader
390
+ // `<script>` and the CSS sidecar both use. So a component whose source basename differs from
391
+ // Its tag needs its `$elements` imports renamed to match, and those get resolved before the
392
+ // Dependency itself is compiled. Hence: read every component's tag up front.
393
+ const componentTagByPath = new Map<string, string>();
394
+ for (const file of componentFiles) {
395
+ const componentPath = resolve(componentsDir, file);
396
+ try {
397
+ const { tagName: depTag } = await readPageDocument(componentPath, formatRegistry);
398
+ if (depTag) {
399
+ componentTagByPath.set(componentPath, depTag);
400
+ }
401
+ } catch {
402
+ // Left to the compile pass below, which reports the parse failure with its file context.
403
+ }
404
+ }
405
+
259
406
  for (const file of componentFiles) {
260
407
  try {
261
408
  const componentPath = resolve(componentsDir, file);
262
409
  const result = await compileElement(componentPath, {
263
410
  ...(projectConfig.$media ? { $media: projectConfig.$media } : {}),
264
411
  formats: formatRegistry,
412
+ resolveElementPath: (refPath: string, currentDir: string | null) => {
413
+ const depPath = currentDir ? resolve(currentDir, refPath) : resolve(refPath);
414
+ const depTag = componentTagByPath.get(depPath);
415
+ return depTag ? `./${depTag}.js` : refPath.replace(/\.json$/, ".js");
416
+ },
265
417
  rewriteSrc: rewriteSidecarSrc,
266
418
  });
267
419
  for (const f of result.files) {
268
- // Reduce the emitted path (an absolute source path with .json swapped to .js) to a bare
269
- // Filename for the dist/components/ output. basename handles both / and \ — a
270
- // Forward-slash-only split left the full drive path on Windows, so resolve() then wrote
271
- // The sidecar back into the source tree instead of dist.
272
- const outName = basename(f.path);
420
+ // Name the output after the tag. `injectComponentScripts` emits
421
+ // `<script src="/components/<tag>.js">` and the CSS sidecar is written as `<tag>.css`, so
422
+ // A source basename here left the loader pointing at a file that was never written.
423
+ // Basename is the fallback, and handles both / and \ — a forward-slash-only split left
424
+ // The full drive path on Windows, so resolve() then wrote the sidecar back into the
425
+ // Source tree instead of dist.
426
+ const outName = f.tagName ? `${f.tagName}.js` : basename(f.path);
273
427
  writeFileSync(resolve(componentOutDir, outName), f.content, "utf8");
274
428
  if (f.tagName) {
275
429
  compiledComponentTags.push(f.tagName);
@@ -281,9 +435,23 @@ export async function buildSite(
281
435
  const doc = await readPageDocument(componentPath, formatRegistry);
282
436
  if (doc.tagName) {
283
437
  componentDefs.set(doc.tagName, doc);
284
- const css = buildComponentCSS(doc.tagName, doc.style, doc, projectConfig.$media ?? {});
438
+ const componentShadow = resolveShadowMode(doc, projectConfig.defaults);
439
+ const css = buildComponentCSS(
440
+ doc.tagName,
441
+ doc.style,
442
+ doc,
443
+ projectConfig.$media ?? {},
444
+ componentShadow,
445
+ );
285
446
  if (css) {
286
- componentCSS.set(doc.tagName, css);
447
+ /*
448
+ * A shadow component's sheet is linked from inside its own declarative shadow root, so
449
+ * it is written to disk but kept OUT of the page-level map: a `:host`-rooted rule in
450
+ * the document head matches nothing, and the duplicate request is pure cost.
451
+ */
452
+ if (componentShadow === null) {
453
+ componentCSS.set(doc.tagName, css);
454
+ }
287
455
  collectAssetRefs(css, assetMounts, assetRefs);
288
456
  writeFileSync(resolve(componentOutDir, `${doc.tagName}.css`), css, "utf8");
289
457
  fileCount += 1;
@@ -332,9 +500,127 @@ export async function buildSite(
332
500
  // (absolute <loc> URLs require it) and not explicitly disabled via build.sitemap: false.
333
501
  const siteUrl = projectConfig.url;
334
502
  const sitemapEnabled = Boolean(siteUrl) && projectConfig.build.sitemap !== false;
335
- const sitemapEntries: { loc: string; lastmod: Date }[] = [];
503
+ const sitemapEntries: SitemapEntry[] = [];
504
+
505
+ // Warned once per distinct prefix rather than once per route: one mistyped directory is one
506
+ // Mistake, however many pages live under it.
507
+ /*
508
+ * Translation sets, computed from the whole route table before the first page is compiled: a
509
+ * page's alternates include its siblings, which do not exist until every route is known.
510
+ * Dynamic patterns are excluded — an unexpanded `:slug` is not a URL.
511
+ */
512
+ const concreteRoutes = routes.filter(
513
+ (r) => !r.urlPattern.includes(":") && !r.urlPattern.includes("*"),
514
+ );
515
+ /*
516
+ * A localized slug shares nothing with the page it translates — `/fr-ca/a-propos/` and `/about/`
517
+ * reduce to different keys — so the document says so itself, and the whole annotation follows
518
+ * from the one key. Read only when the project has locales to group across.
519
+ */
520
+ const declaredKeys =
521
+ i18n === null
522
+ ? new Map<string, string>()
523
+ : await readTranslationKeys(
524
+ concreteRoutes as { sourcePath: string; urlPattern: string }[],
525
+ formatRegistry,
526
+ );
527
+ const keyedRoutes = concreteRoutes.map((r) => ({
528
+ translationKey: declaredKeys.get(r.urlPattern),
529
+ urlPattern: r.urlPattern,
530
+ }));
531
+ const alternateMap = localeAlternates(keyedRoutes, i18n, siteUrl ?? "");
532
+ /*
533
+ * The same sets, site-absolute, for `$page.alternates`. Computed from the whole table for the
534
+ * same reason the alternates are — and separately from them because a switcher must work in a
535
+ * project that has not configured `url` yet, which is every project in development.
536
+ */
537
+ const { conflicts, sets: translationMap } = translationSets(keyedRoutes, i18n);
538
+
539
+ /*
540
+ * Two routes claiming to be the same page in the same language. A set is single-valued, so one
541
+ * of them is dropped either way; what differs is whether the author asserted it.
542
+ *
543
+ * A **declared** collision fails the build: `$translationKey` is a promise written down twice,
544
+ * and silently advertising one of the two URLs as *the* page in that language is a wrong answer
545
+ * nobody can see. A derived one warns — parallel paths that happen to collide may be a deliberate
546
+ * alias, and failing a build over pages that work would be the compiler overruling a decision it
547
+ * cannot see the reason for.
548
+ */
549
+ for (const conflict of conflicts) {
550
+ const [kept, dropped] = conflict.urlPatterns;
551
+ const fix = conflict.declared
552
+ ? "Give one of them a different $translationKey."
553
+ : "Move one out of the locale tree, or give it its own $translationKey.";
554
+ const message =
555
+ `${dropped} and ${kept} are both the "${conflict.locale}" version of ` +
556
+ `"${conflict.key}". A translation set names one URL per language, so ${dropped} is ` +
557
+ `dropped from it — it carries no alternates and no switcher. ${fix}`;
558
+ if (conflict.declared) {
559
+ errors.push(message);
560
+ console.error(message);
561
+ } else {
562
+ console.warn(message);
563
+ }
564
+ }
565
+
566
+ /*
567
+ * `prefix-always` says every URL names its language, and until now nothing checked it: a page
568
+ * outside the locale tree built, served, and claimed the default locale, so a site that promised
569
+ * no unprefixed URLs shipped them silently. Reported once with the whole list, not once per
570
+ * page — an author who moved a directory wants to see the extent of it.
571
+ */
572
+ const unprefixed = unprefixedRoutes(routes, i18n);
573
+ if (unprefixed.length > 0) {
574
+ console.warn(
575
+ `i18n.routing is "prefix-always", but ${unprefixed.length} route(s) sit outside the locale ` +
576
+ `tree and are served as "${i18n?.defaultLocale}": ${unprefixed.slice(0, 10).join(", ")}` +
577
+ `${unprefixed.length > 10 ? `, and ${unprefixed.length - 10} more` : ""}. ` +
578
+ `Move them under a locale directory, or use "prefix-except-default".`,
579
+ );
580
+ }
336
581
 
582
+ /*
583
+ * `prefix-always` with no adapter, and no page at `/`. The mode leaves the root for negotiation
584
+ * to answer (§13.6), negotiation needs a request, and a static deployment never sees one — so the
585
+ * site's front door is a 404 and nothing else in the build says so. `unprefixedRoutes` above
586
+ * deliberately never reports `/`, because under an adapter it is handled; this is the one shape
587
+ * where it is not.
588
+ *
589
+ * A warning rather than a generated redirect: which URL the root should send a visitor to is a
590
+ * deployment decision, and inventing one would be the compiler overruling a choice it cannot see.
591
+ */
592
+ if (
593
+ i18n !== null &&
594
+ i18n.routing === "prefix-always" &&
595
+ !projectConfig.build.adapter &&
596
+ !routes.some((r) => r.urlPattern === "/")
597
+ ) {
598
+ console.warn(
599
+ `i18n.routing is "prefix-always" and no page claims "/", so the site root is a 404. ` +
600
+ `Locale negotiation answers "/" only when build.adapter is set — a static deployment has ` +
601
+ `no runtime to read Accept-Language. Add a redirect from "/" to "/${i18n.defaultLocale.toLowerCase()}/", ` +
602
+ `set build.adapter, or use "prefix-except-default".`,
603
+ );
604
+ }
605
+
606
+ const warnedLocalePrefixes = new Set<string>();
607
+ /*
608
+ * A mistyped `rel` is silent: the tag is still valid HTML, still renders, and simply does
609
+ * nothing — the stylesheet never loads, the canonical never consolidates. Warned once per
610
+ * distinct value across the whole build, because the one that matters lives in the layout and is
611
+ * therefore on every page.
612
+ */
613
+ const warnedRelations = new Set<string>();
337
614
  for (const route of routes) {
615
+ const mismatch = undeclaredLocalePrefix(route.urlPattern, i18n);
616
+ if (mismatch && !warnedLocalePrefixes.has(mismatch.segment)) {
617
+ warnedLocalePrefixes.add(mismatch.segment);
618
+ console.warn(
619
+ `Routes under /${mismatch.segment}/ are served as "${i18n?.defaultLocale}" — ` +
620
+ `i18n.locales declares "${mismatch.meant}", not "${mismatch.segment}". ` +
621
+ `Rename the directory to "${mismatch.meant.toLowerCase()}" or declare the shorter tag.`,
622
+ );
623
+ }
338
624
  try {
339
625
  log(` Compiling ${route.urlPattern} ...`);
340
626
  const result = await compilePage(
@@ -349,8 +635,28 @@ export async function buildSite(
349
635
  registry,
350
636
  rewriteSidecarSrc,
351
637
  assetMounts,
638
+ extensionHead,
639
+ rewriteNpmAsset,
640
+ {
641
+ alternates: alternateMap.get(route.urlPattern) ?? [],
642
+ i18n,
643
+ translations: translationMap.get(route.urlPattern) ?? [],
644
+ },
645
+ runtimeImports,
646
+ registerElementBundle,
352
647
  );
353
648
 
649
+ for (const relation of result.unregisteredRelations) {
650
+ if (!warnedRelations.has(relation)) {
651
+ warnedRelations.add(relation);
652
+ console.warn(
653
+ `<link rel="${relation}"> — "${relation}" is not an IANA link relation, and a ` +
654
+ `relation nobody recognizes does nothing. Check the spelling, or use an absolute ` +
655
+ `URI if it is an extension relation (RFC 8288 §2.1.2).`,
656
+ );
657
+ }
658
+ }
659
+
354
660
  // Determine which component tags are fully static (for script omission)
355
661
  const staticTags = new Set<string>();
356
662
  for (const [tag, def] of componentDefs) {
@@ -366,12 +672,16 @@ export async function buildSite(
366
672
  compiledComponentTags,
367
673
  componentCSS,
368
674
  staticTags,
675
+ result.files.map((f: { content: string }) => f.content).join("\n"),
676
+ runtimeImports,
677
+ runtimeAssetsUsed,
369
678
  );
370
679
  }
371
680
 
372
681
  // Mounted assets this page actually references — scanned from the finished HTML so a
373
682
  // Reference is caught wherever it came from (markdown image, hand-authored page, $head).
374
683
  collectAssetRefs(result.html, assetMounts, assetRefs);
684
+ collectCspSources(result.html, cspSources);
375
685
 
376
686
  // Determine output path
377
687
  const outPath = routeToOutputPath(route.urlPattern, outDir, trailingSlash);
@@ -384,9 +694,21 @@ export async function buildSite(
384
694
  // The two always agree.
385
695
  const isConcrete = !route.urlPattern.includes(":") && !route.urlPattern.includes("*");
386
696
  if (sitemapEnabled && !result.excludeFromSitemap && isConcrete) {
697
+ const routeAlternates = alternateMap.get(route.urlPattern) ?? [];
387
698
  sitemapEntries.push({
388
- lastmod: statSync(route.sourcePath).mtime,
699
+ /*
700
+ * The route's own timestamp when it has one, and only then the template's. A concrete
701
+ * route expanded from a collection carries the entry it was generated from
702
+ * (`sourceMtime`), because `sourcePath` still points at the `[slug]` template — so
703
+ * without this every post in an archive claims to have been edited the moment the
704
+ * template was, which is precisely the signal `<lastmod>` exists to give.
705
+ */
706
+ lastmod:
707
+ typeof route.sourceMtime === "string" && route.sourceMtime !== ""
708
+ ? route.sourceMtime
709
+ : toRfc3339(statSync(route.sourcePath).mtime),
389
710
  loc: new URL(route.urlPattern, siteUrl).href,
711
+ ...(routeAlternates.length > 0 && { alternates: routeAlternates }),
390
712
  });
391
713
  }
392
714
 
@@ -485,7 +807,7 @@ export async function buildSite(
485
807
  const skipWorker = adapter === "cloudflare-pages" && deduped.size === 0 && mounts.length === 0;
486
808
  const workerSource = skipWorker
487
809
  ? null
488
- : compileSiteServer([...deduped.values()], { adapter, connectors, mounts });
810
+ : compileSiteServer([...deduped.values()], { adapter, connectors, i18n, mounts });
489
811
 
490
812
  if (workerSource) {
491
813
  // Bundle the worker self-contained for the adapter's runtime (compiler.md §12): mount
@@ -510,7 +832,17 @@ export async function buildSite(
510
832
  // Only invoke the worker for server routes; everything else stays static.
511
833
  writeFileSync(
512
834
  resolve(outDir, "_routes.json"),
513
- `${JSON.stringify({ exclude: [], include: ["/_jx/*"], version: 1 }, null, 2)}\n`,
835
+ // `/` joins the include list only when there is a locale to negotiate: Pages invokes
836
+ // The worker for included paths only, so an uninvoked middleware is an inert one.
837
+ `${JSON.stringify(
838
+ {
839
+ exclude: [],
840
+ include: i18n && i18n.locales.length > 1 ? ["/", "/_jx/*"] : ["/_jx/*"],
841
+ version: 1,
842
+ },
843
+ null,
844
+ 2,
845
+ )}\n`,
514
846
  "utf8",
515
847
  );
516
848
  fileCount += 1;
@@ -519,6 +851,30 @@ export async function buildSite(
519
851
  }
520
852
 
521
853
  // ── 6d. Bundle client sidecar modules (spec.md §12) ─────────────────────
854
+ if (runtimeAssetsUsed.size > 0) {
855
+ log(`Bundling ${runtimeAssetsUsed.size} client runtime module(s)...`);
856
+ const runtime = await writeClientRuntime(
857
+ { ...clientRuntime, assetPaths: [...runtimeAssetsUsed] },
858
+ outDir,
859
+ );
860
+ fileCount += runtime.written;
861
+ for (const error of runtime.errors) {
862
+ errors.push(error);
863
+ console.error(error);
864
+ }
865
+ }
866
+
867
+ if (npmAssets.size > 0) {
868
+ log(`Copying ${npmAssets.size} package asset(s)...`);
869
+ for (const [assetPath, entryPath] of npmAssets) {
870
+ const outfile = resolve(outDir, assetPath.replace(/^\//, ""));
871
+ mkdirSync(dirname(outfile), { recursive: true });
872
+ cpSync(entryPath, outfile);
873
+ fileCount += 1;
874
+ log(` ${entryPath} → ${assetPath}`);
875
+ }
876
+ }
877
+
522
878
  if (sidecarBundles.size > 0) {
523
879
  log(`Bundling ${sidecarBundles.size} client sidecar module(s)...`);
524
880
  for (const [assetPath, bundle] of sidecarBundles) {
@@ -539,6 +895,49 @@ export async function buildSite(
539
895
  }
540
896
  }
541
897
 
898
+ if (elementBundles.size > 0) {
899
+ log(`Bundling ${elementBundles.size} npm element set(s)...`);
900
+ for (const [assetPath, specifiers] of elementBundles) {
901
+ const outfile = resolve(outDir, assetPath.replace(/^\//, ""));
902
+ mkdirSync(dirname(outfile), { recursive: true });
903
+ try {
904
+ /*
905
+ * `external: []` — the whole point. A component package re-exporting its framework is
906
+ * exactly the shape Bun mis-compiles when that framework is external.
907
+ */
908
+ await bundleSource(
909
+ `${specifiers.map((sp) => `import ${JSON.stringify(sp)};`).join("\n")}\n`,
910
+ { outfile, resolveDir: projectRoot },
911
+ { target: "browser" },
912
+ );
913
+ fileCount += 1;
914
+ log(` ${specifiers.length} element(s) → ${assetPath}`);
915
+ } catch (error) {
916
+ const msg = `Error bundling npm $elements [${specifiers.join(", ")}]: ${
917
+ (error as Error).message
918
+ }`;
919
+ errors.push(msg);
920
+ console.error(msg);
921
+ }
922
+ }
923
+ }
924
+
925
+ /*
926
+ * Package subpaths the emitted bundles import — run AFTER every bundle exists, because the set is
927
+ * discovered from their contents rather than declared anywhere (site-architecture.md §8.7).
928
+ */
929
+ if (runtimeAssetsUsed.size > 0) {
930
+ const subpaths = await writeRuntimeSubpaths(outDir);
931
+ if (subpaths.written > 0) {
932
+ log(`Bundling ${subpaths.written} runtime subpath module(s)...`);
933
+ }
934
+ fileCount += subpaths.written;
935
+ for (const error of subpaths.errors) {
936
+ errors.push(error);
937
+ console.error(error);
938
+ }
939
+ }
940
+
542
941
  // ── 6e. Extension-emitted assets (extensions.md §8.4) ───────────────────
543
942
  // Section-owner classes with an `emit` capability contribute derived build artifacts
544
943
  // (search indexes, feeds, …). The host writes the returned files so extensions stay pure;
@@ -584,8 +983,12 @@ export async function buildSite(
584
983
  if (projectConfig.redirects && Object.keys(projectConfig.redirects).length > 0) {
585
984
  log("Generating redirects...");
586
985
  const compiledUrls = new Set(routes.map((r) => r.urlPattern));
587
- const redirectFiles = generateRedirects(projectConfig.redirects, outDir, compiledUrls);
588
- fileCount += redirectFiles;
986
+ const redirects = generateRedirects(projectConfig.redirects, outDir, {
987
+ compiledUrls,
988
+ trailingSlash: projectConfig.build.trailingSlash,
989
+ });
990
+ fileCount += redirects.files;
991
+ errors.push(...redirects.errors);
589
992
  }
590
993
 
591
994
  // ── 7b. Generate sitemap.xml ────────────────────────────────────────────
@@ -620,6 +1023,83 @@ export async function buildSite(
620
1023
  fileCount += ensureRobotsSitemap(outDir, siteUrl);
621
1024
  }
622
1025
 
1026
+ // ── 7d.1 manifest.webmanifest and .well-known/security.txt ──────────────
1027
+ /*
1028
+ * After the public/ copy, and skipping anything already there. That skip is how an author ships
1029
+ * a **clearsigned** security.txt: signing needs a private key at build time, so the build cannot
1030
+ * do it, but `public/.well-known/security.txt` shadows this at zero cost.
1031
+ */
1032
+ {
1033
+ const generated = [buildManifest(projectConfig), buildSecurityTxt(projectConfig, new Date())];
1034
+ for (const output of generated) {
1035
+ for (const error of output.errors) {
1036
+ errors.push(error);
1037
+ console.error(error);
1038
+ }
1039
+ for (const warning of output.warnings) {
1040
+ console.warn(warning);
1041
+ }
1042
+ }
1043
+ const { skipped, written } = writeWellKnown(
1044
+ generated.flatMap((o) => o.files),
1045
+ outDir,
1046
+ existsSync,
1047
+ );
1048
+ fileCount += written;
1049
+ for (const path of skipped) {
1050
+ log(` ${path} — kept the copy from public/`);
1051
+ }
1052
+ }
1053
+
1054
+ // ── 7d.2 The service worker, or the tombstone that removes one ──────────
1055
+ /*
1056
+ * `serviceWorker: false` is not the same as omitting the key, and this is the one place in the
1057
+ * build where that distinction carries weight. A worker is sticky: deleting the file leaves
1058
+ * every previous visitor running the old one forever, because a 404 at that URL is not an
1059
+ * instruction to stop. `false` says "I had one" and emits the instruction.
1060
+ */
1061
+ {
1062
+ const sw = normalizeServiceWorker(projectConfig.serviceWorker);
1063
+ if (sw !== null) {
1064
+ const output = sw === false ? tombstoneServiceWorker() : buildServiceWorker(sw, outDir);
1065
+ for (const warning of output.warnings) {
1066
+ console.warn(warning);
1067
+ }
1068
+ for (const error of output.errors) {
1069
+ errors.push(error);
1070
+ console.error(error);
1071
+ }
1072
+ writeFileSync(join(outDir, output.path as string), output.source, "utf8");
1073
+ fileCount += 1;
1074
+ log(sw === false ? " sw.js — tombstone (unregisters and clears)" : " sw.js");
1075
+ }
1076
+ }
1077
+
1078
+ // ── 7e. Response headers and the Jekyll opt-out ─────────────────────────
1079
+ // Also after the public/ copy, for the same reason — but PREPENDING rather than appending, since
1080
+ // A later `_headers` rule wins for a duplicate header name and the author's block must override.
1081
+ {
1082
+ const { errors: headerErrors, rules: securityRules } = buildHeaderRules(
1083
+ projectConfig.build,
1084
+ cspSources,
1085
+ );
1086
+ errors.push(...headerErrors);
1087
+ // Runs here, after every emitter, so it can see which of these files the build actually wrote.
1088
+ const rules = [...securityRules, ...contentTypeRules(outDir)];
1089
+ if (rules.length > 0) {
1090
+ log("Writing response headers...");
1091
+ fileCount += writeHeaders(outDir, rules);
1092
+ const { adapter } = projectConfig.build;
1093
+ if (adapter === "node" || adapter === "bun") {
1094
+ console.warn(
1095
+ `The "${adapter}" adapter serves no static assets, so dist/_headers is documentation ` +
1096
+ `rather than configuration — apply these headers at the reverse proxy in front of it.`,
1097
+ );
1098
+ }
1099
+ }
1100
+ fileCount += writeNoJekyll(outDir);
1101
+ }
1102
+
623
1103
  // ── 8. Copy declarative file mappings ──────────────────────────────────
624
1104
  if (projectConfig.copy) {
625
1105
  log("Copying mapped files...");
@@ -658,19 +1138,28 @@ export async function buildSite(
658
1138
  * serverHandler: string | null;
659
1139
  * doc: JxDocument;
660
1140
  * }>}
1141
+ * Every parameter is required. There is exactly one caller and it passes all fifteen, so the
1142
+ * defaults this list used to carry described no reachable call — and one of them, an identity
1143
+ * `rewriteNpmAsset`, was a function that could never run pretending to be a safety net.
661
1144
  */
662
1145
  async function compilePage(
663
1146
  route: SiteRoute,
664
1147
  projectConfig: ProjectConfig,
665
1148
  projectRoot: string,
666
- sections: Record<string, unknown> = {},
667
- imageCache: CacheManifest | null = null,
668
- componentDefs = new Map<string, JxElement>(),
669
- imageMetaCache: ImageMetaCache | null = null,
670
- formatRegistry?: FormatRegistry,
671
- registry?: ExtensionRegistry,
672
- rewriteSidecarSrc?: (specifier: string, docDir: string | null) => string,
673
- assetMounts: readonly AssetMount[] = [],
1149
+ sections: Record<string, unknown>,
1150
+ imageCache: CacheManifest | null,
1151
+ componentDefs: Map<string, JxElement>,
1152
+ imageMetaCache: ImageMetaCache | null,
1153
+ formatRegistry: FormatRegistry | undefined,
1154
+ registry: ExtensionRegistry | undefined,
1155
+ rewriteSidecarSrc: ((specifier: string, docDir: string | null) => string) | undefined,
1156
+ assetMounts: readonly AssetMount[],
1157
+ extensionHead: readonly JxHeadEntry[],
1158
+ rewriteNpmAsset: (specifier: string) => string,
1159
+ locale: PageLocaleContext,
1160
+ runtimeImports: Record<string, string>,
1161
+ /** Registers a page's npm `$elements` set as one bundle and returns its URL. */
1162
+ registerElementBundle: (specifiers: string[]) => string,
674
1163
  ) {
675
1164
  // Load the raw page document (.json natively, other formats via the registry)
676
1165
  const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
@@ -688,7 +1177,19 @@ async function compilePage(
688
1177
  delete layoutDoc._pageTitle;
689
1178
 
690
1179
  // Inject $site and $page context
691
- injectContext(layoutDoc, projectConfig, route, projectRoot);
1180
+ injectContext(layoutDoc, projectConfig, route, projectRoot, locale.i18n, locale.translations);
1181
+
1182
+ /*
1183
+ * The page's language and writing direction. `$lang` on the document wins over the locale its
1184
+ * route implies, and `dir` is emitted only when it is `rtl` or the author asked — `ltr` is
1185
+ * HTML's default and writing it on every page says nothing.
1186
+ */
1187
+ const language = pageLanguage({
1188
+ defaults: projectConfig.defaults,
1189
+ pageDir: typeof pageDoc.$dir === "string" ? pageDoc.$dir : undefined,
1190
+ pageLang: typeof pageDoc.$lang === "string" ? pageDoc.$lang : undefined,
1191
+ routeLocale: localeOfRoute(route.urlPattern, locale.i18n),
1192
+ });
692
1193
 
693
1194
  // Resolve generic $prototype entries via .class.json imports (and lower registry classes)
694
1195
  await resolvePrototypes(layoutDoc, route, projectRoot, {
@@ -714,8 +1215,25 @@ async function compilePage(
714
1215
  }
715
1216
 
716
1217
  // Resolve template strings in $head entries
717
- const resolvedPageHead = resolveHeadTemplates(pageHead, scope);
718
- const resolvedLayoutHead = resolveHeadTemplates(layoutHead, scope);
1218
+ /*
1219
+ * Bare specifiers resolve on ALL THREE heads, not just the project's.
1220
+ *
1221
+ * A `$head` entry may name a package file — a component library's stylesheet, most often — and
1222
+ * `rewriteNpmAsset` copies it into `/assets/` so it survives deploy. That ran only over
1223
+ * `projectConfig.$head`, so the same link written on a PAGE shipped its bare specifier verbatim
1224
+ * into the HTML: the browser resolved `@scope/pkg/x.css` against the page URL and 404'd, and the
1225
+ * page rendered unstyled with nothing in the build log. `resolveHeadBareSpecifiers` leaves an
1226
+ * entry with no bare specifier untouched, so widening the scope costs nothing where it does not
1227
+ * apply.
1228
+ */
1229
+ const resolvedPageHead = resolveHeadBareSpecifiers(
1230
+ resolveHeadTemplates(pageHead, scope),
1231
+ rewriteNpmAsset,
1232
+ );
1233
+ const resolvedLayoutHead = resolveHeadBareSpecifiers(
1234
+ resolveHeadTemplates(layoutHead, scope),
1235
+ rewriteNpmAsset,
1236
+ );
719
1237
 
720
1238
  // Resolve template strings in the document tree (innerHTML, textContent, style, attributes)
721
1239
  // So that timing: "compiler" data is baked into the static HTML
@@ -729,13 +1247,19 @@ async function compilePage(
729
1247
  media: { ...projectConfig.$media, ...layoutDoc.$media },
730
1248
  rules: [],
731
1249
  };
732
- expandComponents(layoutDoc, componentDefs, slotCss);
1250
+ expandComponents(layoutDoc, componentDefs, slotCss, projectConfig.defaults);
733
1251
 
734
1252
  // Strip resolved timing: "compiler" state entries — they're now baked into the tree
735
1253
  // And keeping them would cause isDynamic() to misclassify the page as dynamic.
736
1254
  // Also strip resolved content arrays (from ContentCollection) that have been
737
1255
  // Baked into unrolled map templates.
738
1256
  if (layoutDoc.state) {
1257
+ // An array is only strippable if nothing that survives the build still reads it. A map
1258
+ // Expansion is one consumer, not the only one: the same array is routinely also read by a
1259
+ // Computed at runtime, and dropping it there left `state.rows` undefined in the browser while
1260
+ // The build reported success (issue #122). `timing: "compiler"` is excluded from this rescue —
1261
+ // That is the author declaring the entry build-time-only, so it is stripped as before.
1262
+ const strippable = new Set<string>();
739
1263
  for (const [key, def] of Object.entries(layoutDoc.state)) {
740
1264
  if (key === "$site" || key === "$page") {
741
1265
  continue;
@@ -748,13 +1272,54 @@ async function compilePage(
748
1272
  ) {
749
1273
  delete layoutDoc.state[key];
750
1274
  } else if (Array.isArray(def)) {
751
- delete layoutDoc.state[key];
1275
+ strippable.add(key);
1276
+ }
1277
+ }
1278
+ // Iterated to a fixpoint: rescuing one array can reveal a read of another from its own def.
1279
+ let rescued = true;
1280
+ while (rescued) {
1281
+ rescued = false;
1282
+ const surviving = { ...layoutDoc.state } as Record<string, unknown>;
1283
+ for (const key of strippable) {
1284
+ delete surviving[key];
752
1285
  }
1286
+ const haystack = JSON.stringify({ children: layoutDoc.children, state: surviving });
1287
+ for (const key of strippable) {
1288
+ if (referencesStateKey(haystack, key)) {
1289
+ strippable.delete(key);
1290
+ rescued = true;
1291
+ }
1292
+ }
1293
+ }
1294
+ for (const key of strippable) {
1295
+ delete layoutDoc.state[key];
753
1296
  }
754
1297
  }
755
1298
 
756
1299
  // Resolve bare npm specifiers in $head (e.g. "@pkg/name/file.css" → "/node_modules/@pkg/name/file.css")
757
- const resolvedSiteHead = resolveHeadBareSpecifiers(projectConfig.$head ?? []);
1300
+ /*
1301
+ * Extension `head` contributions sit BELOW the project's own `$head`, so a project that writes
1302
+ * its own feed link keeps it — the same "author wins" rule every auto-injected entry follows.
1303
+ */
1304
+ /*
1305
+ * The registration script. Byte-identical on every page, so a strict `script-src` needs exactly
1306
+ * one hash for it (§14.3.1) — and it is only emitted when a worker actually exists, since a
1307
+ * tombstone must not be registered by the page that is trying to get rid of it.
1308
+ */
1309
+ const swConfig = normalizeServiceWorker(projectConfig.serviceWorker);
1310
+ const swHead: JxHeadEntry[] =
1311
+ swConfig === null || swConfig === false
1312
+ ? []
1313
+ : [{ tagName: "script", textContent: registrationScript(swConfig.scope ?? "/") }];
1314
+
1315
+ const resolvedSiteHead = [
1316
+ // The manifest link and theme colour are first-party and unconditional once declared, so they
1317
+ // Sit with the extension contributions: below the project's own $head, which still wins.
1318
+ ...(manifestHeadEntries(projectConfig) as JxHeadEntry[]),
1319
+ ...swHead,
1320
+ ...extensionHead,
1321
+ ...resolveHeadBareSpecifiers(projectConfig.$head ?? [], rewriteNpmAsset),
1322
+ ];
758
1323
 
759
1324
  // Merge $head from site + layout + page
760
1325
  const mergedHead = mergeHead(resolvedSiteHead, resolvedLayoutHead, resolvedPageHead, {
@@ -762,6 +1327,7 @@ async function compilePage(
762
1327
  charset: projectConfig.defaults?.charset ?? "utf8",
763
1328
  ...(projectConfig.name != null && { siteName: projectConfig.name }),
764
1329
  ...(projectConfig.url != null && { siteUrl: projectConfig.url }),
1330
+ ...(locale.alternates.length > 0 && { alternates: locale.alternates }),
765
1331
  pageUrl: route.urlPattern,
766
1332
  });
767
1333
 
@@ -777,8 +1343,12 @@ async function compilePage(
777
1343
  mergedHead.splice(2, 0, { tagName: "script", textContent: colorSchemePrePaintScript() });
778
1344
  }
779
1345
 
780
- // Transform <img> nodes for responsive image optimization
781
- if (projectConfig.images?.optimize && (imageCache || imageMetaCache)) {
1346
+ /*
1347
+ * Responsive images. This runs whether or not `images.optimize` is on: the loading pass inside
1348
+ * it is governed by `images.lazyLoad`, and declining to generate variants for an image says
1349
+ * nothing about when the browser should fetch it.
1350
+ */
1351
+ if (projectConfig.images) {
782
1352
  await transformImageNodes(
783
1353
  layoutDoc,
784
1354
  projectConfig.images as ImageConfig,
@@ -791,7 +1361,17 @@ async function compilePage(
791
1361
 
792
1362
  // Compile the document using the existing compiler
793
1363
  const result = await compile(layoutDoc, {
794
- lang: projectConfig.defaults?.lang ?? "en",
1364
+ lang: language.lang,
1365
+ /*
1366
+ * The page-template tiers emit their own import map, and it defaulted to the CDN — so a page
1367
+ * that reached compile-static/compile-client kept loading its runtime from esm.sh even after
1368
+ * the self-hosting landed, because `injectComponentScripts` sees a map already present and
1369
+ * declines to add its own. Both halves have to name the same URLs.
1370
+ */
1371
+ ...(runtimeImports["@vue/reactivity"] === undefined
1372
+ ? {}
1373
+ : { reactivitySrc: runtimeImports["@vue/reactivity"] }),
1374
+ ...(runtimeImports["lit-html"] === undefined ? {} : { litHtmlSrc: runtimeImports["lit-html"] }),
795
1375
  prePaintScheme: false, // Injected via the merged <head> above, not the target template
796
1376
  projectStyle: projectConfig.style ?? null,
797
1377
  ...(rewriteSidecarSrc === undefined
@@ -812,14 +1392,35 @@ async function compilePage(
812
1392
  }
813
1393
 
814
1394
  // Post-process: inject merged <head> content into the compiled HTML
815
- result.html = injectHead(result.html, mergedHead, projectConfig.defaults?.lang ?? "en");
816
-
817
- // Inject <script type="module"> for npm $elements (cherry-picked component imports)
818
- const npmElements = (layoutDoc.$elements ?? []).filter(
819
- (e: JxElement | string) => typeof e === "string" && !e.startsWith("./") && !e.startsWith("../"),
820
- );
1395
+ result.html = injectHead(result.html, mergedHead, language);
1396
+
1397
+ /*
1398
+ * Inject <script type="module"> for npm $elements (cherry-picked component imports).
1399
+ *
1400
+ * BOTH documents, because `$elements` is legal on either and `resolveLayout` does not merge the
1401
+ * page's into the layout's. Reading only the layout dropped every bare specifier a PAGE declared:
1402
+ * the page still rendered `<sl-card>` and friends into the markup, nothing imported the modules
1403
+ * that define them, and the result was a page of inert unknown elements with no error anywhere.
1404
+ * A local `./x.json` element never hit this, because those are registered through `componentDefs`
1405
+ * on a different path — which is why the failure looked component-specific rather than general.
1406
+ */
1407
+ /*
1408
+ * Deliberately the original predicate, not the stricter module-level `isBareSpecifier`: this fix
1409
+ * is about WHERE entries are read from, and narrowing WHICH ones count would silently drop an
1410
+ * absolute-URL `$element` that reaches this path today.
1411
+ */
1412
+ const isNpmElementEntry = (e: JxElement | string): e is string =>
1413
+ typeof e === "string" && !e.startsWith("./") && !e.startsWith("../");
1414
+ const npmElements = [
1415
+ ...new Set(
1416
+ [
1417
+ ...((layoutDoc.$elements ?? []) as (JxElement | string)[]),
1418
+ ...((pageDoc.$elements ?? []) as (JxElement | string)[]),
1419
+ ].filter((e) => isNpmElementEntry(e)),
1420
+ ),
1421
+ ];
821
1422
  if (npmElements.length > 0) {
822
- result.html = injectNpmElementScripts(result.html, npmElements as string[]);
1423
+ result.html = injectNpmElementScripts(result.html, registerElementBundle(npmElements));
823
1424
  }
824
1425
 
825
1426
  // Compile server handler if applicable (skip when provider bundles site-wide)
@@ -842,6 +1443,12 @@ async function compilePage(
842
1443
  excludeFromSitemap: pageDoc.$sitemap === false,
843
1444
  files: result.files,
844
1445
  html: result.html,
1446
+ /*
1447
+ * Reported rather than warned about here: a mistyped `rel` almost always lives in the site or
1448
+ * layout `$head`, which means it is on every page, and the caller is the only scope that can
1449
+ * say it once instead of four hundred times.
1450
+ */
1451
+ unregisteredRelations: unregisteredHeadRelations(mergedHead),
845
1452
  serverHandler,
846
1453
  };
847
1454
  }
@@ -932,6 +1539,39 @@ function buildMountSpecs(
932
1539
  return { connectors, mounts };
933
1540
  }
934
1541
 
1542
+ /**
1543
+ * Gather every extension's `<head>` contribution, once, before the first page is built.
1544
+ *
1545
+ * Separate from `emit` because the two answer different questions at different times: `emit`
1546
+ * derives files from loaded content long after every page was written, while this derives entries
1547
+ * from CONFIGURATION and must run first. Gated like every other section capability — a class only
1548
+ * contributes when the project declares a non-empty value for its section key.
1549
+ */
1550
+ async function collectExtensionHead(
1551
+ registry: ExtensionRegistry,
1552
+ projectConfig: ProjectConfig,
1553
+ projectRoot: string,
1554
+ ): Promise<JxHeadEntry[]> {
1555
+ const out: JxHeadEntry[] = [];
1556
+ for (const entry of registry.headProviders()) {
1557
+ const key = entry.project?.key;
1558
+ const sectionValue = key === undefined ? null : (projectConfig as Record<string, unknown>)[key];
1559
+ if (key !== undefined && (sectionValue === undefined || sectionValue === null)) {
1560
+ continue;
1561
+ }
1562
+ try {
1563
+ const contributed = (await entry.call("head", sectionValue ?? null, {
1564
+ projectConfig,
1565
+ root: projectRoot,
1566
+ })) as JxHeadEntry[] | null;
1567
+ out.push(...(contributed ?? []));
1568
+ } catch (error) {
1569
+ console.warn(`${entry.name} head capability failed: ${(error as Error).message}`);
1570
+ }
1571
+ }
1572
+ return out;
1573
+ }
1574
+
935
1575
  /**
936
1576
  * Resolve template strings in $head entries against the compiled scope.
937
1577
  *
@@ -939,6 +1579,21 @@ function buildMountSpecs(
939
1579
  * @param {Record<string, unknown>} scope
940
1580
  * @returns {JxHeadEntry[]}
941
1581
  */
1582
+ function resolveTemplatesDeep(value: unknown, scope: Record<string, unknown>): unknown {
1583
+ if (typeof value === "string") {
1584
+ return isTemplateString(value) ? (evaluateStaticTemplate(value, scope) ?? value) : value;
1585
+ }
1586
+ if (Array.isArray(value)) {
1587
+ return value.map((v) => resolveTemplatesDeep(v, scope));
1588
+ }
1589
+ if (value !== null && typeof value === "object") {
1590
+ return Object.fromEntries(
1591
+ Object.entries(value).map(([k, v]) => [k, resolveTemplatesDeep(v, scope)]),
1592
+ );
1593
+ }
1594
+ return value;
1595
+ }
1596
+
942
1597
  function resolveHeadTemplates(headEntries: JxHeadEntry[], scope: Record<string, unknown>) {
943
1598
  return headEntries.map((entry: JxHeadEntry) => {
944
1599
  if (!entry || typeof entry !== "object") {
@@ -959,20 +1614,32 @@ function resolveHeadTemplates(headEntries: JxHeadEntry[], scope: Record<string,
959
1614
  resolved.textContent =
960
1615
  (evaluateStaticTemplate(resolved.textContent, scope) as string | null) ??
961
1616
  resolved.textContent;
1617
+ } else if (resolved.textContent !== null && typeof resolved.textContent === "object") {
1618
+ // A structured data block (§8.5): templates inside it resolve too, or a JSON-LD object could
1619
+ // Never reference the page it describes.
1620
+ resolved.textContent = resolveTemplatesDeep(resolved.textContent, scope) as Record<
1621
+ string,
1622
+ unknown
1623
+ >;
962
1624
  }
963
1625
  return resolved;
964
1626
  });
965
1627
  }
966
1628
 
967
1629
  /**
968
- * Resolve bare npm specifiers in $head entry attributes (href, src). e.g.
969
- * "@shoelace-style/shoelace/dist/themes/light.css" →
970
- * "/node_modules/@shoelace-style/shoelace/dist/themes/light.css"
1630
+ * Resolve bare npm specifiers in $head entry attributes (href, src) to their copied `/assets/` URL.
1631
+ *
1632
+ * `"@shoelace-style/shoelace/dist/themes/light.css"` →
1633
+ * `"/assets/shoelace-style-shoelace-dist-themes-light.css"`.
971
1634
  *
972
1635
  * @param {JxHeadEntry[]} headEntries
1636
+ * @param {(specifier: string) => string} rewrite
973
1637
  * @returns {JxHeadEntry[]}
974
1638
  */
975
- function resolveHeadBareSpecifiers(headEntries: JxHeadEntry[]) {
1639
+ function resolveHeadBareSpecifiers(
1640
+ headEntries: JxHeadEntry[],
1641
+ rewrite: (specifier: string) => string,
1642
+ ) {
976
1643
  return headEntries.map((entry: JxHeadEntry) => {
977
1644
  if (!entry || typeof entry !== "object" || !entry.attributes) {
978
1645
  return entry;
@@ -981,7 +1648,7 @@ function resolveHeadBareSpecifiers(headEntries: JxHeadEntry[]) {
981
1648
  for (const key of ["href", "src"]) {
982
1649
  const val = resolved.attributes[key];
983
1650
  if (typeof val === "string" && isBareSpecifier(val)) {
984
- resolved.attributes[key] = `/node_modules/${val}`;
1651
+ resolved.attributes[key] = rewrite(val);
985
1652
  }
986
1653
  }
987
1654
  return resolved;
@@ -1151,9 +1818,14 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
1151
1818
  }
1152
1819
  const rawChildren = node.children;
1153
1820
  // Legacy whole-children repeater: expand the items into the node's static children.
1821
+ // Only when the expansion actually produced nodes. `expandMappedArrayStatic` returns `null` when
1822
+ // `items` cannot be resolved at build time and `[]` when it resolves to an empty array — and `[]`
1823
+ // Is truthy, so an empty build-time list used to replace the repeater with nothing, discarding the
1824
+ // Definition the client needed. There is no prerendered output to preserve in that case, so the
1825
+ // Repeater is left in place for the client to bind (a page containing one is never zero-JS).
1154
1826
  if (isMappedArray(rawChildren)) {
1155
1827
  const expanded = expandMappedArrayStatic(rawChildren, scope);
1156
- if (expanded) {
1828
+ if (expanded && expanded.length > 0) {
1157
1829
  node.children = expanded;
1158
1830
  return;
1159
1831
  }
@@ -1183,10 +1855,11 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
1183
1855
  continue;
1184
1856
  }
1185
1857
  }
1186
- // Array pseudo-element among siblings: expand its items in place.
1858
+ // Array pseudo-element among siblings: expand its items in place, on the same
1859
+ // Produced-nothing rule as the whole-children branch above.
1187
1860
  if (isMappedArray(child)) {
1188
1861
  const expanded = expandMappedArrayStatic(child, scope);
1189
- if (expanded) {
1862
+ if (expanded && expanded.length > 0) {
1190
1863
  node.children.splice(i, 1, ...expanded);
1191
1864
  i += expanded.length;
1192
1865
  continue;
@@ -1198,6 +1871,19 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
1198
1871
  }
1199
1872
  }
1200
1873
 
1874
+ /**
1875
+ * Whether a serialized document fragment still reads a given state key — as a `${state.key}`
1876
+ * template, a bare `state.key` inside a handler or computed body, or a `#/state/key` $ref.
1877
+ *
1878
+ * @param {string} haystack - JSON-serialized document fragment
1879
+ * @param {string} key
1880
+ * @returns {boolean}
1881
+ */
1882
+ function referencesStateKey(haystack: string, key: string): boolean {
1883
+ const escaped = key.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
1884
+ return new RegExp(String.raw`\bstate\.${escaped}\b|#/state/${escaped}(?=["/])`).test(haystack);
1885
+ }
1886
+
1201
1887
  /**
1202
1888
  * Statically expand a mapped array to its resolved item nodes when `items` resolves to an array at
1203
1889
  * build time, or null otherwise (leave the array node for client-side rendering).
@@ -1247,18 +1933,20 @@ interface SlotCssCollector {
1247
1933
  * @param {JxElement | string} node
1248
1934
  * @param {Map<string, JxElement>} componentDefs
1249
1935
  * @param {SlotCssCollector} [slotCss]
1936
+ * @param {ProjectConfig["defaults"]} [defaults] - Read for `defaults.shadow` (spec.md §16.6)
1250
1937
  */
1251
1938
  function expandComponents(
1252
1939
  node: JxElement | string,
1253
1940
  componentDefs: Map<string, JxElement>,
1254
1941
  slotCss?: SlotCssCollector,
1942
+ defaults?: ProjectConfig["defaults"],
1255
1943
  ) {
1256
1944
  if (!node || typeof node !== "object") {
1257
1945
  return;
1258
1946
  }
1259
1947
  if (Array.isArray(node)) {
1260
1948
  for (const n of node as (JxElement | string)[]) {
1261
- expandComponents(n, componentDefs, slotCss);
1949
+ expandComponents(n, componentDefs, slotCss, defaults);
1262
1950
  }
1263
1951
  return;
1264
1952
  }
@@ -1266,7 +1954,7 @@ function expandComponents(
1266
1954
  // Recurse into children first (bottom-up expansion)
1267
1955
  if (Array.isArray(node.children)) {
1268
1956
  for (const child of node.children) {
1269
- expandComponents(child, componentDefs, slotCss);
1957
+ expandComponents(child, componentDefs, slotCss, defaults);
1270
1958
  }
1271
1959
  }
1272
1960
 
@@ -1305,10 +1993,27 @@ function expandComponents(
1305
1993
  .join("\n")
1306
1994
  : null;
1307
1995
 
1308
- const innerHTML = preRenderComponentHtml(def, node.$props || null, slotContent);
1996
+ /*
1997
+ * In shadow mode the slotted children are NOT substituted into the prerendered markup: they
1998
+ * stay in the light tree as siblings of the template, where a real `<slot>` distributes them.
1999
+ * Passing them here instead would bake them into the shadow root, and the browser would then
2000
+ * render them twice.
2001
+ */
2002
+ const shadow = resolveShadowMode(def, defaults);
2003
+ const innerHTML = preRenderComponentHtml(def, node.$props || null, shadow ? null : slotContent);
1309
2004
  const isStatic = isComponentFullyStatic(def);
1310
2005
 
1311
- node.innerHTML = innerHTML;
2006
+ /*
2007
+ * A declarative shadow root: markup the parser materializes before any script runs, which the
2008
+ * element then adopts rather than replaces (spec.md §16.6). The stylesheet link moves inside
2009
+ * it because a shadow root does not inherit the document's stylesheets — and it stays an
2010
+ * external `<link>`, so no Content-Security-Policy hash changes.
2011
+ */
2012
+ node.innerHTML = shadow
2013
+ ? `<template shadowrootmode="${shadow}">` +
2014
+ `<link rel="stylesheet" href="/components/${node.tagName}.css">` +
2015
+ `${innerHTML}</template>${slotContent ?? ""}`
2016
+ : innerHTML;
1312
2017
  delete node.children;
1313
2018
 
1314
2019
  // Resolve template-string host styles with props (per-instance values like background-image)
@@ -1351,6 +2056,9 @@ function expandComponents(
1351
2056
  * @param {string[]} allComponentTags - All compiled component tag names
1352
2057
  * @param {Map<string, string>} [cssMap] - TagName → CSS text (for link injection)
1353
2058
  * @param {Set<string>} [staticTags] - Tags where ALL instances are fully static (skip JS)
2059
+ * @param {string} [islandSource] - Concatenated island modules emitted for this page
2060
+ * @param {Record<string, string>} [runtimeImports] - The import map's `imports` object
2061
+ * @param {Set<string>} [runtimeAssetsUsed] - Collects the asset paths an emitted import map names
1354
2062
  * @returns {string}
1355
2063
  */
1356
2064
  function injectComponentScripts(
@@ -1358,11 +2066,17 @@ function injectComponentScripts(
1358
2066
  allComponentTags: string[],
1359
2067
  cssMap = new Map<string, string>(),
1360
2068
  staticTags = new Set<string>(),
2069
+ islandSource = "",
2070
+ runtimeImports: Record<string, string> = {},
2071
+ runtimeAssetsUsed = new Set<string>(),
1361
2072
  ) {
1362
- // Find which components are actually referenced in this page
1363
- const usedTags = allComponentTags.filter(
1364
- (tag: string) => html.includes(`<${tag}`), // Matches <tag> and <tag ...>
1365
- );
2073
+ // A component reaches a page two ways: as a literal tag in the static HTML, or referenced only
2074
+ // From inside an island's client template — in which case the tag exists solely in the island's
2075
+ // Compiled JS and never in the HTML the page ships. Searching the HTML alone missed the second
2076
+ // Kind entirely, so its module was never loaded and the element sat in the DOM un-upgraded.
2077
+ const inHtml = (tag: string) => html.includes(`<${tag}`); // Matches <tag> and <tag ...>
2078
+ const inIsland = (tag: string) => islandSource.includes(`<${tag}`);
2079
+ const usedTags = allComponentTags.filter((tag: string) => inHtml(tag) || inIsland(tag));
1366
2080
  if (usedTags.length === 0) {
1367
2081
  return html;
1368
2082
  }
@@ -1377,21 +2091,28 @@ function injectComponentScripts(
1377
2091
  result = result.replace("</head>", ` ${cssLinks}\n</head>`);
1378
2092
  }
1379
2093
 
1380
- // Only inject JS for components that have non-static instances
1381
- const jsTags = usedTags.filter((tag: string) => !staticTags.has(tag));
2094
+ // Only inject JS for components that have non-static instances. An island-rendered instance is
2095
+ // Never prerendered, so it needs its module even when the component itself is fully static —
2096
+ // That module is what fills the element in client-side. This holds when the tag ALSO appears in
2097
+ // The static HTML: those instances are prerendered, but the ones the island creates are not.
2098
+ const jsTags = usedTags.filter((tag: string) => inIsland(tag) || !staticTags.has(tag));
1382
2099
  if (jsTags.length === 0) {
1383
2100
  return result;
1384
2101
  }
1385
2102
 
1386
2103
  // Build import map (needed for @vue/reactivity and lit-html)
1387
- const importMap = `<script type="importmap">
1388
- {
1389
- "imports": {
1390
- "@vue/reactivity": "${DEFAULT_REACTIVITY_SRC}",
1391
- "lit-html": "${DEFAULT_LIT_HTML_SRC}"
2104
+ const importMap = renderImportMap(runtimeImports);
2105
+ for (const [specifier, url] of Object.entries(runtimeImports)) {
2106
+ /*
2107
+ * A prefix key's value is the DIRECTORY its subpaths are served from — `/assets/lit-html/` —
2108
+ * not a file any build writes. `writeClientRuntime` matches this set against exact asset
2109
+ * paths, so a directory in it bundled nothing and only inflated the count this build logged.
2110
+ */
2111
+ if (isPrefixSpecifier(specifier) || !url.startsWith("/")) {
2112
+ continue;
1392
2113
  }
2114
+ runtimeAssetsUsed.add(url);
1393
2115
  }
1394
- </script>`;
1395
2116
 
1396
2117
  const moduleScripts = jsTags
1397
2118
  .map((tag: string) => `<script type="module" src="/components/${tag}.js"></script>`)
@@ -1406,19 +2127,31 @@ function injectComponentScripts(
1406
2127
 
1407
2128
  /**
1408
2129
  * Inject <script type="module"> tags for npm package $elements (cherry-picked component imports).
1409
- * Bare specifiers are resolved to /node_modules/ paths.
2130
+ *
2131
+ * Bare specifiers are BUNDLED to `/assets/`, not linked into node_modules. Bundling rather than
2132
+ * copying because a component package imports its own dependencies by bare specifier too, and the
2133
+ * import map only ever carried two entries.
1410
2134
  *
1411
2135
  * @param {string} html
1412
2136
  * @param {string[]} npmElements - Bare specifier strings, e.g.
1413
2137
  * "@shoelace-style/shoelace/components/button/button.js"
1414
2138
  * @returns {string}
1415
2139
  */
1416
- function injectNpmElementScripts(html: string, npmElements: string[]) {
1417
- const scripts = npmElements
1418
- .map((spec: string) => `<script type="module" src="/node_modules/${spec}"></script>`)
1419
- .join("\n ");
1420
-
1421
- return html.replace("</body>", ` ${scripts}\n</body>`);
2140
+ function injectNpmElementScripts(html: string, bundleUrl: string) {
2141
+ /*
2142
+ * ONE self-contained module for the whole set, and no import map.
2143
+ *
2144
+ * Each specifier used to be bundled separately with `lit-html` external, shared through the page
2145
+ * import map. Two things were wrong with that. Bun's codegen for `export *` from an external
2146
+ * emits a `__reExport(…, lit_html)` against a namespace it never imports, so the bundle threw
2147
+ * `lit_html is not defined` before defining anything — measured, not inferred. And a package
2148
+ * brings its own framework copy anyway, so sharing Jx's was a version-skew bet with no upside.
2149
+ *
2150
+ * Inlining per specifier would fix the codegen and cost seven copies of Lit on this page — a
2151
+ * documented breakage, not merely 462kb. Bundling the SET as one entry gives one copy, no
2152
+ * external, and 190kb for the seven-component demo that motivated this.
2153
+ */
2154
+ return html.replace("</body>", ` <script type="module" src="${bundleUrl}"></script>\n</body>`);
1422
2155
  }
1423
2156
 
1424
2157
  /**
@@ -1429,7 +2162,11 @@ function injectNpmElementScripts(html: string, npmElements: string[]) {
1429
2162
  * @param {string} lang
1430
2163
  * @returns {string}
1431
2164
  */
1432
- function injectHead(html: string, headEntries: JxHeadEntry[], lang: string) {
2165
+ function injectHead(
2166
+ html: string,
2167
+ headEntries: JxHeadEntry[],
2168
+ root: { lang: string; dir?: string },
2169
+ ) {
1433
2170
  const headHtml = renderHead(headEntries);
1434
2171
 
1435
2172
  // Replace the existing <head>...</head> block, preserving compiler-generated <style> and <script> blocks
@@ -1451,12 +2188,18 @@ function injectHead(html: string, headEntries: JxHeadEntry[], lang: string) {
1451
2188
  result = result.replace(headPattern, `<head>\n ${headHtml}${preservedBlocks}\n</head>`);
1452
2189
  }
1453
2190
 
1454
- // Set the lang attribute on <html>
2191
+ // Set lang and dir on <html>. `dir` matters for the same reason `lang` does: without it, a
2192
+ // Right-to-left page renders left-to-right, and the default is not "unset" but "ltr".
1455
2193
  result = result.replace(/<html\s[^>]*>/i, (match: string) => {
1456
- if (/lang=/.test(match)) {
1457
- return match.replace(/lang="[^"]*"/, `lang="${lang}"`);
2194
+ let tag = /lang=/.test(match)
2195
+ ? match.replace(/lang="[^"]*"/, `lang="${root.lang}"`)
2196
+ : match.replace("<html", `<html lang="${root.lang}"`);
2197
+ if (root.dir !== undefined) {
2198
+ tag = /\sdir=/.test(tag)
2199
+ ? tag.replace(/\sdir="[^"]*"/, ` dir="${root.dir}"`)
2200
+ : tag.replace("<html", `<html dir="${root.dir}"`);
1458
2201
  }
1459
- return match.replace("<html", `<html lang="${lang}"`);
2202
+ return tag;
1460
2203
  });
1461
2204
 
1462
2205
  return result;
@@ -1497,41 +2240,113 @@ function routeToOutputPath(urlPattern: string, outDir: string, trailingSlash: st
1497
2240
  * @param {Set<string>} [compiledUrls] - URL patterns of compiled routes, for conflict warnings
1498
2241
  * @returns {number} Number of files written
1499
2242
  */
2243
+ /**
2244
+ * Which statuses get an HTML fallback, and why each other one does not.
2245
+ *
2246
+ * An HTML meta-refresh is a _client-side_ redirect: the browser fetches the source, then navigates.
2247
+ * That is a reasonable stand-in for a 301 or a 303 on a host that ignores `_redirects`, and it is
2248
+ * actively wrong for everything else.
2249
+ */
2250
+ const REDIRECT_HTML_POLICY: Record<number, { html: boolean; canonical: boolean; why: string }> = {
2251
+ // The permanent case: a canonical link is exactly the right signal.
2252
+ 301: { canonical: true, html: true, why: "" },
2253
+ // Temporary — a canonical link would assert the permanence the status denies.
2254
+ 302: { canonical: false, html: true, why: "" },
2255
+ // "See other, with GET" is what a meta-refresh already does.
2256
+ 303: { canonical: false, html: true, why: "" },
2257
+ 307: {
2258
+ canonical: false,
2259
+ html: false,
2260
+ why: "307 preserves the request method and body; a meta-refresh silently converts POST to GET",
2261
+ },
2262
+ 308: {
2263
+ canonical: false,
2264
+ html: false,
2265
+ why: "308 preserves the request method and body; a meta-refresh silently converts POST to GET",
2266
+ },
2267
+ };
2268
+
2269
+ /**
2270
+ * Emit `dist/_redirects` plus an HTML fallback for the literal sources whose status has one.
2271
+ *
2272
+ * @param {Record<
2273
+ * string,
2274
+ * string | { destination: string; status?: number } | { destination: string; rewrite: true }
2275
+ * >} redirects
2276
+ * @param {string} outDir
2277
+ * @param {{ compiledUrls?: Set<string>; trailingSlash?: string }} [opts]
2278
+ * @returns {{ files: number; errors: string[] }}
2279
+ */
1500
2280
  function generateRedirects(
1501
- redirects: Record<string, string | { destination: string; status?: number }>,
2281
+ redirects: Record<
2282
+ string,
2283
+ string | { destination: string; status?: number } | { destination: string; rewrite: true }
2284
+ >,
1502
2285
  outDir: string,
1503
- compiledUrls = new Set<string>(),
2286
+ opts: { compiledUrls?: Set<string>; trailingSlash?: string } = {},
1504
2287
  ) {
1505
- let count = 0;
2288
+ let files = 0;
2289
+ const errors: string[] = [];
1506
2290
  const redirectLines: string[] = [];
1507
2291
  const normalizeUrl = (u: string) => (u.length > 1 && u.endsWith("/") ? u.slice(0, -1) : u);
1508
- const compiled = new Set([...compiledUrls].map((u) => normalizeUrl(u)));
2292
+ const compiled = new Set([...(opts.compiledUrls ?? [])].map((u) => normalizeUrl(u)));
2293
+ const trailingSlash = opts.trailingSlash ?? "always";
1509
2294
 
1510
2295
  for (const [source, target] of Object.entries(redirects)) {
1511
2296
  const dest = typeof target === "object" ? target.destination : target;
1512
- const status = typeof target === "object" ? (target.status ?? 301) : 301;
2297
+ const isRewrite = typeof target === "object" && "rewrite" in target && target.rewrite;
2298
+ const status = isRewrite
2299
+ ? 200
2300
+ : typeof target === "object" && "status" in target
2301
+ ? (target.status ?? 301)
2302
+ : 301;
2303
+
2304
+ if (!isRewrite && REDIRECT_HTML_POLICY[status] === undefined) {
2305
+ errors.push(
2306
+ `Redirect "${source}" has status ${status}, which is not an RFC 9110 §15.4 redirection ` +
2307
+ `status. Use one of ${Object.keys(REDIRECT_HTML_POLICY).join(", ")}, or ` +
2308
+ `{ destination, rewrite: true } for a proxy.`,
2309
+ );
2310
+ continue;
2311
+ }
1513
2312
 
1514
- // Skip patterns with :param or * — these need platform-specific handling
2313
+ redirectLines.push(`${source} ${dest} ${status}`);
2314
+
2315
+ // A pattern source cannot be a file on disk, so it lives only in `_redirects`.
1515
2316
  if (source.includes(":") || source.includes("*")) {
1516
- redirectLines.push(`${source} ${dest} ${status}`);
1517
2317
  continue;
1518
2318
  }
1519
2319
 
1520
2320
  if (compiled.has(normalizeUrl(source))) {
2321
+ const what = isRewrite ? "rewrite" : "redirect";
1521
2322
  console.warn(
1522
- `Redirect "${source}" overwrites a compiled page at the same route — ` +
1523
- `remove the redirect or the page.`,
2323
+ `The ${what} "${source}" collides with a compiled page at the same route — ` +
2324
+ `remove one or the other.`,
1524
2325
  );
1525
2326
  }
1526
2327
 
1527
- // Static redirect — emit an HTML file with meta refresh
1528
- const htmlPath = routeToOutputPath(source, outDir, "always");
2328
+ /*
2329
+ * A rewrite serves the destination's content AT the source URL. Writing an HTML file there
2330
+ * shadows the rewrite on hosts that honour `_redirects`, and turns it into a redirect on the
2331
+ * hosts that do not — so it is wrong in both directions. This was the bug.
2332
+ */
2333
+ if (isRewrite) {
2334
+ continue;
2335
+ }
2336
+ const policy = REDIRECT_HTML_POLICY[status]!;
2337
+ if (!policy.html) {
2338
+ continue;
2339
+ }
2340
+
2341
+ const htmlPath = routeToOutputPath(source, outDir, trailingSlash);
2342
+ const canonical = policy.canonical
2343
+ ? `\n <link rel="canonical" href="${escapeAttr(dest)}">`
2344
+ : `\n <meta name="robots" content="noindex">`;
1529
2345
  const html = `<!DOCTYPE html>
1530
2346
  <html>
1531
2347
  <head>
1532
2348
  <meta charset="utf-8">
1533
- <meta http-equiv="refresh" content="0;url=${escapeAttr(dest)}">
1534
- <link rel="canonical" href="${escapeAttr(dest)}">
2349
+ <meta http-equiv="refresh" content="0;url=${escapeAttr(dest)}">${canonical}
1535
2350
  <title>Redirecting...</title>
1536
2351
  </head>
1537
2352
  <body>
@@ -1540,17 +2355,15 @@ function generateRedirects(
1540
2355
  </html>`;
1541
2356
  mkdirSync(dirname(htmlPath), { recursive: true });
1542
2357
  writeFileSync(htmlPath, html, "utf8");
1543
- count += 1;
1544
- redirectLines.push(`${source} ${dest} ${status}`);
2358
+ files += 1;
1545
2359
  }
1546
2360
 
1547
- // Write _redirects file (Netlify/Cloudflare format)
1548
2361
  if (redirectLines.length > 0) {
1549
2362
  writeFileSync(join(outDir, "_redirects"), `${redirectLines.join("\n")}\n`, "utf8");
1550
- count += 1;
2363
+ files += 1;
1551
2364
  }
1552
2365
 
1553
- return count;
2366
+ return { errors, files };
1554
2367
  }
1555
2368
 
1556
2369
  /**
@@ -1587,26 +2400,60 @@ function escapeXml(str: string) {
1587
2400
 
1588
2401
  /**
1589
2402
  * Write a sitemap.xml from the collected page entries (sitemaps.org urlset 0.9). Each entry emits a
1590
- * `<loc>` plus a `<lastmod>` date (W3C YYYY-MM-DD).
2403
+ * `<loc>` plus a full RFC 3339 `<lastmod>`.
1591
2404
  *
1592
- * @param {{ loc: string; lastmod: Date }[]} entries
2405
+ * The W3C Datetime profile sitemaps.org cites admits both `YYYY-MM-DD` and a complete timestamp.
2406
+ * The date-only form threw away the time and, with it, any way to tell two edits on one day apart.
2407
+ *
2408
+ * @param {{ loc: string; lastmod: string }[]} entries
1593
2409
  * @param {string} outDir
1594
2410
  * @returns {number} Number of files written
1595
2411
  */
1596
- function generateSitemap(entries: { loc: string; lastmod: Date }[], outDir: string) {
2412
+ /** A `Date` to RFC 3339 in UTC, without fractional seconds. */
2413
+ function toRfc3339(d: Date): string {
2414
+ return d.toISOString().replace(/\.\d{3}Z$/, "Z");
2415
+ }
2416
+
2417
+ /** The namespace `xhtml:link` alternates live in, declared only when some entry has them. */
2418
+ const XHTML_NS = "http://www.w3.org/1999/xhtml";
2419
+
2420
+ interface SitemapEntry {
2421
+ loc: string;
2422
+ lastmod: string;
2423
+ alternates?: readonly LocaleAlternate[];
2424
+ }
2425
+
2426
+ function generateSitemap(entries: SitemapEntry[], outDir: string) {
1597
2427
  if (entries.length === 0) {
1598
2428
  return 0;
1599
2429
  }
1600
2430
  const urls = entries
1601
- .map(
1602
- (e) =>
2431
+ .map((e) => {
2432
+ /*
2433
+ * Every member of a translation set lists every member including itself — that reciprocity
2434
+ * is what the annotation means, and a validator checks for it.
2435
+ */
2436
+ const links = (e.alternates ?? [])
2437
+ .map(
2438
+ (a) =>
2439
+ ` <xhtml:link rel="alternate" hreflang="${escapeXml(a.hreflang)}" ` +
2440
+ `href="${escapeXml(a.href)}"/>`,
2441
+ )
2442
+ .join("\n");
2443
+ return (
1603
2444
  ` <url>\n <loc>${escapeXml(e.loc)}</loc>\n` +
1604
- ` <lastmod>${e.lastmod.toISOString().slice(0, 10)}</lastmod>\n </url>`,
1605
- )
2445
+ ` <lastmod>${e.lastmod}</lastmod>\n${links === "" ? "" : `${links}\n`} </url>`
2446
+ );
2447
+ })
1606
2448
  .join("\n");
2449
+ // The namespace declaration is conditional: a monolingual sitemap should not carry a namespace
2450
+ // It never uses.
2451
+ const ns = entries.some((e) => (e.alternates?.length ?? 0) > 0)
2452
+ ? ` xmlns:xhtml="${XHTML_NS}"`
2453
+ : "";
1607
2454
  const xml =
1608
2455
  `<?xml version="1.0" encoding="UTF-8"?>\n` +
1609
- `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
2456
+ `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${ns}>\n${urls}\n</urlset>\n`;
1610
2457
  writeFileSync(join(outDir, "sitemap.xml"), xml, "utf8");
1611
2458
  return 1;
1612
2459
  }