@jxsuite/compiler 3.0.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -46,19 +46,19 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@apidevtools/json-schema-ref-parser": "^16.0.1",
49
- "@jxsuite/create": "^1.3.8",
50
- "@jxsuite/runtime": "^3.0.0",
51
- "@jxsuite/schema": "^2.0.0",
52
- "@jxsuite/site": "^1.0.0",
53
- "@vue/reactivity": "3.5.41",
49
+ "@jxsuite/create": "^1.3.9",
50
+ "@jxsuite/runtime": "^3.1.0",
51
+ "@jxsuite/schema": "^2.1.0",
52
+ "@jxsuite/site": "^1.1.0",
53
+ "@vue/reactivity": "3.5.42",
54
54
  "esbuild": "^0.28.2",
55
55
  "lit-html": "^3.3.3",
56
56
  "sharp": "^0.35.4"
57
57
  },
58
58
  "devDependencies": {
59
- "@happy-dom/global-registrator": "^20.11.8",
60
- "@jxsuite/connector": "^0.5.6",
61
- "@jxsuite/parser": "^1.6.0",
59
+ "@happy-dom/global-registrator": "^20.12.0",
60
+ "@jxsuite/connector": "^0.5.7",
61
+ "@jxsuite/parser": "^1.7.0",
62
62
  "@types/mdast": "^4.0.4"
63
63
  }
64
64
  }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Base-path.ts — re-rooting a build's output onto the path the site is deployed under.
