@jxsuite/compiler 2.0.7 → 3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "2.0.7",
3
+ "version": "3.0.0",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,22 +42,23 @@
42
42
  "build:site": "bun src/cli.ts build",
43
43
  "test": "bun test --isolate",
44
44
  "test:coverage": "bun test --isolate --coverage",
45
- "upgrade": "bunx npm-check-updates -u && bun install"
45
+ "upgrade": "bun update --latest && bun install"
46
46
  },
47
47
  "dependencies": {
48
- "@apidevtools/json-schema-ref-parser": "^16.0.0",
49
- "@jxsuite/create": "^1.3.6",
50
- "@jxsuite/runtime": "^2.0.5",
51
- "@jxsuite/schema": "^1.8.1",
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",
52
53
  "@vue/reactivity": "3.5.41",
53
54
  "esbuild": "^0.28.2",
54
55
  "lit-html": "^3.3.3",
55
- "sharp": "^0.35.3"
56
+ "sharp": "^0.35.4"
56
57
  },
57
58
  "devDependencies": {
58
- "@happy-dom/global-registrator": "^20.11.6",
59
- "@jxsuite/connector": "^0.5.4",
60
- "@jxsuite/parser": "^1.5.5",
59
+ "@happy-dom/global-registrator": "^20.11.8",
60
+ "@jxsuite/connector": "^0.5.6",
61
+ "@jxsuite/parser": "^1.6.0",
61
62
  "@types/mdast": "^4.0.4"
62
63
  }
63
64
  }
package/src/shared.ts CHANGED
@@ -10,8 +10,11 @@ import {
10
10
  COLOR_SCHEME_ATTR,
11
11
  COLOR_SCHEME_STORAGE_KEY,
12
12
  RESERVED_KEYS,
13
+ booleanAttrValue,
13
14
  camelToKebab,
15
+ isSingleExpression,
14
16
  pureSchemeOf,
17
+ resolveAtQuery,
15
18
  schemeSelectors,
16
19
  toCSSText,
17
20
  } from "@jxsuite/runtime";
