@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/README.md +8 -8
- package/dist/cli.js +193 -190
- package/dist/compiler.js +110 -111
- package/package.json +11 -10
- package/src/shared.ts +99 -31
- package/src/site/bundler.ts +28 -4
- package/src/site/client-runtime.ts +68 -9
- package/src/site/context-injection.ts +30 -128
- package/src/site/i18n.ts +10 -26
- package/src/site/image-optimizer.ts +31 -1
- package/src/site/image-transform.ts +141 -7
- package/src/site/layout-resolver.ts +30 -192
- package/src/site/pages-discovery.ts +9 -49
- package/src/site/site-build.ts +128 -42
- package/src/targets/compile-element.ts +60 -2
- package/src/types.ts +5 -11
- package/src/site/head-merger.ts +0 -251
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/compiler",
|
|
3
|
-
"version": "
|
|
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": "
|
|
45
|
+
"upgrade": "bun update --latest && bun install"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@apidevtools/json-schema-ref-parser": "^16.0.
|
|
49
|
-
"@jxsuite/create": "^1.3.
|
|
50
|
-
"@jxsuite/runtime": "^
|
|
51
|
-
"@jxsuite/schema": "^
|
|
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.
|
|
56
|
+
"sharp": "^0.35.4"
|
|
56
57
|
},
|
|
57
58
|
"devDependencies": {
|
|
58
|
-
"@happy-dom/global-registrator": "^20.11.
|
|
59
|
-
"@jxsuite/connector": "^0.5.
|
|
60
|
-
"@jxsuite/parser": "^1.
|
|
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
|
|
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
|
|
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"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/site/bundler.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
247
|
-
|
|
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
|
-
*
|
|
2
|
+
* The build's filesystem half of context injection.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
49
|
-
doc
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
}
|