@jxsuite/compiler 2.0.8 → 3.1.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.
@@ -21,7 +21,15 @@ import {
21
21
  import { createHash } from "node:crypto";
22
22
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
23
23
  import { isMappedArray, isRef } from "@jxsuite/schema/guards";
24
- import { isNpmSpecifier, npmAssetPath, sidecarAssetPath } from "@jxsuite/schema/asset-paths";
24
+ import {
25
+ isNpmSpecifier,
26
+ npmAssetPath,
27
+ sidecarAssetPath,
28
+ siteAbsoluteUrl,
29
+ siteBasePath,
30
+ withBase,
31
+ } from "@jxsuite/schema/asset-paths";
32
+ import { rewriteHtmlBase } from "./base-path.ts";
25
33
  import {
26
34
  CLIENT_EXTERNALS,
27
35
  bundleEntry,
@@ -40,6 +48,7 @@ import {
40
48
  import {
41
49
  buildHeaderRules,
42
50
  contentTypeRules,
51
+ rebaseHeaderRules,
43
52
  writeHeaders,
44
53
  writeNoJekyll,
45
54
  } from "./headers-emitter.ts";
@@ -50,7 +59,7 @@ import type { AssetMount } from "@jxsuite/schema/asset-paths";
50
59
  import type { FormatEntry, FormatRegistry } from "@jxsuite/schema/format-registry";
51
60
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
52
61
  import { resolveLayout } from "./layout-resolver.ts";
53
- import { mergeHead, renderHead } from "./head-merger.ts";
62
+ import { mergeHead, renderHead } from "@jxsuite/site/head-merger";
54
63
  import { unregisteredHeadRelations } from "./link-relations.ts";
55
64
  import { injectContext } from "./context-injection.ts";
56
65
  import { compile, compileServer, compileSiteServer } from "../compiler.ts";
@@ -62,6 +71,7 @@ import {
62
71
  collectServerEntries,
63
72
  collectStyles,
64
73
  colorSchemePrePaintScript,
74
+ escapeStyleText,
65
75
  evaluateStaticTemplate,
66
76
  isComponentFullyStatic,
67
77
  isSingleExpression,
@@ -99,7 +109,8 @@ import {
99
109
  } from "./i18n.ts";
100
110
  import type { LocaleAlternate, PageLocaleContext } from "./i18n.ts";
101
111
  import {
102
- isPrefixSpecifier,
112
+ importMapAssets,
113
+ importMapAssetsInHtml,
103
114
  renderImportMap,
104
115
  resolveClientRuntime,
105
116
  writeClientRuntime,
@@ -160,6 +171,13 @@ export async function buildSite(
160
171
  const pagesDir = resolve(projectRoot, "pages");
161
172
  const publicDir = resolve(projectRoot, "public");
162
173
  const trailingSlash = projectConfig.build.trailingSlash ?? "always";
174
+ /*
175
+ * Browser bundles are minified unless the project opts out. Off, the six scripts jxsuite.com
176
+ * loads weigh 155 kB of source a phone has to parse before it can paint; on, 65 kB. The opt-out
177
+ * exists for two real cases: reading a deployed stack trace, and diffing `dist/` across bundler
178
+ * backends, which agree on semantics but not on minified bytes.
179
+ */
180
+ const minify = projectConfig.build.minify !== false;
163
181
 
164
182
  // Client sidecar bundles: bundleable Function-def `$src` specifiers (and lowered-def `$bundle`
165
183
  // Hints) are rewritten to their /assets/ URL during compilation, registered here, and bundled
@@ -201,6 +219,11 @@ export async function buildSite(
201
219
  */
202
220
  const clientRuntime = resolveClientRuntime();
203
221
  const runtimeImports = clientRuntime.imports;
222
+ /*
223
+ * What step 6d bundles, scanned back out of each finished page rather than recorded wherever a
224
+ * map is emitted. Several places emit one and only `injectComponentScripts` was recording, so a
225
+ * page whose interactivity came from anything else shipped a map naming files no build wrote.
226
+ */
204
227
  const runtimeAssetsUsed = new Set<string>();
205
228
  /*
206
229
  * Scanned from every finished page, so the hashes name the exact bytes shipped rather than what
@@ -505,6 +528,12 @@ export async function buildSite(
505
528
  // Sitemap is generated from the route table when a production `url` is configured
506
529
  // (absolute <loc> URLs require it) and not explicitly disabled via build.sitemap: false.
507
530
  const siteUrl = projectConfig.url;
531
+ /*
532
+ * The path the site is deployed under, read back off `url` (RFC 3986 §5 — see `base-path.ts`).
533
+ * `""` for a site at an origin root, which is every deployment Jx documents, so every use below
534
+ * is a no-op there rather than a branch.
535
+ */
536
+ const basePath = siteBasePath(siteUrl);
508
537
  const sitemapEnabled = Boolean(siteUrl) && projectConfig.build.sitemap !== false;
509
538
  const sitemapEntries: SitemapEntry[] = [];
510
539
 
@@ -680,10 +709,18 @@ export async function buildSite(
680
709
  staticTags,
681
710
  result.files.map((f: { content: string }) => f.content).join("\n"),
682
711
  runtimeImports,
683
- runtimeAssetsUsed,
684
712
  );
685
713
  }
686
714
 
715
+ /*
716
+ * Whatever import map this page ended up with, step 6d owes it the files it names. Read from
717
+ * the finished HTML so the answer does not depend on which of the emission paths produced
718
+ * the map — the one that reported and the one that shipped were different sets (issue #227).
719
+ */
720
+ for (const assetPath of importMapAssetsInHtml(result.html)) {
721
+ runtimeAssetsUsed.add(assetPath);
722
+ }
723
+
687
724
  // Mounted assets this page actually references — scanned from the finished HTML so a
688
725
  // Reference is caught wherever it came from (markdown image, hand-authored page, $head).
689
726
  collectAssetRefs(result.html, assetMounts, assetRefs);
@@ -692,7 +729,9 @@ export async function buildSite(
692
729
  // Determine output path
693
730
  const outPath = routeToOutputPath(route.urlPattern, outDir, trailingSlash);
694
731
  mkdirSync(dirname(outPath), { recursive: true });
695
- writeFileSync(outPath, result.html, "utf8");
732
+ /* Last, after the scans above: they ask what this page REFERENCES, and the answer is a
733
+ build-output path, not a deployed URL. Re-rooting first would make every one of them miss. */
734
+ writeFileSync(outPath, rewriteHtmlBase(result.html, basePath), "utf8");
696
735
  fileCount += 1;
697
736
 
698
737
  // Record a sitemap entry for this concrete page (skip unexpanded dynamic routes and
@@ -713,7 +752,7 @@ export async function buildSite(
713
752
  typeof route.sourceMtime === "string" && route.sourceMtime !== ""
714
753
  ? route.sourceMtime
715
754
  : toRfc3339(statSync(route.sourcePath).mtime),
716
- loc: new URL(route.urlPattern, siteUrl).href,
755
+ loc: siteAbsoluteUrl(route.urlPattern, siteUrl),
717
756
  ...(routeAlternates.length > 0 && { alternates: routeAlternates }),
718
757
  });
719
758
  }
@@ -813,7 +852,13 @@ export async function buildSite(
813
852
  const skipWorker = adapter === "cloudflare-pages" && deduped.size === 0 && mounts.length === 0;
814
853
  const workerSource = skipWorker
815
854
  ? null
816
- : compileSiteServer([...deduped.values()], { adapter, connectors, i18n, mounts });
855
+ : compileSiteServer([...deduped.values()], {
856
+ adapter,
857
+ base: basePath,
858
+ connectors,
859
+ i18n,
860
+ mounts,
861
+ });
817
862
 
818
863
  if (workerSource) {
819
864
  // Bundle the worker self-contained for the adapter's runtime (compiler.md §12): mount
@@ -862,6 +907,8 @@ export async function buildSite(
862
907
  const runtime = await writeClientRuntime(
863
908
  { ...clientRuntime, assetPaths: [...runtimeAssetsUsed] },
864
909
  outDir,
910
+ undefined,
911
+ minify,
865
912
  );
866
913
  fileCount += runtime.written;
867
914
  for (const error of runtime.errors) {
@@ -889,7 +936,7 @@ export async function buildSite(
889
936
  mkdirSync(dirname(outfile), { recursive: true });
890
937
  await bundleEntry(
891
938
  { entryPath: bundle.entryPath, outfile },
892
- { external: CLIENT_EXTERNALS, target: "browser" },
939
+ { external: CLIENT_EXTERNALS, minify, target: "browser" },
893
940
  );
894
941
  fileCount += 1;
895
942
  log(` ${bundle.specifier} → ${assetPath}`);
@@ -914,7 +961,7 @@ export async function buildSite(
914
961
  await bundleSource(
915
962
  `${specifiers.map((sp) => `import ${JSON.stringify(sp)};`).join("\n")}\n`,
916
963
  { outfile, resolveDir: projectRoot },
917
- { target: "browser" },
964
+ { minify, target: "browser" },
918
965
  );
919
966
  fileCount += 1;
920
967
  log(` ${specifiers.length} element(s) → ${assetPath}`);
@@ -933,7 +980,7 @@ export async function buildSite(
933
980
  * discovered from their contents rather than declared anywhere (site-architecture.md §8.7).
934
981
  */
935
982
  if (runtimeAssetsUsed.size > 0) {
936
- const subpaths = await writeRuntimeSubpaths(outDir);
983
+ const subpaths = await writeRuntimeSubpaths(outDir, undefined, minify);
937
984
  if (subpaths.written > 0) {
938
985
  log(`Bundling ${subpaths.written} runtime subpath module(s)...`);
939
986
  }
@@ -990,6 +1037,7 @@ export async function buildSite(
990
1037
  log("Generating redirects...");
991
1038
  const compiledUrls = new Set(routes.map((r) => r.urlPattern));
992
1039
  const redirects = generateRedirects(projectConfig.redirects, outDir, {
1040
+ basePath,
993
1041
  compiledUrls,
994
1042
  trailingSlash: projectConfig.build.trailingSlash,
995
1043
  });
@@ -1067,7 +1115,8 @@ export async function buildSite(
1067
1115
  {
1068
1116
  const sw = normalizeServiceWorker(projectConfig.serviceWorker);
1069
1117
  if (sw !== null) {
1070
- const output = sw === false ? tombstoneServiceWorker() : buildServiceWorker(sw, outDir);
1118
+ const output =
1119
+ sw === false ? tombstoneServiceWorker() : buildServiceWorker(sw, outDir, basePath);
1071
1120
  for (const warning of output.warnings) {
1072
1121
  console.warn(warning);
1073
1122
  }
@@ -1091,16 +1140,40 @@ export async function buildSite(
1091
1140
  );
1092
1141
  errors.push(...headerErrors);
1093
1142
  // Runs here, after every emitter, so it can see which of these files the build actually wrote.
1094
- const rules = [...securityRules, ...contentTypeRules(outDir)];
1143
+ const rules = rebaseHeaderRules([...securityRules, ...contentTypeRules(outDir)], basePath);
1095
1144
  if (rules.length > 0) {
1096
1145
  log("Writing response headers...");
1097
1146
  fileCount += writeHeaders(outDir, rules);
1098
- const { adapter } = projectConfig.build;
1147
+ const { adapter, deploy } = projectConfig.build;
1099
1148
  if (adapter === "node" || adapter === "bun") {
1100
1149
  console.warn(
1101
1150
  `The "${adapter}" adapter serves no static assets, so dist/_headers is documentation ` +
1102
1151
  `rather than configuration — apply these headers at the reverse proxy in front of it.`,
1103
1152
  );
1153
+ } else if (adapter === undefined && deploy === undefined) {
1154
+ /*
1155
+ * `_headers` is a Cloudflare/Netlify convention, not a web standard, and the build has no
1156
+ * way to know where a plain static `dist/` is going. Most static hosts — GitHub Pages among
1157
+ * them — ignore the file entirely and serve their own `Cache-Control`, which is how a site
1158
+ * ends up shipping a year-long `immutable` rule for its content-addressed images and
1159
+ * getting ten minutes.
1160
+ *
1161
+ * A `public/CNAME` is GitHub Pages' custom-domain marker and nothing else's, so it is a
1162
+ * specific enough signal to say so out loud rather than warn every static build.
1163
+ */
1164
+ if (existsSync(resolve(publicDir, "CNAME"))) {
1165
+ console.warn(
1166
+ "GitHub Pages ignores dist/_headers and serves its own Cache-Control: max-age=600, " +
1167
+ "so the generated caching and security rules will not take effect. Deploy behind a " +
1168
+ "host that reads _headers (Cloudflare Pages, Cloudflare Workers assets, Netlify) " +
1169
+ "to apply them.",
1170
+ );
1171
+ } else {
1172
+ log(
1173
+ " note: _headers is read by Cloudflare Pages, Cloudflare Workers assets and Netlify; " +
1174
+ "other static hosts ignore it and serve their own Cache-Control.",
1175
+ );
1176
+ }
1104
1177
  }
1105
1178
  }
1106
1179
  fileCount += writeNoJekyll(outDir);
@@ -1167,11 +1240,15 @@ async function compilePage(
1167
1240
  /** Registers a page's npm `$elements` set as one bundle and returns its URL. */
1168
1241
  registerElementBundle: (specifiers: string[]) => string,
1169
1242
  ) {
1243
+ /* Derived rather than passed as a seventeenth parameter: it is a pure function of
1244
+ `projectConfig.url`, which is already here, so the two cannot disagree. */
1245
+ const basePath = siteBasePath(projectConfig.url);
1246
+
1170
1247
  // Load the raw page document (.json natively, other formats via the registry)
1171
1248
  const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
1172
1249
 
1173
1250
  // Resolve layout (wraps page in layout with slot distribution)
1174
- const layoutDoc = resolveLayout(pageDoc, projectConfig, projectRoot);
1251
+ const layoutDoc = await resolveLayout(pageDoc, projectConfig, projectRoot);
1175
1252
 
1176
1253
  // Extract head arrays before they get lost in the merge
1177
1254
  const pageHead = (pageDoc.$head ?? layoutDoc._pageHead ?? []) as JxHeadEntry[];
@@ -1316,7 +1393,7 @@ async function compilePage(
1316
1393
  const swHead: JxHeadEntry[] =
1317
1394
  swConfig === null || swConfig === false
1318
1395
  ? []
1319
- : [{ tagName: "script", textContent: registrationScript(swConfig.scope ?? "/") }];
1396
+ : [{ tagName: "script", textContent: registrationScript(swConfig.scope ?? "/", basePath) }];
1320
1397
 
1321
1398
  const resolvedSiteHead = [
1322
1399
  // The manifest link and theme colour are first-party and unconditional once declared, so they
@@ -1393,7 +1470,7 @@ async function compilePage(
1393
1470
  if (slotCss.rules.length > 0) {
1394
1471
  result.html = result.html.replace(
1395
1472
  "</head>",
1396
- `<style>\n${slotCss.rules.join("\n")}\n</style>\n</head>`,
1473
+ `<style>\n${escapeStyleText(slotCss.rules.join("\n"))}\n</style>\n</head>`,
1397
1474
  );
1398
1475
  }
1399
1476
 
@@ -1480,6 +1557,7 @@ function buildMountSpecs(
1480
1557
  if (activeMounts.length === 0) {
1481
1558
  return { connectors: [], mounts: [] };
1482
1559
  }
1560
+ const base = siteBasePath(projectConfig.url);
1483
1561
 
1484
1562
  const sections: Record<string, unknown> = {};
1485
1563
  for (const contribution of registry.projectContributions()) {
@@ -1498,11 +1576,15 @@ function buildMountSpecs(
1498
1576
  `to generate a deployable worker`,
1499
1577
  );
1500
1578
  }
1579
+ /* Both are request paths in the deployed worker: the route it registers, and the prefix the
1580
+ mount strips to find its own sub-path. They must carry the same base or the handler would
1581
+ answer a URL and then mis-read it. */
1582
+ const mountBase = withBase(base, server.basePath);
1501
1583
  return {
1502
- basePath: server.basePath,
1584
+ basePath: mountBase,
1503
1585
  className: (entry.classDef.title as string | undefined) ?? entry.name,
1504
1586
  module,
1505
- options: { basePath: server.basePath, sections },
1587
+ options: { basePath: mountBase, sections },
1506
1588
  order: server.order ?? 100,
1507
1589
  };
1508
1590
  });
@@ -2074,7 +2156,6 @@ function expandComponents(
2074
2156
  * @param {Set<string>} [staticTags] - Tags where ALL instances are fully static (skip JS)
2075
2157
  * @param {string} [islandSource] - Concatenated island modules emitted for this page
2076
2158
  * @param {Record<string, string>} [runtimeImports] - The import map's `imports` object
2077
- * @param {Set<string>} [runtimeAssetsUsed] - Collects the asset paths an emitted import map names
2078
2159
  * @returns {string}
2079
2160
  */
2080
2161
  function injectComponentScripts(
@@ -2084,7 +2165,6 @@ function injectComponentScripts(
2084
2165
  staticTags = new Set<string>(),
2085
2166
  islandSource = "",
2086
2167
  runtimeImports: Record<string, string> = {},
2087
- runtimeAssetsUsed = new Set<string>(),
2088
2168
  ) {
2089
2169
  // A component reaches a page two ways: as a literal tag in the static HTML, or referenced only
2090
2170
  // From inside an island's client template — in which case the tag exists solely in the island's
@@ -2097,15 +2177,31 @@ function injectComponentScripts(
2097
2177
  return html;
2098
2178
  }
2099
2179
 
2100
- // Inject CSS links in <head> for ALL components that have CSS sidecars
2101
- const cssLinks = usedTags
2102
- .filter((tag: string) => cssMap.has(tag))
2103
- .map((tag: string) => `<link rel="stylesheet" href="/components/${tag}.css">`)
2104
- .join("\n ");
2105
- let result = html;
2106
- if (cssLinks) {
2107
- result = result.replace("</head>", ` ${cssLinks}\n</head>`);
2108
- }
2180
+ /*
2181
+ * Component CSS is INLINED, not linked.
2182
+ *
2183
+ * A `<link rel="stylesheet">` per component tag is a render-blocking request per component: on
2184
+ * jxsuite.com's home page, 11 of them totalling 22 kB — the whole of its critical request chain,
2185
+ * for sheets averaging 2 kB each. The page already carries its project and page CSS inline
2186
+ * (`buildStyleBlock`), so this is the same delivery, applied consistently.
2187
+ *
2188
+ * The trade is real and deliberate: these bytes now repeat on every page instead of caching
2189
+ * across navigations. They are small, they gzip against the markup around them, and a round trip
2190
+ * on a phone costs more than the bytes do. Components in shadow mode are untouched — their sheet
2191
+ * is linked from inside the declarative shadow root and never enters `cssMap`.
2192
+ *
2193
+ * Emitted last in <head>, exactly where the links were, so the cascade is unchanged.
2194
+ */
2195
+ const componentCss = usedTags
2196
+ .map((tag: string) => cssMap.get(tag))
2197
+ .filter((css): css is string => css !== undefined && css !== "")
2198
+ .join("\n");
2199
+ const styleBlock =
2200
+ componentCss === "" ? "" : `<style>\n${escapeStyleText(componentCss)}\n </style>`;
2201
+ const intoHead = (fragments: string[]) => {
2202
+ const block = fragments.filter(Boolean).join("\n ");
2203
+ return block === "" ? html : html.replace("</head>", ` ${block}\n</head>`);
2204
+ };
2109
2205
 
2110
2206
  // Only inject JS for components that have non-static instances. An island-rendered instance is
2111
2207
  // Never prerendered, so it needs its module even when the component itself is fully static —
@@ -2113,22 +2209,30 @@ function injectComponentScripts(
2113
2209
  // The static HTML: those instances are prerendered, but the ones the island creates are not.
2114
2210
  const jsTags = usedTags.filter((tag: string) => inIsland(tag) || !staticTags.has(tag));
2115
2211
  if (jsTags.length === 0) {
2116
- return result;
2212
+ return intoHead([styleBlock]);
2117
2213
  }
2118
2214
 
2119
2215
  // Build import map (needed for @vue/reactivity and lit-html)
2120
2216
  const importMap = renderImportMap(runtimeImports);
2121
- for (const [specifier, url] of Object.entries(runtimeImports)) {
2122
- /*
2123
- * A prefix key's value is the DIRECTORY its subpaths are served from — `/assets/lit-html/` —
2124
- * not a file any build writes. `writeClientRuntime` matches this set against exact asset
2125
- * paths, so a directory in it bundled nothing and only inflated the count this build logged.
2126
- */
2127
- if (isPrefixSpecifier(specifier) || !url.startsWith("/")) {
2128
- continue;
2129
- }
2130
- runtimeAssetsUsed.add(url);
2131
- }
2217
+ /*
2218
+ * The runtime modules THIS page needs — the hints are per page, so they are read from this
2219
+ * page's map rather than from the build-wide set, which would have every page hint at every
2220
+ * other page's runtime.
2221
+ */
2222
+ const pageRuntimeAssets = importMapAssets(runtimeImports);
2223
+
2224
+ /*
2225
+ * An import map declares where a bare specifier lives; it does not tell the browser to go get it.
2226
+ * Without these hints `/assets/vue-reactivity.js` is discovered only after a component module has
2227
+ * been fetched AND parsed — a three-deep request chain on the critical path, on a connection
2228
+ * where each hop is a round trip. The component modules are hinted too so the whole set starts
2229
+ * downloading while the HTML is still being parsed.
2230
+ */
2231
+ const preloads = [...pageRuntimeAssets, ...jsTags.map((tag: string) => `/components/${tag}.js`)]
2232
+ .map((href) => `<link rel="modulepreload" href="${href}">`)
2233
+ .join("\n ");
2234
+
2235
+ const result = intoHead([preloads, styleBlock]);
2132
2236
 
2133
2237
  const moduleScripts = jsTags
2134
2238
  .map((tag: string) => `<script type="module" src="/components/${tag}.js"></script>`)
@@ -2299,7 +2403,7 @@ function generateRedirects(
2299
2403
  string | { destination: string; status?: number } | { destination: string; rewrite: true }
2300
2404
  >,
2301
2405
  outDir: string,
2302
- opts: { compiledUrls?: Set<string>; trailingSlash?: string } = {},
2406
+ opts: { compiledUrls?: Set<string>; trailingSlash?: string; basePath?: string } = {},
2303
2407
  ) {
2304
2408
  let files = 0;
2305
2409
  const errors: string[] = [];
@@ -2326,7 +2430,11 @@ function generateRedirects(
2326
2430
  continue;
2327
2431
  }
2328
2432
 
2329
- redirectLines.push(`${source} ${dest} ${status}`);
2433
+ /* Both halves are site URLs, and the host matches the source against a REQUEST path — which
2434
+ arrives under the base. A rule left at the old root would never fire, and one that did would
2435
+ send the visitor out of the deployment. */
2436
+ const rebased = opts.basePath ?? "";
2437
+ redirectLines.push(`${withBase(rebased, source)} ${withBase(rebased, dest)} ${status}`);
2330
2438
 
2331
2439
  // A pattern source cannot be a file on disk, so it lives only in `_redirects`.
2332
2440
  if (source.includes(":") || source.includes("*")) {
@@ -2493,7 +2601,7 @@ function ensureRobotsSitemap(outDir: string, siteUrl: string) {
2493
2601
  if (!content.endsWith("\n")) {
2494
2602
  content += "\n";
2495
2603
  }
2496
- content += `\nSitemap: ${new URL("/sitemap.xml", siteUrl).href}\n`;
2604
+ content += `\nSitemap: ${siteAbsoluteUrl("/sitemap.xml", siteUrl)}\n`;
2497
2605
  writeFileSync(robotsPath, content, "utf8");
2498
2606
  return existed ? 0 : 1;
2499
2607
  }
@@ -9,6 +9,7 @@
9
9
  * @docs framework/site/deployment
10
10
  */
11
11
 
12
+ import { siteBasePath, withBase } from "@jxsuite/schema/asset-paths";
12
13
  import { mkdirSync, writeFileSync } from "node:fs";
13
14
  import { dirname, join } from "node:path";
14
15
  import { canonicalizeLocale } from "@jxsuite/schema/locale";
@@ -48,17 +49,27 @@ export function buildManifest(projectConfig: ProjectConfig): WellKnownOutput {
48
49
  }
49
50
 
50
51
  const name = config.name ?? projectConfig.name ?? "Jx Site";
51
- const icons = config.icons ?? [];
52
+ /*
53
+ * Every URL in a manifest is fetched or navigated to by the browser, so all three carry the
54
+ * deployment base (`base-path.ts`). `scope` is the one that fails loudly — a scope the manifest's
55
+ * own URL is not inside makes the manifest invalid and the site uninstallable — while a
56
+ * `start_url` at the old root just opens the wrong page from the home screen.
57
+ */
58
+ const base = siteBasePath(projectConfig.url);
59
+ const icons = [];
60
+ for (const icon of config.icons ?? []) {
61
+ icons.push(typeof icon.src === "string" ? { ...icon, src: withBase(base, icon.src) } : icon);
62
+ }
52
63
  const manifest: Record<string, unknown> = {
53
64
  display: config.display ?? "standalone",
54
65
  icons,
55
66
  name,
56
67
  short_name: config.shortName ?? name,
57
- start_url: config.startUrl ?? "/",
68
+ start_url: withBase(base, config.startUrl ?? "/"),
58
69
  ...(config.description === undefined ? {} : { description: config.description }),
59
70
  ...(config.themeColor === undefined ? {} : { theme_color: config.themeColor }),
60
71
  ...(config.backgroundColor === undefined ? {} : { background_color: config.backgroundColor }),
61
- ...(config.scope === undefined ? {} : { scope: config.scope }),
72
+ ...(config.scope === undefined ? {} : { scope: withBase(base, config.scope) }),
62
73
  ...(config.orientation === undefined ? {} : { orientation: config.orientation }),
63
74
  ...(config.lang === undefined ? {} : { lang: config.lang }),
64
75
  ...(config.dir === undefined ? {} : { dir: config.dir }),
@@ -17,6 +17,7 @@ import {
17
17
  escapeHtml,
18
18
  isMutating,
19
19
  isSchemaOnly,
20
+ srcExportName,
20
21
  srcImportBinding,
21
22
  tagNameToClassName,
22
23
  } from "../shared.ts";
@@ -351,11 +352,36 @@ export function emitElementModule(
351
352
 
352
353
  // Collect $src imports from state entries before emitting other imports
353
354
  const srcImportMap = new Map<string, string[]>();
355
+ /** `$lazy` `$src` modules → the (export, local binding) pairs each one supplies. */
356
+ const lazySrcMap = new Map<string, { binding: string; exportName: string }[]>();
354
357
  const defs = doc.state ?? {};
358
+ const callableRefs = collectCallableRefs(doc);
355
359
  for (const [key, def] of Object.entries(defs)) {
356
360
  const d = def as JxMutableNode;
357
361
  if (d && typeof d === "object" && !Array.isArray(d) && d.$prototype === "Function" && d.$src) {
358
362
  const srcPath = d.$src;
363
+ if (d.$lazy === true) {
364
+ /*
365
+ * A computed reads its value synchronously, and a lazy binding can only ever hand back a
366
+ * promise. Silently producing `computed(() => Promise)` would render "[object Promise]" in
367
+ * whatever the template bound it to, so this is a build error rather than a surprise.
368
+ */
369
+ if (!callableRefs.has(key) && !LIFECYCLE_KEYS.has(key)) {
370
+ throw new Error(
371
+ `"${key}" declares "$lazy": true but is used as a computed value, not called. ` +
372
+ `A lazily loaded function resolves to a promise; a computed must resolve now. ` +
373
+ `Remove "$lazy", or call ${key}() from a handler instead of binding its value.`,
374
+ );
375
+ }
376
+ if (!lazySrcMap.has(srcPath)) {
377
+ lazySrcMap.set(srcPath, []);
378
+ }
379
+ (lazySrcMap.get(srcPath) as { binding: string; exportName: string }[]).push({
380
+ binding: key,
381
+ exportName: srcExportName(key, d),
382
+ });
383
+ continue;
384
+ }
359
385
  if (!srcImportMap.has(srcPath)) {
360
386
  srcImportMap.set(srcPath, []);
361
387
  }
@@ -380,6 +406,31 @@ export function emitElementModule(
380
406
  lines.push(
381
407
  `import { reactive, computed, effect, stop } from '@vue/reactivity';`,
382
408
  `import { render, html } from 'lit-html';`,
409
+ );
410
+
411
+ /*
412
+ * `$lazy` swaps the static import for a memoized dynamic one. A static import is a download, a
413
+ * parse and an evaluate on every page that renders the component, whether or not anybody calls
414
+ * the function: jxsuite.com's search client is 70 kB of MiniSearch fetched by every visitor, most
415
+ * of whom never open search. The local binding keeps its name so every call site is unchanged;
416
+ * what changes is that it now returns a promise.
417
+ */
418
+ let lazyIndex = 0;
419
+ for (const [srcPath, entries] of lazySrcMap) {
420
+ const importPath = rewriteSrc ? rewriteSrc(srcPath) : srcPath;
421
+ const mod = `_jxLazy${lazyIndex}`;
422
+ lazyIndex += 1;
423
+ lines.push(
424
+ `let ${mod};`,
425
+ `const ${mod}Load = () => (${mod} ??= import('${importPath}'));`,
426
+ ...entries.map(
427
+ ({ binding, exportName }) =>
428
+ `const ${binding} = (...args) => ${mod}Load().then((m) => m.${exportName}(...args));`,
429
+ ),
430
+ );
431
+ }
432
+
433
+ lines.push(
383
434
  "",
384
435
  `class ${className} extends HTMLElement {`,
385
436
  // One registry for every effect the element creates — render, dynamic host styles, Request
@@ -401,7 +452,6 @@ export function emitElementModule(
401
452
  const formulaEntries: [string, JxExpressionDef][] = [];
402
453
  /** `"${…}"` state entries — computed, exactly as the runtime treats them. */
403
454
  const templateEntries: [string, string][] = [];
404
- const callableRefs = collectCallableRefs(doc);
405
455
  for (const [key, def] of Object.entries(defs)) {
406
456
  const d = def as JxMutableNode;
407
457
  if (isExpressionDef(d)) {
@@ -5,6 +5,7 @@
5
5
  * file.
6
6
  */
7
7
 
8
+ import { withBase } from "@jxsuite/schema/asset-paths";
8
9
  import { dereference } from "@apidevtools/json-schema-ref-parser";
9
10
  import { collectServerEntries } from "../shared.ts";
10
11
  import { localeNegotiationMiddleware } from "../site/locale-negotiation.ts";
@@ -110,15 +111,21 @@ export function compileSiteServer(
110
111
  mounts?: SiteMountSpec[];
111
112
  connectors?: SiteConnectorSpec[];
112
113
  i18n?: ResolvedI18n | null;
114
+ base?: string;
113
115
  } = {},
114
116
  ) {
115
- const {
116
- baseUrl = "/_jx/server",
117
- adapter = null,
118
- mounts = [],
119
- connectors = [],
120
- i18n = null,
121
- } = opts;
117
+ const { adapter = null, mounts = [], connectors = [], i18n = null, base = "" } = opts;
118
+ /*
119
+ * Routes are registered under the deployment base (`site/base-path.ts`), because the worker IS
120
+ * the origin: the request it sees is the one the visitor's browser sent, and on a site served
121
+ * from `/m/my-site/` that path carries the prefix. A route left at the bare path would simply
122
+ * never match — the 404 nobody attributes to a config value.
123
+ *
124
+ * The dev server takes the opposite approach and strips the base at the edge (`server.ts`), so
125
+ * everything behind it routes on the bare path. Each is internally consistent: production serves
126
+ * exactly one URL space, while `jx dev` deliberately answers both.
127
+ */
128
+ const baseUrl = withBase(base, opts.baseUrl ?? "/_jx/server");
122
129
  if (entries.length === 0 && mounts.length === 0 && !adapter) {
123
130
  return null;
124
131
  }
package/src/types.ts CHANGED
@@ -1,14 +1,8 @@
1
- export interface HeadMergeContext {
2
- title?: string;
3
- siteName?: string;
4
- lang?: string;
5
- charset?: string;
6
- url?: string;
7
- siteUrl?: string;
8
- pageUrl?: string;
9
- /** `rel="alternate"` links for this page's translations (site-architecture.md §13.5). */
10
- alternates?: readonly { hreflang: string; href: string }[];
11
- }
1
+ /**
2
+ * `HeadMergeContext` moved to `@jxsuite/site/head-merger` with the merger itself — a browser bundle
3
+ * and a Worker both assemble the same `<head>`, and neither can import this package.
4
+ */
5
+ export type { HeadMergeContext } from "@jxsuite/site/head-merger";
12
6
 
13
7
  export interface SiteRoute {
14
8
  urlPattern: string;