@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/dist/cli.js +205 -202
- package/dist/compiler.js +96 -93
- package/package.json +9 -9
- package/src/site/base-path.ts +175 -0
- package/src/site/headers-emitter.ts +20 -0
- package/src/site/i18n.ts +3 -2
- package/src/site/service-worker.ts +22 -5
- package/src/site/site-build.ts +50 -12
- package/src/site/well-known.ts +14 -3
- package/src/targets/compile-server.ts +14 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/compiler",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
50
|
-
"@jxsuite/runtime": "^3.
|
|
51
|
-
"@jxsuite/schema": "^2.
|
|
52
|
-
"@jxsuite/site": "^1.
|
|
53
|
-
"@vue/reactivity": "3.5.
|
|
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.
|
|
60
|
-
"@jxsuite/connector": "^0.5.
|
|
61
|
-
"@jxsuite/parser": "^1.
|
|
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
|
+
* `"` 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 = /^("|"|'|')([\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:
|
|
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:
|
|
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(
|
|
150
|
-
const CACHE_FIRST = ${JSON.stringify(
|
|
151
|
-
const OFFLINE_FALLBACK = ${JSON.stringify(
|
|
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('
|
|
308
|
+
`navigator.serviceWorker.register('${worker}',{scope:${JSON.stringify(withBase(base, scope))}})` +
|
|
292
309
|
`.catch(function(){})})}`
|
|
293
310
|
);
|
|
294
311
|
}
|
package/src/site/site-build.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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:
|
|
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()], {
|
|
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 =
|
|
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:
|
|
1584
|
+
basePath: mountBase,
|
|
1551
1585
|
className: (entry.classDef.title as string | undefined) ?? entry.name,
|
|
1552
1586
|
module,
|
|
1553
|
-
options: { basePath:
|
|
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
|
-
|
|
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: ${
|
|
2604
|
+
content += `\nSitemap: ${siteAbsoluteUrl("/sitemap.xml", siteUrl)}\n`;
|
|
2567
2605
|
writeFileSync(robotsPath, content, "utf8");
|
|
2568
2606
|
return existed ? 0 : 1;
|
|
2569
2607
|
}
|
package/src/site/well-known.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
}
|