@jxsuite/compiler 1.5.0 → 2.0.2

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;
@@ -305,9 +435,23 @@ export async function buildSite(
305
435
  const doc = await readPageDocument(componentPath, formatRegistry);
306
436
  if (doc.tagName) {
307
437
  componentDefs.set(doc.tagName, doc);
308
- 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
+ );
309
446
  if (css) {
310
- 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
+ }
311
455
  collectAssetRefs(css, assetMounts, assetRefs);
312
456
  writeFileSync(resolve(componentOutDir, `${doc.tagName}.css`), css, "utf8");
313
457
  fileCount += 1;
@@ -356,9 +500,127 @@ export async function buildSite(
356
500
  // (absolute <loc> URLs require it) and not explicitly disabled via build.sitemap: false.
357
501
  const siteUrl = projectConfig.url;
358
502
  const sitemapEnabled = Boolean(siteUrl) && projectConfig.build.sitemap !== false;
359
- 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
+ }
360
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>();
361
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
+ }
362
624
  try {
363
625
  log(` Compiling ${route.urlPattern} ...`);
364
626
  const result = await compilePage(
@@ -373,8 +635,28 @@ export async function buildSite(
373
635
  registry,
374
636
  rewriteSidecarSrc,
375
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,
376
647
  );
377
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
+
378
660
  // Determine which component tags are fully static (for script omission)
379
661
  const staticTags = new Set<string>();
380
662
  for (const [tag, def] of componentDefs) {
@@ -391,12 +673,15 @@ export async function buildSite(
391
673
  componentCSS,
392
674
  staticTags,
393
675
  result.files.map((f: { content: string }) => f.content).join("\n"),
676
+ runtimeImports,
677
+ runtimeAssetsUsed,
394
678
  );
395
679
  }
396
680
 
397
681
  // Mounted assets this page actually references — scanned from the finished HTML so a
398
682
  // Reference is caught wherever it came from (markdown image, hand-authored page, $head).
399
683
  collectAssetRefs(result.html, assetMounts, assetRefs);
684
+ collectCspSources(result.html, cspSources);
400
685
 
401
686
  // Determine output path
402
687
  const outPath = routeToOutputPath(route.urlPattern, outDir, trailingSlash);
@@ -409,9 +694,21 @@ export async function buildSite(
409
694
  // The two always agree.
410
695
  const isConcrete = !route.urlPattern.includes(":") && !route.urlPattern.includes("*");
411
696
  if (sitemapEnabled && !result.excludeFromSitemap && isConcrete) {
697
+ const routeAlternates = alternateMap.get(route.urlPattern) ?? [];
412
698
  sitemapEntries.push({
413
- 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),
414
710
  loc: new URL(route.urlPattern, siteUrl).href,
711
+ ...(routeAlternates.length > 0 && { alternates: routeAlternates }),
415
712
  });
416
713
  }
417
714
 
@@ -510,7 +807,7 @@ export async function buildSite(
510
807
  const skipWorker = adapter === "cloudflare-pages" && deduped.size === 0 && mounts.length === 0;
511
808
  const workerSource = skipWorker
512
809
  ? null
513
- : compileSiteServer([...deduped.values()], { adapter, connectors, mounts });
810
+ : compileSiteServer([...deduped.values()], { adapter, connectors, i18n, mounts });
514
811
 
515
812
  if (workerSource) {
516
813
  // Bundle the worker self-contained for the adapter's runtime (compiler.md §12): mount
@@ -535,7 +832,17 @@ export async function buildSite(
535
832
  // Only invoke the worker for server routes; everything else stays static.
536
833
  writeFileSync(
537
834
  resolve(outDir, "_routes.json"),
538
- `${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`,
539
846
  "utf8",
540
847
  );
541
848
  fileCount += 1;
@@ -544,6 +851,30 @@ export async function buildSite(
544
851
  }
545
852
 
546
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
+
547
878
  if (sidecarBundles.size > 0) {
548
879
  log(`Bundling ${sidecarBundles.size} client sidecar module(s)...`);
549
880
  for (const [assetPath, bundle] of sidecarBundles) {
@@ -564,6 +895,49 @@ export async function buildSite(
564
895
  }
565
896
  }
566
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
+
567
941
  // ── 6e. Extension-emitted assets (extensions.md §8.4) ───────────────────
568
942
  // Section-owner classes with an `emit` capability contribute derived build artifacts
569
943
  // (search indexes, feeds, …). The host writes the returned files so extensions stay pure;
@@ -609,8 +983,12 @@ export async function buildSite(
609
983
  if (projectConfig.redirects && Object.keys(projectConfig.redirects).length > 0) {
610
984
  log("Generating redirects...");
611
985
  const compiledUrls = new Set(routes.map((r) => r.urlPattern));
612
- const redirectFiles = generateRedirects(projectConfig.redirects, outDir, compiledUrls);
613
- 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);
614
992
  }
615
993
 
616
994
  // ── 7b. Generate sitemap.xml ────────────────────────────────────────────
@@ -645,6 +1023,83 @@ export async function buildSite(
645
1023
  fileCount += ensureRobotsSitemap(outDir, siteUrl);
646
1024
  }
647
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
+
648
1103
  // ── 8. Copy declarative file mappings ──────────────────────────────────
649
1104
  if (projectConfig.copy) {
650
1105
  log("Copying mapped files...");
@@ -683,19 +1138,28 @@ export async function buildSite(
683
1138
  * serverHandler: string | null;
684
1139
  * doc: JxDocument;
685
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.
686
1144
  */
687
1145
  async function compilePage(
688
1146
  route: SiteRoute,
689
1147
  projectConfig: ProjectConfig,
690
1148
  projectRoot: string,
691
- sections: Record<string, unknown> = {},
692
- imageCache: CacheManifest | null = null,
693
- componentDefs = new Map<string, JxElement>(),
694
- imageMetaCache: ImageMetaCache | null = null,
695
- formatRegistry?: FormatRegistry,
696
- registry?: ExtensionRegistry,
697
- rewriteSidecarSrc?: (specifier: string, docDir: string | null) => string,
698
- 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,
699
1163
  ) {
700
1164
  // Load the raw page document (.json natively, other formats via the registry)
701
1165
  const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
@@ -713,7 +1177,19 @@ async function compilePage(
713
1177
  delete layoutDoc._pageTitle;
714
1178
 
715
1179
  // Inject $site and $page context
716
- 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
+ });
717
1193
 
718
1194
  // Resolve generic $prototype entries via .class.json imports (and lower registry classes)
719
1195
  await resolvePrototypes(layoutDoc, route, projectRoot, {
@@ -739,8 +1215,25 @@ async function compilePage(
739
1215
  }
740
1216
 
741
1217
  // Resolve template strings in $head entries
742
- const resolvedPageHead = resolveHeadTemplates(pageHead, scope);
743
- 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
+ );
744
1237
 
745
1238
  // Resolve template strings in the document tree (innerHTML, textContent, style, attributes)
746
1239
  // So that timing: "compiler" data is baked into the static HTML
@@ -754,13 +1247,19 @@ async function compilePage(
754
1247
  media: { ...projectConfig.$media, ...layoutDoc.$media },
755
1248
  rules: [],
756
1249
  };
757
- expandComponents(layoutDoc, componentDefs, slotCss);
1250
+ expandComponents(layoutDoc, componentDefs, slotCss, projectConfig.defaults);
758
1251
 
759
1252
  // Strip resolved timing: "compiler" state entries — they're now baked into the tree
760
1253
  // And keeping them would cause isDynamic() to misclassify the page as dynamic.
761
1254
  // Also strip resolved content arrays (from ContentCollection) that have been
762
1255
  // Baked into unrolled map templates.
763
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>();
764
1263
  for (const [key, def] of Object.entries(layoutDoc.state)) {
765
1264
  if (key === "$site" || key === "$page") {
766
1265
  continue;
@@ -773,13 +1272,54 @@ async function compilePage(
773
1272
  ) {
774
1273
  delete layoutDoc.state[key];
775
1274
  } else if (Array.isArray(def)) {
776
- delete layoutDoc.state[key];
1275
+ strippable.add(key);
777
1276
  }
778
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];
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];
1296
+ }
779
1297
  }
780
1298
 
781
1299
  // Resolve bare npm specifiers in $head (e.g. "@pkg/name/file.css" → "/node_modules/@pkg/name/file.css")
782
- 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
+ ];
783
1323
 
784
1324
  // Merge $head from site + layout + page
785
1325
  const mergedHead = mergeHead(resolvedSiteHead, resolvedLayoutHead, resolvedPageHead, {
@@ -787,6 +1327,7 @@ async function compilePage(
787
1327
  charset: projectConfig.defaults?.charset ?? "utf8",
788
1328
  ...(projectConfig.name != null && { siteName: projectConfig.name }),
789
1329
  ...(projectConfig.url != null && { siteUrl: projectConfig.url }),
1330
+ ...(locale.alternates.length > 0 && { alternates: locale.alternates }),
790
1331
  pageUrl: route.urlPattern,
791
1332
  });
792
1333
 
@@ -802,8 +1343,12 @@ async function compilePage(
802
1343
  mergedHead.splice(2, 0, { tagName: "script", textContent: colorSchemePrePaintScript() });
803
1344
  }
804
1345
 
805
- // Transform <img> nodes for responsive image optimization
806
- 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) {
807
1352
  await transformImageNodes(
808
1353
  layoutDoc,
809
1354
  projectConfig.images as ImageConfig,
@@ -816,7 +1361,17 @@ async function compilePage(
816
1361
 
817
1362
  // Compile the document using the existing compiler
818
1363
  const result = await compile(layoutDoc, {
819
- 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"] }),
820
1375
  prePaintScheme: false, // Injected via the merged <head> above, not the target template
821
1376
  projectStyle: projectConfig.style ?? null,
822
1377
  ...(rewriteSidecarSrc === undefined
@@ -837,14 +1392,35 @@ async function compilePage(
837
1392
  }
838
1393
 
839
1394
  // Post-process: inject merged <head> content into the compiled HTML
840
- result.html = injectHead(result.html, mergedHead, projectConfig.defaults?.lang ?? "en");
841
-
842
- // Inject <script type="module"> for npm $elements (cherry-picked component imports)
843
- const npmElements = (layoutDoc.$elements ?? []).filter(
844
- (e: JxElement | string) => typeof e === "string" && !e.startsWith("./") && !e.startsWith("../"),
845
- );
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
+ ];
846
1422
  if (npmElements.length > 0) {
847
- result.html = injectNpmElementScripts(result.html, npmElements as string[]);
1423
+ result.html = injectNpmElementScripts(result.html, registerElementBundle(npmElements));
848
1424
  }
849
1425
 
850
1426
  // Compile server handler if applicable (skip when provider bundles site-wide)
@@ -867,6 +1443,12 @@ async function compilePage(
867
1443
  excludeFromSitemap: pageDoc.$sitemap === false,
868
1444
  files: result.files,
869
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),
870
1452
  serverHandler,
871
1453
  };
872
1454
  }
@@ -957,6 +1539,39 @@ function buildMountSpecs(
957
1539
  return { connectors, mounts };
958
1540
  }
959
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
+
960
1575
  /**
961
1576
  * Resolve template strings in $head entries against the compiled scope.
962
1577
  *
@@ -964,6 +1579,21 @@ function buildMountSpecs(
964
1579
  * @param {Record<string, unknown>} scope
965
1580
  * @returns {JxHeadEntry[]}
966
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
+
967
1597
  function resolveHeadTemplates(headEntries: JxHeadEntry[], scope: Record<string, unknown>) {
968
1598
  return headEntries.map((entry: JxHeadEntry) => {
969
1599
  if (!entry || typeof entry !== "object") {
@@ -984,20 +1614,32 @@ function resolveHeadTemplates(headEntries: JxHeadEntry[], scope: Record<string,
984
1614
  resolved.textContent =
985
1615
  (evaluateStaticTemplate(resolved.textContent, scope) as string | null) ??
986
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
+ >;
987
1624
  }
988
1625
  return resolved;
989
1626
  });
990
1627
  }
991
1628
 
992
1629
  /**
993
- * Resolve bare npm specifiers in $head entry attributes (href, src). e.g.
994
- * "@shoelace-style/shoelace/dist/themes/light.css" →
995
- * "/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"`.
996
1634
  *
997
1635
  * @param {JxHeadEntry[]} headEntries
1636
+ * @param {(specifier: string) => string} rewrite
998
1637
  * @returns {JxHeadEntry[]}
999
1638
  */
1000
- function resolveHeadBareSpecifiers(headEntries: JxHeadEntry[]) {
1639
+ function resolveHeadBareSpecifiers(
1640
+ headEntries: JxHeadEntry[],
1641
+ rewrite: (specifier: string) => string,
1642
+ ) {
1001
1643
  return headEntries.map((entry: JxHeadEntry) => {
1002
1644
  if (!entry || typeof entry !== "object" || !entry.attributes) {
1003
1645
  return entry;
@@ -1006,7 +1648,7 @@ function resolveHeadBareSpecifiers(headEntries: JxHeadEntry[]) {
1006
1648
  for (const key of ["href", "src"]) {
1007
1649
  const val = resolved.attributes[key];
1008
1650
  if (typeof val === "string" && isBareSpecifier(val)) {
1009
- resolved.attributes[key] = `/node_modules/${val}`;
1651
+ resolved.attributes[key] = rewrite(val);
1010
1652
  }
1011
1653
  }
1012
1654
  return resolved;
@@ -1229,6 +1871,19 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
1229
1871
  }
1230
1872
  }
1231
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
+
1232
1887
  /**
1233
1888
  * Statically expand a mapped array to its resolved item nodes when `items` resolves to an array at
1234
1889
  * build time, or null otherwise (leave the array node for client-side rendering).
@@ -1278,18 +1933,20 @@ interface SlotCssCollector {
1278
1933
  * @param {JxElement | string} node
1279
1934
  * @param {Map<string, JxElement>} componentDefs
1280
1935
  * @param {SlotCssCollector} [slotCss]
1936
+ * @param {ProjectConfig["defaults"]} [defaults] - Read for `defaults.shadow` (spec.md §16.6)
1281
1937
  */
1282
1938
  function expandComponents(
1283
1939
  node: JxElement | string,
1284
1940
  componentDefs: Map<string, JxElement>,
1285
1941
  slotCss?: SlotCssCollector,
1942
+ defaults?: ProjectConfig["defaults"],
1286
1943
  ) {
1287
1944
  if (!node || typeof node !== "object") {
1288
1945
  return;
1289
1946
  }
1290
1947
  if (Array.isArray(node)) {
1291
1948
  for (const n of node as (JxElement | string)[]) {
1292
- expandComponents(n, componentDefs, slotCss);
1949
+ expandComponents(n, componentDefs, slotCss, defaults);
1293
1950
  }
1294
1951
  return;
1295
1952
  }
@@ -1297,7 +1954,7 @@ function expandComponents(
1297
1954
  // Recurse into children first (bottom-up expansion)
1298
1955
  if (Array.isArray(node.children)) {
1299
1956
  for (const child of node.children) {
1300
- expandComponents(child, componentDefs, slotCss);
1957
+ expandComponents(child, componentDefs, slotCss, defaults);
1301
1958
  }
1302
1959
  }
1303
1960
 
@@ -1336,10 +1993,27 @@ function expandComponents(
1336
1993
  .join("\n")
1337
1994
  : null;
1338
1995
 
1339
- 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);
1340
2004
  const isStatic = isComponentFullyStatic(def);
1341
2005
 
1342
- 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;
1343
2017
  delete node.children;
1344
2018
 
1345
2019
  // Resolve template-string host styles with props (per-instance values like background-image)
@@ -1383,6 +2057,8 @@ function expandComponents(
1383
2057
  * @param {Map<string, string>} [cssMap] - TagName → CSS text (for link injection)
1384
2058
  * @param {Set<string>} [staticTags] - Tags where ALL instances are fully static (skip JS)
1385
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
1386
2062
  * @returns {string}
1387
2063
  */
1388
2064
  function injectComponentScripts(
@@ -1391,6 +2067,8 @@ function injectComponentScripts(
1391
2067
  cssMap = new Map<string, string>(),
1392
2068
  staticTags = new Set<string>(),
1393
2069
  islandSource = "",
2070
+ runtimeImports: Record<string, string> = {},
2071
+ runtimeAssetsUsed = new Set<string>(),
1394
2072
  ) {
1395
2073
  // A component reaches a page two ways: as a literal tag in the static HTML, or referenced only
1396
2074
  // From inside an island's client template — in which case the tag exists solely in the island's
@@ -1423,14 +2101,18 @@ function injectComponentScripts(
1423
2101
  }
1424
2102
 
1425
2103
  // Build import map (needed for @vue/reactivity and lit-html)
1426
- const importMap = `<script type="importmap">
1427
- {
1428
- "imports": {
1429
- "@vue/reactivity": "${DEFAULT_REACTIVITY_SRC}",
1430
- "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;
1431
2113
  }
2114
+ runtimeAssetsUsed.add(url);
1432
2115
  }
1433
- </script>`;
1434
2116
 
1435
2117
  const moduleScripts = jsTags
1436
2118
  .map((tag: string) => `<script type="module" src="/components/${tag}.js"></script>`)
@@ -1445,19 +2127,31 @@ function injectComponentScripts(
1445
2127
 
1446
2128
  /**
1447
2129
  * Inject <script type="module"> tags for npm package $elements (cherry-picked component imports).
1448
- * 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.
1449
2134
  *
1450
2135
  * @param {string} html
1451
2136
  * @param {string[]} npmElements - Bare specifier strings, e.g.
1452
2137
  * "@shoelace-style/shoelace/components/button/button.js"
1453
2138
  * @returns {string}
1454
2139
  */
1455
- function injectNpmElementScripts(html: string, npmElements: string[]) {
1456
- const scripts = npmElements
1457
- .map((spec: string) => `<script type="module" src="/node_modules/${spec}"></script>`)
1458
- .join("\n ");
1459
-
1460
- 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>`);
1461
2155
  }
1462
2156
 
1463
2157
  /**
@@ -1468,7 +2162,11 @@ function injectNpmElementScripts(html: string, npmElements: string[]) {
1468
2162
  * @param {string} lang
1469
2163
  * @returns {string}
1470
2164
  */
1471
- function injectHead(html: string, headEntries: JxHeadEntry[], lang: string) {
2165
+ function injectHead(
2166
+ html: string,
2167
+ headEntries: JxHeadEntry[],
2168
+ root: { lang: string; dir?: string },
2169
+ ) {
1472
2170
  const headHtml = renderHead(headEntries);
1473
2171
 
1474
2172
  // Replace the existing <head>...</head> block, preserving compiler-generated <style> and <script> blocks
@@ -1490,12 +2188,18 @@ function injectHead(html: string, headEntries: JxHeadEntry[], lang: string) {
1490
2188
  result = result.replace(headPattern, `<head>\n ${headHtml}${preservedBlocks}\n</head>`);
1491
2189
  }
1492
2190
 
1493
- // 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".
1494
2193
  result = result.replace(/<html\s[^>]*>/i, (match: string) => {
1495
- if (/lang=/.test(match)) {
1496
- 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}"`);
1497
2201
  }
1498
- return match.replace("<html", `<html lang="${lang}"`);
2202
+ return tag;
1499
2203
  });
1500
2204
 
1501
2205
  return result;
@@ -1536,41 +2240,113 @@ function routeToOutputPath(urlPattern: string, outDir: string, trailingSlash: st
1536
2240
  * @param {Set<string>} [compiledUrls] - URL patterns of compiled routes, for conflict warnings
1537
2241
  * @returns {number} Number of files written
1538
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
+ */
1539
2280
  function generateRedirects(
1540
- redirects: Record<string, string | { destination: string; status?: number }>,
2281
+ redirects: Record<
2282
+ string,
2283
+ string | { destination: string; status?: number } | { destination: string; rewrite: true }
2284
+ >,
1541
2285
  outDir: string,
1542
- compiledUrls = new Set<string>(),
2286
+ opts: { compiledUrls?: Set<string>; trailingSlash?: string } = {},
1543
2287
  ) {
1544
- let count = 0;
2288
+ let files = 0;
2289
+ const errors: string[] = [];
1545
2290
  const redirectLines: string[] = [];
1546
2291
  const normalizeUrl = (u: string) => (u.length > 1 && u.endsWith("/") ? u.slice(0, -1) : u);
1547
- 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";
1548
2294
 
1549
2295
  for (const [source, target] of Object.entries(redirects)) {
1550
2296
  const dest = typeof target === "object" ? target.destination : target;
1551
- 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
+ }
1552
2312
 
1553
- // 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`.
1554
2316
  if (source.includes(":") || source.includes("*")) {
1555
- redirectLines.push(`${source} ${dest} ${status}`);
1556
2317
  continue;
1557
2318
  }
1558
2319
 
1559
2320
  if (compiled.has(normalizeUrl(source))) {
2321
+ const what = isRewrite ? "rewrite" : "redirect";
1560
2322
  console.warn(
1561
- `Redirect "${source}" overwrites a compiled page at the same route — ` +
1562
- `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.`,
1563
2325
  );
1564
2326
  }
1565
2327
 
1566
- // Static redirect — emit an HTML file with meta refresh
1567
- 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">`;
1568
2345
  const html = `<!DOCTYPE html>
1569
2346
  <html>
1570
2347
  <head>
1571
2348
  <meta charset="utf-8">
1572
- <meta http-equiv="refresh" content="0;url=${escapeAttr(dest)}">
1573
- <link rel="canonical" href="${escapeAttr(dest)}">
2349
+ <meta http-equiv="refresh" content="0;url=${escapeAttr(dest)}">${canonical}
1574
2350
  <title>Redirecting...</title>
1575
2351
  </head>
1576
2352
  <body>
@@ -1579,17 +2355,15 @@ function generateRedirects(
1579
2355
  </html>`;
1580
2356
  mkdirSync(dirname(htmlPath), { recursive: true });
1581
2357
  writeFileSync(htmlPath, html, "utf8");
1582
- count += 1;
1583
- redirectLines.push(`${source} ${dest} ${status}`);
2358
+ files += 1;
1584
2359
  }
1585
2360
 
1586
- // Write _redirects file (Netlify/Cloudflare format)
1587
2361
  if (redirectLines.length > 0) {
1588
2362
  writeFileSync(join(outDir, "_redirects"), `${redirectLines.join("\n")}\n`, "utf8");
1589
- count += 1;
2363
+ files += 1;
1590
2364
  }
1591
2365
 
1592
- return count;
2366
+ return { errors, files };
1593
2367
  }
1594
2368
 
1595
2369
  /**
@@ -1626,26 +2400,60 @@ function escapeXml(str: string) {
1626
2400
 
1627
2401
  /**
1628
2402
  * Write a sitemap.xml from the collected page entries (sitemaps.org urlset 0.9). Each entry emits a
1629
- * `<loc>` plus a `<lastmod>` date (W3C YYYY-MM-DD).
2403
+ * `<loc>` plus a full RFC 3339 `<lastmod>`.
1630
2404
  *
1631
- * @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
1632
2409
  * @param {string} outDir
1633
2410
  * @returns {number} Number of files written
1634
2411
  */
1635
- 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) {
1636
2427
  if (entries.length === 0) {
1637
2428
  return 0;
1638
2429
  }
1639
2430
  const urls = entries
1640
- .map(
1641
- (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 (
1642
2444
  ` <url>\n <loc>${escapeXml(e.loc)}</loc>\n` +
1643
- ` <lastmod>${e.lastmod.toISOString().slice(0, 10)}</lastmod>\n </url>`,
1644
- )
2445
+ ` <lastmod>${e.lastmod}</lastmod>\n${links === "" ? "" : `${links}\n`} </url>`
2446
+ );
2447
+ })
1645
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
+ : "";
1646
2454
  const xml =
1647
2455
  `<?xml version="1.0" encoding="UTF-8"?>\n` +
1648
- `<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`;
1649
2457
  writeFileSync(join(outDir, "sitemap.xml"), xml, "utf8");
1650
2458
  return 1;
1651
2459
  }