@@ -54,7 +57,9 @@ export {
54
57
  COLOR_SCHEME_ATTR,
55
58
  COLOR_SCHEME_STORAGE_KEY,
56
59
  RESERVED_KEYS,
60
+ booleanAttrValue,
57
61
  camelToKebab,
62
+ isSingleExpression,
58
63
  pureSchemeOf,
59
64
  schemeSelectors,
60
65
  toCSSText,
@@ -106,11 +111,26 @@ export function emitFormulaFn(def: { parameters?: unknown[] }, compiledBody: str
106
111
  * @returns {string}
107
112
  */
108
113
  export function srcImportBinding(key: string, def: unknown) {
109
- const declared = (def as { $export?: unknown } | null | undefined)?.$export;
110
- const exportName = typeof declared === "string" && declared !== "" ? declared : key;
114
+ const exportName = srcExportName(key, def);
111
115
  return exportName === key ? key : `${exportName} as ${key}`;
112
116
  }
113
117
 
118
+ /**
119
+ * The export a Function-def `$src` entry names inside its module: `$export`, or the state key.
120
+ *
121
+ * Separate from {@link srcImportBinding} because a lazily loaded `$src` has no import clause to
122
+ * alias in — it reaches the export off the module namespace object a dynamic `import()` resolves
123
+ * to, where the local name is chosen by the generated code and only the export name is fixed.
124
+ *
125
+ * @param {string} key - State key
126
+ * @param {unknown} def - The Function definition
127
+ * @returns {string}
128
+ */
129
+ export function srcExportName(key: string, def: unknown): string {
130
+ const declared = (def as { $export?: unknown } | null | undefined)?.$export;
131
+ return typeof declared === "string" && declared !== "" ? declared : key;
132
+ }
133
+
114
134
  /**
115
135
  * Emit the auto-fetch `effect()` for a `$prototype: "Request"` state entry.
116
136
  *
@@ -799,8 +819,7 @@ export function evaluateStaticTemplate(str: string, scope: Record<string, unknow
799
819
  const args = ["state", "$map", "$site", "$page"] as const;
800
820
  const values = [scope, scope?.$map, scope?.$site, scope?.$page];
801
821
  try {
802
- const singleExprMatch = str.match(/^\$\{(.+)\}$/s);
803
- const body = singleExprMatch ? `return (${singleExprMatch[1]})` : `return \`${str}\``;
822
+ const body = isSingleExpression(str) ? `return (${str.slice(2, -1)})` : `return \`${str}\``;
804
823
  const fn = new Function(...args, body) as (...a: unknown[]) => unknown;
805
824
  return fn(...values);
806
825
  } catch {
@@ -896,10 +915,34 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
896
915
  if (def.attributes) {
897
916
  for (const [k, v] of Object.entries(def.attributes)) {
898
917
  const value = resolveStaticValue(v, scope);
918
+ /*
919
+ * A boolean is the attribute's PRESENCE or its text, depending on which family HTML puts the
920
+ * attribute in — `booleanAttrValue` owns that judgement, and the runtime asks it the same
921
+ * question so a prerendered page cannot change meaning as it hydrates.
922
+ *
923
+ * @docs framework/concepts/elements
924
+ *
925
+ * Stringifying a boolean emitted `open="false"`, and HTML reads a boolean attribute by
926
+ * Presence alone — so the author wrote "closed" and the page rendered open. Nor could the
927
+ * Template return nothing instead: `resolveDocTemplates` and `expandMapTemplate` both `?? v`,
928
+ * Which restores the raw `${…}` source on a null. Absence therefore has to be expressible
929
+ * HERE, and `false` is the only value that can mean it.
930
+ *
931
+ * A *string* is never reinterpreted, in either family. `"aria-current": "false"` is the docs
932
+ * Sidebar's own not-the-current-page marker, and second-guessing it would be this same defect
933
+ * Pointed the other way.
934
+ */
935
+ if (typeof value === "boolean") {
936
+ const text = booleanAttrValue(k, value);
937
+ if (text !== null) {
938
+ out += text === "" ? ` ${k}` : ` ${k}="${escapeHtml(text)}"`;
939
+ }
940
+ continue;
941
+ }
899
942
  if (
900
943
  value !== null &&
901
944
  value !== undefined &&
902
- (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
945
+ (typeof value === "string" || typeof value === "number")
903
946
  ) {
904
947
  out += ` ${k}="${escapeHtml(String(value))}"`;
905
948
  }
@@ -1013,11 +1056,7 @@ function conditionalRuleEmitter(
1013
1056
  mediaQueries: Record<string, string>,
1014
1057
  rules: string[],
1015
1058
  ): (selector: string, props: string) => void {
1016
- const query = atKey.startsWith("@--")
1017
- ? (mediaQueries[atKey.slice(1)] ?? atKey.slice(1))
1018
- : atKey.startsWith("@(")
1019
- ? atKey.slice(1)
1020
- : null;
1059
+ const query = resolveAtQuery(atKey, mediaQueries);
1021
1060
  const atRule = query === null ? atKey : `@media ${query}`;
1022
1061
  const scheme = query === null ? null : pureSchemeOf(query);
1023
1062
  return (selector: string, props: string) => {
@@ -1206,7 +1245,20 @@ export function compileStyles(
1206
1245
  if (rules.length === 0) {
1207
1246
  return "";
1208
1247
  }
1209
- return `<style>\n${rules.join("\n")}\n</style>`;
1248
+ return `<style>\n${escapeStyleText(rules.join("\n"))}\n</style>`;
1249
+ }
1250
+
1251
+ /**
1252
+ * Make CSS safe to sit inside a `<style>` element.
1253
+ *
1254
+ * An HTML parser ends a `<style>` at the first `</style` in the text, wherever it appears — inside
1255
+ * a CSS string, inside a comment, anywhere. Since a project's style values are author data
1256
+ * (`content: "</style>"` is a legal declaration), inlining them unescaped would let content close
1257
+ * the element and the rest of the stylesheet would render as markup. `\/` is a valid CSS escape for
1258
+ * `/`, so the declaration keeps its meaning.
1259
+ */
1260
+ export function escapeStyleText(css: string): string {
1261
+ return css.replaceAll(/<\/(?=style)/gi, String.raw`<\/`);
1210
1262
  }
1211
1263
 
1212
1264
  /**
@@ -1513,6 +1565,40 @@ export function resolveStaticTagName(
1513
1565
  return expression.cases[String(key)] ?? expression.default;
1514
1566
  }
1515
1567
 
1568
+ /**
1569
+ * The content of one node: `textContent`, else `innerHTML`, else rendered `children`.
1570
+ *
1571
+ * Shared with `preRenderComponentHtml`, which returns a component's innerHTML and so cannot call
1572
+ * `renderStaticNode` (that would wrap the result in the host tag). It used to look at `children`
1573
+ * alone and return "" for anything else, so a component whose content is root-level `textContent` —
1574
+ * the shape spec.md §17.2 recommends when every child is a bare string — prerendered to nothing.
1575
+ *
1576
+ * @param {JxElement | JxMutableNode} node
1577
+ * @param {Record<string, unknown> | null} scope
1578
+ * @param {string | null} slotContent
1579
+ * @returns {string}
1580
+ */
1581
+ function renderInner(
1582
+ node: JxElement | JxMutableNode,
1583
+ scope: Record<string, unknown> | null,
1584
+ slotContent: string | null,
1585
+ ): string {
1586
+ if (node.textContent !== undefined) {
1587
+ const val = resolveStaticValue(node.textContent, scope);
1588
+ return val != null ? escapeHtml(String(val)) : "";
1589
+ }
1590
+ if (node.innerHTML) {
1591
+ const val = resolveStaticValue(node.innerHTML, scope);
1592
+ return val != null ? String(val) : (node.innerHTML as string);
1593
+ }
1594
+ if (Array.isArray(node.children)) {
1595
+ return node.children
1596
+ .map((c: JxElement | JxMutableNode | string) => renderStaticNode(c, scope, slotContent))
1597
+ .join("\n");
1598
+ }
1599
+ return "";
1600
+ }
1601
+
1516
1602
  /**
1517
1603
  * Recursively render a Jx node tree to static HTML for pre-rendering.
1518
1604
  *
@@ -1587,20 +1673,7 @@ export function renderStaticNode(
1587
1673
  return `<${tag}${attrs}>`;
1588
1674
  }
1589
1675
 
1590
- let inner = "";
1591
- if (node.textContent !== undefined) {
1592
- const val = resolveStaticValue(node.textContent, scope);
1593
- inner = val != null ? escapeHtml(String(val)) : "";
1594
- } else if (node.innerHTML) {
1595
- const val = resolveStaticValue(node.innerHTML, scope);
1596
- inner = val != null ? String(val) : (node.innerHTML as string);
1597
- } else if (Array.isArray(node.children)) {
1598
- inner = node.children
1599
- .map((c: JxElement | JxMutableNode | string) => renderStaticNode(c, scope, slotContent))
1600
- .join("\n");
1601
- }
1602
-
1603
- return `<${tag}${attrs}>${inner}</${tag}>`;
1676
+ return `<${tag}${attrs}>${renderInner(node, scope, slotContent)}</${tag}>`;
1604
1677
  }
1605
1678
 
1606
1679
  /**
@@ -1636,12 +1709,7 @@ export function preRenderComponentHtml(
1636
1709
  }
1637
1710
  }
1638
1711
  const scope = buildInitialScope(stateDefs, null);
1639
- if (!Array.isArray(doc.children)) {
1640
- return "";
1641
- }
1642
- return doc.children
1643
- .map((c: JxElement | JxMutableNode | string) => renderStaticNode(c, scope, slotContent))
1644
- .join("\n");
1712
+ return renderInner(doc, scope, slotContent);
1645
1713
  }
1646
1714
 
1647
1715
  /**
@@ -92,6 +92,14 @@ export interface BundleOptions {
92
92
  conditions?: string[];
93
93
  /** Resolution hooks, applied before the bundler's own resolution. */
94
94
  resolver?: BundleResolver;
95
+ /**
96
+ * Whether to minify the bundle.
97
+ *
98
+ * Defaults to true for `target: "browser"` and false everywhere else: browser output is parsed on
99
+ * someone else's phone, server output is read off a stack trace. Callers override it — the site
100
+ * build from `build.minify`, and `jx dev` to keep a breakpoint on a line somebody wrote.
101
+ */
102
+ minify?: boolean;
95
103
  /**
96
104
  * Called with the absolute path of every file the bundler loads.
97
105
  *
@@ -161,6 +169,21 @@ export function workerBundleOptions(adapter: string): BundleOptions {
161
169
  */
162
170
  const BROWSER_DEFINE = { "process.env.NODE_ENV": '"production"' } as const;
163
171
 
172
+ /**
173
+ * Whether this bundle is minified.
174
+ *
175
+ * Browser output defaults to minified and everything else to readable. Measured on jxsuite.com's
176
+ * own deployed bytes, the six scripts a page loads go from 155,254 B to 64,994 B — and it is the 90
177
+ * kB of source a phone no longer has to parse, not the 16 kB it no longer downloads, that moves
178
+ * Total Blocking Time.
179
+ *
180
+ * This widens the backend divergence {@link BROWSER_DEFINE} warns about: the two minifiers agree on
181
+ * semantics, not on bytes. A build that diffs `dist/` must pin one backend (`JX_BUNDLER=esbuild`).
182
+ */
183
+ function shouldMinify(opts: BundleOptions): boolean {
184
+ return opts.minify ?? opts.target === "browser";
185
+ }
186
+
164
187
  /** Map a bundle target to esbuild's platform (conditions carry the workerd specifics). */
165
188
  function esbuildPlatform(target: BundleTargetName): "browser" | "node" {
166
189
  return target === "node" || target === "bun" ? "node" : "browser";
@@ -193,7 +216,7 @@ export async function bundleEntry(request: BundleRequest, opts: BundleOptions):
193
216
  external,
194
217
  format: "esm",
195
218
  logLevel: "silent",
196
- minify: false,
219
+ minify: shouldMinify(opts),
197
220
  outfile: request.outfile,
198
221
  platform: esbuildPlatform(opts.target),
199
222
  plugins: plugins as unknown as NonNullable<Parameters<typeof esbuild.build>[0]["plugins"]>,
@@ -207,7 +230,7 @@ export async function bundleEntry(request: BundleRequest, opts: BundleOptions):
207
230
  entrypoints: [request.entryPath],
208
231
  external,
209
232
  format: "esm",
210
- minify: false,
233
+ minify: shouldMinify(opts),
211
234
  plugins,
212
235
  target: bunTarget(opts.target),
213
236
  throw: false,
@@ -254,7 +277,8 @@ export async function resolveThroughBundler(
254
277
  await bundleSource(
255
278
  `export * from ${JSON.stringify(specifier)};\n`,
256
279
  { outfile, resolveDir },
257
- { ...opts, onFileLoaded: (path) => loaded.push(path) },
280
+ // The output is deleted in `finally` — this only wants the resolution, so never pay to minify.
281
+ { ...opts, minify: false, onFileLoaded: (path) => loaded.push(path) },
258
282
  );
259
283
  } catch {
260
284
  return undefined;
@@ -286,7 +310,7 @@ export async function bundleSource(
286
310
  external: opts.external ?? [],
287
311
  format: "esm",
288
312
  logLevel: "silent",
289
- minify: false,
313
+ minify: shouldMinify(opts),
290
314
  outfile,
291
315
  platform: esbuildPlatform(opts.target),
292
316
  stdin: { contents: source, loader: "js", resolveDir, sourcefile: "jx-entry.js" },
@@ -15,7 +15,7 @@
15
15
  * @docs framework/build
16
16
  */
17
17
 
18
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
18
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
19
19
  import { createRequire } from "node:module";
20
20
  import { dirname, join, relative, resolve } from "node:path";
21
21
  import type { BundleResolver } from "./bundler.ts";
@@ -84,7 +84,15 @@ export interface ClientRuntime {
84
84
  * @param {string} [resolveDir] - Directory bare specifiers resolve from; defaults to this package
85
85
  * @returns {ClientRuntime}
86
86
  */
87
- export function resolveClientRuntime(resolveDir: string = import.meta.dir): ClientRuntime {
87
+ /**
88
+ * This package's own directory, resolved under both runtimes. `import.meta.dir` is Bun-only and is
89
+ * `undefined` under Node — and `bin/jx.js` carries a Node shebang, so every `bunx jx build` took
90
+ * that path, `join(undefined, …)` threw, the bare catch read it as "cannot resolve", and the import
91
+ * map fell back to the esm.sh CDN. `import.meta.dirname` is defined on both.
92
+ */
93
+ const PACKAGE_DIR = import.meta.dirname;
94
+
95
+ export function resolveClientRuntime(resolveDir: string = PACKAGE_DIR): ClientRuntime {
88
96
  const imports: Record<string, string> = {};
89
97
  const warnings: string[] = [];
90
98
  const assetPaths: string[] = [];
@@ -122,7 +130,8 @@ export function resolveClientRuntime(resolveDir: string = import.meta.dir): Clie
122
130
  export async function writeClientRuntime(
123
131
  runtime: ClientRuntime,
124
132
  outDir: string,
125
- resolveDir: string = import.meta.dir,
133
+ resolveDir: string = PACKAGE_DIR,
134
+ minify = true,
126
135
  ): Promise<{ written: number; errors: string[] }> {
127
136
  const errors: string[] = [];
128
137
  let written = 0;
@@ -142,7 +151,7 @@ export async function writeClientRuntime(
142
151
  await bundleSource(
143
152
  `export * from ${JSON.stringify(mod.specifier)};\n`,
144
153
  { outfile, resolveDir },
145
- { target: "browser" },
154
+ { minify, target: "browser" },
146
155
  );
147
156
  written += 1;
148
157
  } catch (error) {
@@ -152,6 +161,52 @@ export async function writeClientRuntime(
152
161
  return { errors, written };
153
162
  }
154
163
 
164
+ /**
165
+ * The asset paths an import map's `imports` object asks THIS build to write.
166
+ *
167
+ * Two kinds of entry are named by a map and produced by nobody: a prefix key's value is the
168
+ * DIRECTORY its subpaths are served from — `/assets/lit-html/` — rather than a file, and a CDN
169
+ * fallback belongs to a third party. What is left is the exact set `writeClientRuntime` matches
170
+ * against, so the promise the HTML makes and the bundles the build writes are read off one rule.
171
+ */
172
+ export function importMapAssets(imports: Record<string, string>): string[] {
173
+ return Object.entries(imports)
174
+ .filter(([specifier, url]) => !isPrefixSpecifier(specifier) && url.startsWith("/"))
175
+ .map(([, url]) => url);
176
+ }
177
+
178
+ /**
179
+ * The same set, read back out of a finished page.
180
+ *
181
+ * A page reaches an import map by more than one route: `injectComponentScripts` adds one, and the
182
+ * page-template tiers in `compile-static`/`compile-client`/`compile-element` emit their own. Only
183
+ * the first of those was recording what it named, so a project with interactivity but no components
184
+ * shipped a map pointing at `/assets/vue-reactivity.js` while the set that gates bundling stayed
185
+ * empty — nothing was written, the page 404'd on both modules and rendered blank, and the build
186
+ * reported success (issue #227). Scanning the emitted HTML is what makes the two agree no matter
187
+ * which path produced the map.
188
+ */
189
+ export function importMapAssetsInHtml(html: string): string[] {
190
+ const found: string[] = [];
191
+ for (const match of html.matchAll(/<script type="importmap">([\s\S]*?)<\/script>/g)) {
192
+ let imports: unknown;
193
+ try {
194
+ ({ imports } = JSON.parse(match[1] as string) as { imports?: unknown });
195
+ } catch {
196
+ // A map the build did not write and cannot parse names nothing this build must produce.
197
+ continue;
198
+ }
199
+ if (typeof imports !== "object" || imports === null) {
200
+ continue;
201
+ }
202
+ const entries = Object.entries(imports as Record<string, unknown>).filter(
203
+ (entry): entry is [string, string] => typeof entry[1] === "string",
204
+ );
205
+ found.push(...importMapAssets(Object.fromEntries(entries)));
206
+ }
207
+ return found;
208
+ }
209
+
155
210
  /** Render the `<script type="importmap">` block for a resolved runtime. */
156
211
  export function renderImportMap(imports: Record<string, string>): string {
157
212
  const entries = Object.entries(imports)
@@ -208,11 +263,13 @@ const MAX_SUBPATH_PASSES = 10;
208
263
  *
209
264
  * @param {string} outDir - Build output root
210
265
  * @param {string} [resolveDir] - Directory bare specifiers resolve from
266
+ * @param {boolean} [minify] - Minify the emitted bundles (defaults to the browser-target default)
211
267
  * @returns {Promise<{ written: number; errors: string[] }>}
212
268
  */
213
269
  export async function writeRuntimeSubpaths(
214
270
  outDir: string,
215
- resolveDir: string = import.meta.dir,
271
+ resolveDir: string = PACKAGE_DIR,
272
+ minify = true,
216
273
  ): Promise<{ written: number; errors: string[] }> {
217
274
  const errors: string[] = [];
218
275
  const done = new Set<string>();
@@ -235,7 +292,7 @@ export async function writeRuntimeSubpaths(
235
292
 
236
293
  /** `from "lit-html/directives/class-map.js"` — the import forms a bundler emits. */
237
294
  const importedSpecifiers = async (file: string): Promise<string[]> => {
238
- const text = await Bun.file(file).text();
295
+ const text = readFileSync(file, "utf8");
239
296
  return [...text.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map((m) => m[1] as string);
240
297
  };
241
298
 
@@ -243,9 +300,10 @@ export async function writeRuntimeSubpaths(
243
300
  let discovered = 0;
244
301
  let files: string[] = [];
245
302
  try {
246
- files = await Array.fromAsync(
247
- new Bun.Glob("**/*.js").scan({ absolute: true, cwd: assetsDir }),
248
- );
303
+ files = readdirSync(assetsDir, { recursive: true })
304
+ .map(String)
305
+ .filter((entry) => entry.endsWith(".js"))
306
+ .map((entry) => join(assetsDir, entry));
249
307
  } catch {
250
308
  return finish(outDir, mirrors, errors, written);
251
309
  }
@@ -271,6 +329,7 @@ export async function writeRuntimeSubpaths(
271
329
  `export * from ${JSON.stringify(specifier)};\n`,
272
330
  { outfile, resolveDir },
273
331
  {
332
+ minify,
274
333
  resolver: shareRuntimeCore(mod, outfile, await coreOf(mod), mirrors),
275
334
  target: "browser",
276
335
  },
@@ -1,40 +1,39 @@
1
1
  /**
2
- * Context-injection.js — $page and $site context injection
2
+ * The build's filesystem half of context injection.
3
3
  *
4
- * Injects project-level and page-level context variables into a page's state before compilation.
5
- * These are available as $site.* and $page.* in template expressions.
6
- *
7
- * Per site-architecture spec §10: $site.name — from project.json name $site.url — from project.json
8
- * url $site.state.* — site-wide reactive state $page.url — current page URL path $page.title — page
9
- * title $page.params — dynamic route parameters (if any) $page.locale/$page.dir — the route's
10
- * language and writing direction (§13.4) $page.alternates — this page in every language it exists
11
- * in (§13.5)
4
+ * `injectContext` itself is `@jxsuite/site/context` — a live renderer binds `${$site.name}` and
5
+ * `${$page.url}` exactly as a built page does, so the two must not be separate implementations.
6
+ * What is left here is the one thing that needs real directories: rebasing a project-level relative
7
+ * `imports` entry onto the page's own directory.
12
8
  *
13
9
  * @docs framework/site/i18n
14
10
  */
15
11
 
16
12
  import { dirname, relative, resolve } from "node:path";
17
- import type {
18
- JxDocument,
19
- JxElement,
20
- JxStateDefinition,
21
- ProjectConfig,
22
- } from "@jxsuite/schema/types";
23
- import { localeDirection, localeLabel } from "@jxsuite/schema/locale";
24
- import { localeOfRoute } from "./i18n.ts";
25
- import type { ResolvedI18n, TranslationMember } from "./i18n.ts";
26
- import type { SiteRoute } from "../types.ts";
13
+ import { injectContext as injectContextWith } from "@jxsuite/site/context";
14
+ import type { ImportRebaser, SiteRoute, TranslationMember } from "@jxsuite/site/context";
15
+ import type { ResolvedI18n } from "@jxsuite/schema/locale";
16
+ import type { JxDocument, ProjectConfig } from "@jxsuite/schema/types";
17
+
18
+ /** An {@link ImportRebaser} over a project on disk. */
19
+ export function nodeImportRebaser(projectRoot: string): ImportRebaser {
20
+ return (src, route) => {
21
+ if (!route.sourcePath) {
22
+ return src;
23
+ }
24
+ return `./${relative(dirname(route.sourcePath), resolve(projectRoot, src))}`;
25
+ };
26
+ }
27
27
 
28
28
  /**
29
- * Inject $site and $page context into a page document's state.
29
+ * Inject $site and $page context for a page compiled from a project on disk.
30
30
  *
31
31
  * @param {JxDocument} doc - The page document (mutated)
32
32
  * @param {ProjectConfig} projectConfig - Loaded project configuration
33
33
  * @param {SiteRoute} route - The resolved route for this page
34
34
  * @param {string | null} [projectRoot] - Absolute path to the project root (for import rebasing)
35
35
  * @param {ResolvedI18n | null} [i18n] - Validated locale config, when the project declares one
36
- * @param {readonly TranslationMember[]} [translations] - This route's translation set, itself
37
- * included
36
+ * @param {readonly TranslationMember[]} [translations] - This route's translation set
38
37
  * @returns {JxDocument} The mutated document
39
38
  */
40
39
  export function injectContext(
@@ -44,110 +43,13 @@ export function injectContext(
44
43
  projectRoot: string | null = null,
45
44
  i18n: ResolvedI18n | null = null,
46
45
  translations: readonly TranslationMember[] = [],
47
- ) {
48
- if (!doc.state) {
49
- doc.state = {};
50
- }
51
-
52
- // $site context — read-only project-level data
53
- doc.state.$site = {
54
- name: projectConfig.name ?? "Jx Site",
55
- url: projectConfig.url ?? "",
56
- ...(i18n === null ? {} : { defaultLocale: i18n.defaultLocale, locales: i18n.locales }),
57
- ...projectConfig.state,
58
- };
59
-
60
- /*
61
- * $page context — read-only page-level data.
62
- *
63
- * The locale lives here rather than as a top-level `$locale` because that is what it is: a
64
- * property of the route, not a third ambient namespace beside $site and $page. A document's own
65
- * `$lang` still wins over the route's locale, so this is the resolved answer, not the prefix.
66
- */
67
- const pageLang =
68
- (typeof doc.$lang === "string" ? doc.$lang : undefined) ??
69
- localeOfRoute(route.urlPattern, i18n) ??
70
- projectConfig.defaults?.lang;
71
- /*
72
- * The same translation set `<head>` advertises, in the shape a template can render: a language
73
- * switcher is the one part of a multilingual site the framework cannot write for the author, and
74
- * without this it could only be hand-maintained — a hardcoded list of URLs that goes stale the
75
- * moment a page gains or loses a translation, silently, in the one place a reader would use it.
76
- *
77
- * Site-absolute URLs, not the absolute hrefs `<head>` needs: a switcher is an internal link, and
78
- * it has to work before `url` is configured. `current` marks the member this route *is*, taken
79
- * from the route rather than from `$page.locale`, because a document whose `$lang` disagrees with
80
- * its directory is still served from that directory.
81
- */
82
- const alternates = translations.map((member) => ({
83
- code: member.locale,
84
- current: member.urlPattern === route.urlPattern,
85
- dir: localeDirection(member.locale),
86
- label: localeLabel(member.locale),
87
- url: member.urlPattern,
88
- }));
89
- doc.state.$page = {
90
- params: route._pathParams ?? {},
91
- title: doc.title ?? doc._pageTitle ?? projectConfig.name ?? "",
92
- url: route.urlPattern,
93
- ...(alternates.length > 0 && { alternates }),
94
- ...(pageLang === undefined ? {} : { dir: localeDirection(pageLang), locale: pageLang }),
95
- };
96
-
97
- // Merge project-level state into page state (page wins on conflicts)
98
- if (projectConfig.state) {
99
- for (const [key, value] of Object.entries(projectConfig.state)) {
100
- if (key !== "$site" && key !== "$page" && !(key in doc.state)) {
101
- doc.state[key] = value as JxStateDefinition;
102
- }
103
- }
104
- }
105
-
106
- // Merge project-level $media into page $media
107
- if (projectConfig.$media) {
108
- doc.$media = { ...projectConfig.$media, ...doc.$media };
109
- }
110
-
111
- // Merge project-level imports into page imports (page wins on collision)
112
- if (projectConfig.imports && Object.keys(projectConfig.imports).length > 0) {
113
- if (!doc.imports) {
114
- doc.imports = {};
115
- }
116
- for (const [name, srcPath] of Object.entries(projectConfig.imports)) {
117
- if (!(name in doc.imports)) {
118
- const src = srcPath as string;
119
- // Only rebase relative paths — bare/npm specifiers pass through unmodified
120
- if (projectRoot && route.sourcePath && (src.startsWith("./") || src.startsWith("../"))) {
121
- const abs = resolve(projectRoot, src);
122
- doc.imports[name] = `./${relative(dirname(route.sourcePath), abs)}`;
123
- } else {
124
- doc.imports[name] = src;
125
- }
126
- }
127
- }
128
- }
129
-
130
- // Merge project-level $elements into page $elements (union, dedup)
131
- if (projectConfig.$elements?.length) {
132
- if (!doc.$elements?.length) {
133
- doc.$elements = [...projectConfig.$elements];
134
- } else {
135
- const seen = new Set<string>();
136
- const merged: (string | JxElement)[] = [];
137
- for (const entry of [
138
- ...projectConfig.$elements,
139
- ...(doc.$elements as (string | JxElement)[]),
140
- ]) {
141
- const key =
142
- typeof entry === "string" ? entry : /** @type {{ $ref?: string }} */ entry?.$ref;
143
- if (key && !seen.has(key)) {
144
- seen.add(key);
145
- merged.push(entry);
146
- }
147
- }
148
- doc.$elements = merged;
149
- }
150
- }
151
-
152
- return doc;
46
+ ): JxDocument {
47
+ return injectContextWith(
48
+ doc,
49
+ projectConfig,
50
+ route,
51
+ projectRoot === null ? null : nodeImportRebaser(projectRoot),
52
+ i18n,
53
+ translations,
54
+ );
153
55
  }