3
+ *
4
+ * A site at `https://example.pages.dev/m/my-site/` is served from `/m/my-site/`, and every
5
+ * absolute-path reference the build emits — `/assets/vue-reactivity.js`,
6
+ * `/components/site-counter.js`, `/images/hero.jpg` — resolves against `example.pages.dev/`
7
+ * instead, where nothing is. The page renders blank rather than merely wrong, because the runtime
8
+ * assets 404 first (issue 235).
9
+ *
10
+ * **The base is not a new option.** `project.json`'s `url` is the base URI every emitted reference
11
+ * is resolved against (RFC 3986 §5), and the compiler was resolving against its ORIGIN and
12
+ * discarding its path. `siteBasePath` in `@jxsuite/schema/asset-paths` reads the path back; a
13
+ * second `build.base` key would be a second writer on the one fact the canonical links and the
14
+ * sitemap are already built from, free to disagree with them.
15
+ *
16
+ * **The rewrite happens on finished HTML, and that is the point.** The alternative is to thread a
17
+ * prefix through the twelve emitters that can put a URL on a page — the import map and its
18
+ * modulepreloads, three page-template tiers that each write their own map, component scripts,
19
+ * island modules, sidecar and npm bundles, image `srcset`, `$head` entries, and whatever a page
20
+ * author wrote by hand. That list is exactly the kind that is complete until the next emitter is
21
+ * added, and #227 was already one emitter's map disagreeing with another's. One pass over the bytes
22
+ * that ship cannot drift from them.
23
+ *
24
+ * Outputs that are not HTML keep their own handling, because each has a syntax and none of them can
25
+ * be found by looking at a tag: `_headers`, `_redirects`, the service worker's precache list and
26
+ * scope, `manifest.webmanifest`, and the absolute URLs in `sitemap.xml` and `robots.txt`.
27
+ *
28
+ * Everything here is idempotent (see `withBase`), so a value an emitter already prefixed survives
29
+ * this pass unchanged.
30
+ *
31
+ * @docs framework/site/deployment
32
+ */
33
+
34
+ import { formatSrcset, parseSrcset, withBase } from "@jxsuite/schema/asset-paths";
35
+
36
+ /**
37
+ * Attributes whose value is a single URL.
38
+ *
39
+ * `href` and `src` are most of the traffic; the rest are the ones a Jx page can actually emit —
40
+ * `<form action>`, `<button formaction>`, `<video poster>`, `<object data>`. Anything not listed is
41
+ * left alone, which is the safe direction: a missed URL 404s visibly on a subpath deployment, while
42
+ * a rewritten non-URL corrupts a page that was working.
43
+ */
44
+ const URL_ATTRS = new Set(["href", "src", "poster", "action", "formaction", "data"]);
45
+
46
+ /** Attributes whose value is a `srcset` candidate list rather than one URL. */
47
+ const SRCSET_ATTRS = new Set(["srcset", "imagesrcset"]);
48
+
49
+ /**
50
+ * One HTML tag: the name, then attributes with quoted values consumed whole.
51
+ *
52
+ * Consuming quotes matters — a `>` inside an attribute value would otherwise end the tag early and
53
+ * leave the rest of its attributes as text this pass never looks at.
54
+ */
55
+ const TAG_RE = /<([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
56
+
57
+ /** One attribute inside a tag's attribute blob. */
58
+ const ATTR_RE = /([a-zA-Z_:][\w:.-]*)\s*=\s*("([^"]*)"|'([^']*)')/g;
59
+
60
+ /** `url(...)` inside a CSS value, capturing the optional quote so it can be put back. */
61
+ const CSS_URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
62
+
63
+ /** A JSON string literal holding an absolute-path reference — an import map's keys and values. */
64
+ const JSON_ABS_PATH_RE = /"(\/[^"/][^"]*)"/g;
65
+
66
+ /** `<script type="importmap">…</script>`, whose body is JSON rather than markup. */
67
+ const IMPORTMAP_RE = /(<script\b[^>]*\btype\s*=\s*["']importmap["'][^>]*>)([\s\S]*?)(<\/script>)/gi;
68
+
69
+ /** `<style>…</style>`, whose body is CSS rather than markup. */
70
+ const STYLE_BLOCK_RE = /(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi;
71
+
72
+ /**
73
+ * `<meta>` names whose `content` is a URL.
74
+ *
75
+ * `content` is the one attribute here that usually holds prose, so it is rewritten only when the
76
+ * same tag says the value is a URL. OpenGraph and Twitter image/player metas are the cases a page
77
+ * actually produces from an author-written `/hero.jpg`.
78
+ */
79
+ const URL_META_RE =
80
+ /\b(?:property|name)\s*=\s*["'](?:og:|twitter:)[\w:.-]*(?:image|url|video|audio|player)[\w:.-]*["']/i;
81
+
82
+ /**
83
+ * A quote around a `url()` target that survived attribute escaping.
84
+ *
85
+ * In a `style` ATTRIBUTE the quotes inside `url("…")` are escaped, so the target arrives wrapped in
86
+ * `&quot;` rather than in a character {@link CSS_URL_RE} would have treated as a delimiter. Left
87
+ * alone the value no longer starts with `/` and is silently skipped — a background image that 404s
88
+ * on a subpath deployment with nothing to show for it.
89
+ */
90
+ const ENTITY_QUOTE_RE = /^(&quot;|&#34;|&apos;|&#39;)([\s\S]*)\1$/;
91
+
92
+ /** Rewrite every `url(...)` in a CSS fragment, in a `<style>` block or a `style` attribute. */
93
+ function rewriteCssUrls(css: string, base: string): string {
94
+ return css.replaceAll(CSS_URL_RE, (match, quote: string, inner: string) => {
95
+ const raw = inner.trim();
96
+ const escaped = ENTITY_QUOTE_RE.exec(raw);
97
+ const target = escaped ? escaped[2]! : raw;
98
+ const out = withBase(base, target);
99
+ if (out === target) {
100
+ return match;
101
+ }
102
+ return `url(${quote}${escaped ? `${escaped[1]}${out}${escaped[1]}` : out}${quote})`;
103
+ });
104
+ }
105
+
106
+ /** Rewrite one attribute value, or return it unchanged when the attribute carries no URL. */
107
+ function rewriteAttrValue(tag: string, attrs: string, name: string, value: string, base: string) {
108
+ const lower = name.toLowerCase();
109
+ if (URL_ATTRS.has(lower)) {
110
+ return withBase(base, value);
111
+ }
112
+ if (SRCSET_ATTRS.has(lower)) {
113
+ const candidates = parseSrcset(value);
114
+ return candidates.length === 0
115
+ ? value
116
+ : formatSrcset(
117
+ candidates.map((c) => ({ descriptor: c.descriptor, url: withBase(base, c.url) })),
118
+ );
119
+ }
120
+ if (lower === "style") {
121
+ return rewriteCssUrls(value, base);
122
+ }
123
+ if (lower === "content" && tag === "meta" && URL_META_RE.test(attrs)) {
124
+ return withBase(base, value);
125
+ }
126
+ return value;
127
+ }
128
+
129
+ /**
130
+ * Re-root every site-absolute URL in a page's HTML onto `base`.
131
+ *
132
+ * `base` of `""` is a site at an origin root, and the HTML is returned untouched — no allocation
133
+ * and no risk for the deployment Jx documents.
134
+ *
135
+ * @param {string} html - A finished page
136
+ * @param {string} base - From `siteBasePath`
137
+ * @returns {string} The page as it should ship
138
+ */
139
+ export function rewriteHtmlBase(html: string, base: string): string {
140
+ if (base === "") {
141
+ return html;
142
+ }
143
+
144
+ let out = html.replaceAll(IMPORTMAP_RE, (_match, open: string, body: string, close: string) => {
145
+ /* The import map's KEYS are addressable too: a map may name a module by URL rather than by bare
146
+ specifier, and a key left at the old root would resolve to a file the build did not write. */
147
+ const rewritten = body.replaceAll(
148
+ JSON_ABS_PATH_RE,
149
+ (_literal, path: string) => `"${withBase(base, path)}"`,
150
+ );
151
+ return open + rewritten + close;
152
+ });
153
+
154
+ out = out.replaceAll(
155
+ STYLE_BLOCK_RE,
156
+ (_m, open: string, css: string, close: string) => open + rewriteCssUrls(css, base) + close,
157
+ );
158
+
159
+ return out.replaceAll(TAG_RE, (match, tagName: string, attrs: string) => {
160
+ if (attrs === "") {
161
+ return match;
162
+ }
163
+ const tag = tagName.toLowerCase();
164
+ const rewrittenAttrs = attrs.replaceAll(
165
+ ATTR_RE,
166
+ (whole, name: string, _quoted: string, dq: string | undefined, sq: string | undefined) => {
167
+ const quote = dq === undefined ? "'" : '"';
168
+ const value = dq ?? sq ?? "";
169
+ const next = rewriteAttrValue(tag, attrs, name, value, base);
170
+ return next === value ? whole : `${name}=${quote}${next}${quote}`;
171
+ },
172
+ );
173
+ return rewrittenAttrs === attrs ? match : `<${tagName}${rewrittenAttrs}>`;
174
+ });
175
+ }
@@ -11,6 +11,7 @@
11
11
  * @docs framework/site/deployment
12
12
  */
13
13
 
14
+ import { withBase } from "@jxsuite/schema/asset-paths";
14
15
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
15
16
  import { join } from "node:path";
16
17
  import { buildCspHeaders, normalizeCspConfig } from "./csp.ts";
@@ -181,6 +182,25 @@ export function contentTypeRules(outDir: string): HeaderRule[] {
181
182
  return rules;
182
183
  }
183
184
 
185
+ /**
186
+ * Re-root every rule's pattern onto the deployment base.
187
+ *
188
+ * A `_headers` pattern is matched against the REQUEST path, so on a site served from `/m/my-site/`
189
+ * a rule for `/assets/*` matches nothing at all — which is silent, because a missing cache header
190
+ * is a slow site rather than a broken one. The generated patterns and the author's `build.headers`
191
+ * rules go through together: both describe the same URL space.
192
+ *
193
+ * @param {readonly HeaderRule[]} rules
194
+ * @param {string} base - From `siteBasePath`; `""` returns the rules untouched
195
+ * @returns {HeaderRule[]}
196
+ */
197
+ export function rebaseHeaderRules(rules: readonly HeaderRule[], base: string): HeaderRule[] {
198
+ if (base === "") {
199
+ return [...rules];
200
+ }
201
+ return rules.map((rule) => ({ headers: rule.headers, pattern: withBase(base, rule.pattern) }));
202
+ }
203
+
184
204
  /** Render rules in the Netlify / Cloudflare Pages `_headers` format. */
185
205
  export function renderHeaders(rules: readonly HeaderRule[]): string {
186
206
  const blocks = rules.map((rule) => {
package/src/site/i18n.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  * @docs framework/site/i18n
13
13
  */
14
14
 
15
+ import { siteAbsoluteUrl } from "@jxsuite/schema/asset-paths";
15
16
  import {
16
17
  canonicalizeLocale,
17
18
  localeDirection,
@@ -298,13 +299,13 @@ export function localeAlternates(
298
299
  let alternates = built.get(members);
299
300
  if (alternates === undefined) {
300
301
  alternates = members.map((m) => ({
301
- href: new URL(m.urlPattern, siteUrl).href,
302
+ href: siteAbsoluteUrl(m.urlPattern, siteUrl),
302
303
  hreflang: m.locale,
303
304
  }));
304
305
  const fallback = members.find((m) => m.locale === i18n.defaultLocale);
305
306
  if (fallback !== undefined) {
306
307
  alternates.push({
307
- href: new URL(fallback.urlPattern, siteUrl).href,
308
+ href: siteAbsoluteUrl(fallback.urlPattern, siteUrl),
308
309
  hreflang: "x-default",
309
310
  });
310
311
  }
@@ -20,6 +20,7 @@
20
20
  * @docs framework/site/deployment
21
21
  */
22
22
 
23
+ import { withBase } from "@jxsuite/schema/asset-paths";
23
24
  import { createHash } from "node:crypto";
24
25
  import { existsSync } from "node:fs";
25
26
  import { join } from "node:path";
@@ -100,6 +101,7 @@ function configId(config: ServiceWorkerConfig): string {
100
101
  export function buildServiceWorker(
101
102
  config: ServiceWorkerConfig,
102
103
  outDir?: string,
104
+ base = "",
103
105
  ): ServiceWorkerOutput {
104
106
  const warnings: string[] = [];
105
107
  const errors: string[] = [];
@@ -140,15 +142,26 @@ export function buildServiceWorker(
140
142
  }
141
143
  }
142
144
 
145
+ /*
146
+ * The worker runs in the browser and every URL in it is a REQUEST path, so on a site served from
147
+ * `/m/my-site/` the base belongs on all three: a precache entry without it fetches a 404, and
148
+ * `CACHE_FIRST` without it never matches, so the content-addressed images it exists to serve
149
+ * from cache go to the network on every view. `outputFileFor` above is deliberately checked
150
+ * BEFORE this — those URLs are looked up in `outDir`, which has no base.
151
+ */
152
+ const cachedPrecache = precache.map((url) => withBase(base, url));
153
+ const cacheFirst = withBase(base, CACHE_FIRST_PREFIX);
154
+ const offlineFallback = fallback === undefined ? null : withBase(base, fallback);
155
+
143
156
  const cache = `jx-${configId(config)}`;
144
157
  const source = `/*
145
158
  * Generated by @jxsuite/compiler — configure via \`serviceWorker\` in project.json.
146
159
  * Set \`"serviceWorker": false\` to replace this with a worker that removes itself.
147
160
  */
148
161
  const CACHE = ${JSON.stringify(cache)};
149
- const PRECACHE = ${JSON.stringify(precache)};
150
- const CACHE_FIRST = ${JSON.stringify(CACHE_FIRST_PREFIX)};
151
- const OFFLINE_FALLBACK = ${JSON.stringify(fallback ?? null)};
162
+ const PRECACHE = ${JSON.stringify(cachedPrecache)};
163
+ const CACHE_FIRST = ${JSON.stringify(cacheFirst)};
164
+ const OFFLINE_FALLBACK = ${JSON.stringify(offlineFallback)};
152
165
 
153
166
  self.addEventListener("install", (event) => {
154
167
  // Take over as soon as possible: a half-updated pair of workers is harder to reason about than
@@ -285,10 +298,14 @@ self.addEventListener("activate", (event) => {
285
298
  * @param {string} scope
286
299
  * @returns {string}
287
300
  */
288
- export function registrationScript(scope: string): string {
301
+ export function registrationScript(scope: string, base = ""): string {
302
+ /* Both are request paths. A worker's scope may not exceed its own directory, so a worker served
303
+ from `/m/my-site/sw.js` with a scope of `/` fails to register outright — the one failure here
304
+ that is loud rather than silent. */
305
+ const worker = withBase(base, `/${SERVICE_WORKER_PATH}`);
289
306
  return (
290
307
  `if('serviceWorker' in navigator){addEventListener('load',function(){` +
291
- `navigator.serviceWorker.register('/${SERVICE_WORKER_PATH}',{scope:${JSON.stringify(scope)}})` +
308
+ `navigator.serviceWorker.register('${worker}',{scope:${JSON.stringify(withBase(base, scope))}})` +
292
309
  `.catch(function(){})})}`
293
310
  );
294
311
  }
@@ -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";
@@ -519,6 +528,12 @@ export async function buildSite(
519
528
  // Sitemap is generated from the route table when a production `url` is configured
520
529
  // (absolute <loc> URLs require it) and not explicitly disabled via build.sitemap: false.
521
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);
522
537
  const sitemapEnabled = Boolean(siteUrl) && projectConfig.build.sitemap !== false;
523
538
  const sitemapEntries: SitemapEntry[] = [];
524
539
 
@@ -714,7 +729,9 @@ export async function buildSite(
714
729
  // Determine output path
715
730
  const outPath = routeToOutputPath(route.urlPattern, outDir, trailingSlash);
716
731
  mkdirSync(dirname(outPath), { recursive: true });
717
- 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");
718
735
  fileCount += 1;
719
736
 
720
737
  // Record a sitemap entry for this concrete page (skip unexpanded dynamic routes and
@@ -735,7 +752,7 @@ export async function buildSite(
735
752
  typeof route.sourceMtime === "string" && route.sourceMtime !== ""
736
753
  ? route.sourceMtime
737
754
  : toRfc3339(statSync(route.sourcePath).mtime),
738
- loc: new URL(route.urlPattern, siteUrl).href,
755
+ loc: siteAbsoluteUrl(route.urlPattern, siteUrl),
739
756
  ...(routeAlternates.length > 0 && { alternates: routeAlternates }),
740
757
  });
741
758
  }
@@ -835,7 +852,13 @@ export async function buildSite(
835
852
  const skipWorker = adapter === "cloudflare-pages" && deduped.size === 0 && mounts.length === 0;
836
853
  const workerSource = skipWorker
837
854
  ? null
838
- : compileSiteServer([...deduped.values()], { adapter, connectors, i18n, mounts });
855
+ : compileSiteServer([...deduped.values()], {
856
+ adapter,
857
+ base: basePath,
858
+ connectors,
859
+ i18n,
860
+ mounts,
861
+ });
839
862
 
840
863
  if (workerSource) {
841
864
  // Bundle the worker self-contained for the adapter's runtime (compiler.md §12): mount
@@ -1014,6 +1037,7 @@ export async function buildSite(
1014
1037
  log("Generating redirects...");
1015
1038
  const compiledUrls = new Set(routes.map((r) => r.urlPattern));
1016
1039
  const redirects = generateRedirects(projectConfig.redirects, outDir, {
1040
+ basePath,
1017
1041
  compiledUrls,
1018
1042
  trailingSlash: projectConfig.build.trailingSlash,
1019
1043
  });
@@ -1091,7 +1115,8 @@ export async function buildSite(
1091
1115
  {
1092
1116
  const sw = normalizeServiceWorker(projectConfig.serviceWorker);
1093
1117
  if (sw !== null) {
1094
- const output = sw === false ? tombstoneServiceWorker() : buildServiceWorker(sw, outDir);
1118
+ const output =
1119
+ sw === false ? tombstoneServiceWorker() : buildServiceWorker(sw, outDir, basePath);
1095
1120
  for (const warning of output.warnings) {
1096
1121
  console.warn(warning);
1097
1122
  }
@@ -1115,7 +1140,7 @@ export async function buildSite(
1115
1140
  );
1116
1141
  errors.push(...headerErrors);
1117
1142
  // Runs here, after every emitter, so it can see which of these files the build actually wrote.
1118
- const rules = [...securityRules, ...contentTypeRules(outDir)];
1143
+ const rules = rebaseHeaderRules([...securityRules, ...contentTypeRules(outDir)], basePath);
1119
1144
  if (rules.length > 0) {
1120
1145
  log("Writing response headers...");
1121
1146
  fileCount += writeHeaders(outDir, rules);
@@ -1215,6 +1240,10 @@ async function compilePage(
1215
1240
  /** Registers a page's npm `$elements` set as one bundle and returns its URL. */
1216
1241
  registerElementBundle: (specifiers: string[]) => string,
1217
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
+
1218
1247
  // Load the raw page document (.json natively, other formats via the registry)
1219
1248
  const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
1220
1249
 
@@ -1364,7 +1393,7 @@ async function compilePage(
1364
1393
  const swHead: JxHeadEntry[] =
1365
1394
  swConfig === null || swConfig === false
1366
1395
  ? []
1367
- : [{ tagName: "script", textContent: registrationScript(swConfig.scope ?? "/") }];
1396
+ : [{ tagName: "script", textContent: registrationScript(swConfig.scope ?? "/", basePath) }];
1368
1397
 
1369
1398
  const resolvedSiteHead = [
1370
1399
  // The manifest link and theme colour are first-party and unconditional once declared, so they
@@ -1528,6 +1557,7 @@ function buildMountSpecs(
1528
1557
  if (activeMounts.length === 0) {
1529
1558
  return { connectors: [], mounts: [] };
1530
1559
  }
1560
+ const base = siteBasePath(projectConfig.url);
1531
1561
 
1532
1562
  const sections: Record<string, unknown> = {};
1533
1563
  for (const contribution of registry.projectContributions()) {
@@ -1546,11 +1576,15 @@ function buildMountSpecs(
1546
1576
  `to generate a deployable worker`,
1547
1577
  );
1548
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);
1549
1583
  return {
1550
- basePath: server.basePath,
1584
+ basePath: mountBase,
1551
1585
  className: (entry.classDef.title as string | undefined) ?? entry.name,
1552
1586
  module,
1553
- options: { basePath: server.basePath, sections },
1587
+ options: { basePath: mountBase, sections },
1554
1588
  order: server.order ?? 100,
1555
1589
  };
1556
1590
  });
@@ -2369,7 +2403,7 @@ function generateRedirects(
2369
2403
  string | { destination: string; status?: number } | { destination: string; rewrite: true }
2370
2404
  >,
2371
2405
  outDir: string,
2372
- opts: { compiledUrls?: Set<string>; trailingSlash?: string } = {},
2406
+ opts: { compiledUrls?: Set<string>; trailingSlash?: string; basePath?: string } = {},
2373
2407
  ) {
2374
2408
  let files = 0;
2375
2409
  const errors: string[] = [];
@@ -2396,7 +2430,11 @@ function generateRedirects(
2396
2430
  continue;
2397
2431
  }
2398
2432
 
2399
- 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}`);
2400
2438
 
2401
2439
  // A pattern source cannot be a file on disk, so it lives only in `_redirects`.
2402
2440
  if (source.includes(":") || source.includes("*")) {
@@ -2563,7 +2601,7 @@ function ensureRobotsSitemap(outDir: string, siteUrl: string) {
2563
2601
  if (!content.endsWith("\n")) {
2564
2602
  content += "\n";
2565
2603
  }
2566
- content += `\nSitemap: ${new URL("/sitemap.xml", siteUrl).href}\n`;
2604
+ content += `\nSitemap: ${siteAbsoluteUrl("/sitemap.xml", siteUrl)}\n`;
2567
2605
  writeFileSync(robotsPath, content, "utf8");
2568
2606
  return existed ? 0 : 1;
2569
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 }),
@@ -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
  }