@jxsuite/compiler 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -2,8 +2,10 @@
2
2
  * Dev command — `jx dev` resolves the project's @jxsuite/server dev entry and spawns it under Bun.
3
3
  *
4
4
  * The compiler cannot depend on @jxsuite/server (the server already depends on the compiler), and
5
- * the dev server is Bun-native while the jx bin runs under Node — so the command is pure resolution
6
- * + spawn: the entry module is resolved from the PROJECT's node_modules and executed with Bun.
5
+ * the dev server is Bun-native while the jx bin runs under Node — so the command is pure
6
+ * resolution
7
+ *
8
+ * - Spawn: the entry module is resolved from the PROJECT's node_modules and executed with Bun.
7
9
  *
8
10
  * @module dev-command
9
11
  */
@@ -10,7 +10,7 @@ import { readFileSync } from "node:fs";
10
10
  import { dirname, isAbsolute, resolve, sep } from "node:path";
11
11
  import { pathToFileURL } from "node:url";
12
12
  import { createRequire } from "node:module";
13
- import { buildExtensionRegistry } from "@jxsuite/schema/extension-registry";
13
+ import { buildExtensionRegistry, EXTENSION_MANIFEST } from "@jxsuite/schema/extension-registry";
14
14
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
15
15
  import type { FormatHostIO, FormatRegistry, ProjectBlock } from "@jxsuite/schema/format-registry";
16
16
  import type { ProjectConfig } from "@jxsuite/schema/types";
@@ -75,6 +75,59 @@ export function createNodeFormatIO(projectRoot: string): FormatHostIO {
75
75
  };
76
76
  }
77
77
 
78
+ /**
79
+ * Where an extension package's manifest resolves from — the two answers `createNodeFormatIO`
80
+ * merges.
81
+ */
82
+ export interface ExtensionResolution {
83
+ /** Resolvable from the PROJECT's own node_modules. */
84
+ project: boolean;
85
+ /** Resolvable from the HOST's module graph — `createNodeFormatIO`'s fallback branch. */
86
+ host: boolean;
87
+ /** The manifest path from whichever probe answered; the project wins, matching resolution order. */
88
+ manifestPath?: string;
89
+ }
90
+
91
+ /**
92
+ * Split `createNodeFormatIO`'s two-branch resolution into its two ANSWERS.
93
+ *
94
+ * The IO deliberately collapses them (project first, then host) because a caller loading a class
95
+ * only needs the file. A catalogue needs them apart: "the project installed this" and "this host
96
+ * ships it" are different affordances — install-then-enable versus enable — and a surface that
97
+ * cannot tell them apart offers the wrong one.
98
+ *
99
+ * Probes the MANIFEST rather than the package directory, because the manifest is what the registry
100
+ * resolves (see `loadExtension`): a package present in node_modules whose `exports` map omits
101
+ * `./jx-extension.json` is one whose enablement would throw, so it must not read as resolvable.
102
+ *
103
+ * @param {string} projectRoot - The project whose node_modules is tried first
104
+ * @param {string} name - Bare package specifier, e.g. "@jxsuite/parser"
105
+ * @returns {ExtensionResolution}
106
+ */
107
+ export function probeExtension(projectRoot: string, name: string): ExtensionResolution {
108
+ const ref = `${name}/${EXTENSION_MANIFEST}`;
109
+ let manifestPath: string | undefined;
110
+
111
+ let project = false;
112
+ try {
113
+ manifestPath = createRequire(resolve(projectRoot, "package.json")).resolve(ref);
114
+ project = true;
115
+ } catch {
116
+ // Not installed in the project. The host probe below decides whether it resolves at all.
117
+ }
118
+
119
+ let host = false;
120
+ try {
121
+ const fromHost = createRequire(import.meta.url).resolve(ref);
122
+ host = true;
123
+ manifestPath ??= fromHost;
124
+ } catch {
125
+ // Not shipped by this host either.
126
+ }
127
+
128
+ return { host, project, ...(manifestPath === undefined ? {} : { manifestPath }) };
129
+ }
130
+
78
131
  /** Build the extension registry for a project from its project.json `extensions` array. */
79
132
  export async function buildProjectExtensionRegistry(
80
133
  projectRoot: string,
@@ -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
  }
@@ -56,8 +56,9 @@ const RESERVED_KEYS = new Set([
56
56
  * to a .class.json path via doc.imports
57
57
  *
58
58
  * The resolver: - Reads the .class.json from disk - Follows $implementation to import the JS module
59
+ *
59
60
  * - Instantiates the class with the config - Calls .resolve() and replaces the state entry with the
60
- * result
61
+ * result
61
62
  *
62
63
  * @param {JxMutableNode | JxDocument} doc - The page document (mutated in place)
63
64
  * @param {{ sourcePath?: string; _pathParams?: Record<string, string> }} route - Route info
@@ -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
  }