@jxsuite/compiler 2.0.8 → 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 -189
- package/dist/compiler.js +110 -110
- package/package.json +11 -10
- package/src/shared.ts +60 -35
- package/src/site/bundler.ts +28 -4
- package/src/site/client-runtime.ts +51 -1
- 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 +102 -32
- package/src/targets/compile-element.ts +51 -1
- 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,7 +10,9 @@ 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,
|
|
15
17
|
resolveAtQuery,
|
|
16
18
|
schemeSelectors,
|
|
@@ -55,7 +57,9 @@ export {
|
|
|
55
57
|
COLOR_SCHEME_ATTR,
|
|
56
58
|
COLOR_SCHEME_STORAGE_KEY,
|
|
57
59
|
RESERVED_KEYS,
|
|
60
|
+
booleanAttrValue,
|
|
58
61
|
camelToKebab,
|
|
62
|
+
isSingleExpression,
|
|
59
63
|
pureSchemeOf,
|
|
60
64
|
schemeSelectors,
|
|
61
65
|
toCSSText,
|
|
@@ -107,11 +111,26 @@ export function emitFormulaFn(def: { parameters?: unknown[] }, compiledBody: str
|
|
|
107
111
|
* @returns {string}
|
|
108
112
|
*/
|
|
109
113
|
export function srcImportBinding(key: string, def: unknown) {
|
|
110
|
-
const
|
|
111
|
-
const exportName = typeof declared === "string" && declared !== "" ? declared : key;
|
|
114
|
+
const exportName = srcExportName(key, def);
|
|
112
115
|
return exportName === key ? key : `${exportName} as ${key}`;
|
|
113
116
|
}
|
|
114
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
|
+
|
|
115
134
|
/**
|
|
116
135
|
* Emit the auto-fetch `effect()` for a `$prototype: "Request"` state entry.
|
|
117
136
|
*
|
|
@@ -780,37 +799,6 @@ function readsRuntimeOnlyState(str: string, scope: Record<string, unknown>) {
|
|
|
780
799
|
return false;
|
|
781
800
|
}
|
|
782
801
|
|
|
783
|
-
/**
|
|
784
|
-
* Whether the whole string is exactly ONE `${…}` — the interpolation that opens at index 0 also
|
|
785
|
-
* closes at the final character.
|
|
786
|
-
*
|
|
787
|
-
* A greedy `/^\$\{(.+)\}$/` also matched `"${a} / ${b}"`, spliced the interior into `return (a} /
|
|
788
|
-
* ${b)`, and the SyntaxError became a silent null: the node rendered empty, and a `$head` entry
|
|
789
|
-
* shipped its own template text. A brace-depth scan is exact where a tightened regex is not —
|
|
790
|
-
* `"${`${a}-x`}"` is still one expression, and must keep returning a raw value.
|
|
791
|
-
*
|
|
792
|
-
* @param {string} str
|
|
793
|
-
* @returns {boolean}
|
|
794
|
-
*/
|
|
795
|
-
export function isSingleExpression(str: string): boolean {
|
|
796
|
-
if (!str.startsWith("${") || !str.endsWith("}")) {
|
|
797
|
-
return false;
|
|
798
|
-
}
|
|
799
|
-
let depth = 0;
|
|
800
|
-
for (let i = 1; i < str.length; i += 1) {
|
|
801
|
-
const c = str[i];
|
|
802
|
-
if (c === "{") {
|
|
803
|
-
depth += 1;
|
|
804
|
-
} else if (c === "}") {
|
|
805
|
-
depth -= 1;
|
|
806
|
-
if (depth === 0) {
|
|
807
|
-
return i === str.length - 1;
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
return false;
|
|
812
|
-
}
|
|
813
|
-
|
|
814
802
|
/**
|
|
815
803
|
* @param {string} str
|
|
816
804
|
* @param {Record<string, unknown>} scope
|
|
@@ -927,10 +915,34 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
|
|
|
927
915
|
if (def.attributes) {
|
|
928
916
|
for (const [k, v] of Object.entries(def.attributes)) {
|
|
929
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
|
+
}
|
|
930
942
|
if (
|
|
931
943
|
value !== null &&
|
|
932
944
|
value !== undefined &&
|
|
933
|
-
(typeof value === "string" || typeof value === "number"
|
|
945
|
+
(typeof value === "string" || typeof value === "number")
|
|
934
946
|
) {
|
|
935
947
|
out += ` ${k}="${escapeHtml(String(value))}"`;
|
|
936
948
|
}
|
|
@@ -1233,7 +1245,20 @@ export function compileStyles(
|
|
|
1233
1245
|
if (rules.length === 0) {
|
|
1234
1246
|
return "";
|
|
1235
1247
|
}
|
|
1236
|
-
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`<\/`);
|
|
1237
1262
|
}
|
|
1238
1263
|
|
|
1239
1264
|
/**
|
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" },
|
|
@@ -131,6 +131,7 @@ export async function writeClientRuntime(
|
|
|
131
131
|
runtime: ClientRuntime,
|
|
132
132
|
outDir: string,
|
|
133
133
|
resolveDir: string = PACKAGE_DIR,
|
|
134
|
+
minify = true,
|
|
134
135
|
): Promise<{ written: number; errors: string[] }> {
|
|
135
136
|
const errors: string[] = [];
|
|
136
137
|
let written = 0;
|
|
@@ -150,7 +151,7 @@ export async function writeClientRuntime(
|
|
|
150
151
|
await bundleSource(
|
|
151
152
|
`export * from ${JSON.stringify(mod.specifier)};\n`,
|
|
152
153
|
{ outfile, resolveDir },
|
|
153
|
-
{ target: "browser" },
|
|
154
|
+
{ minify, target: "browser" },
|
|
154
155
|
);
|
|
155
156
|
written += 1;
|
|
156
157
|
} catch (error) {
|
|
@@ -160,6 +161,52 @@ export async function writeClientRuntime(
|
|
|
160
161
|
return { errors, written };
|
|
161
162
|
}
|
|
162
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
|
+
|
|
163
210
|
/** Render the `<script type="importmap">` block for a resolved runtime. */
|
|
164
211
|
export function renderImportMap(imports: Record<string, string>): string {
|
|
165
212
|
const entries = Object.entries(imports)
|
|
@@ -216,11 +263,13 @@ const MAX_SUBPATH_PASSES = 10;
|
|
|
216
263
|
*
|
|
217
264
|
* @param {string} outDir - Build output root
|
|
218
265
|
* @param {string} [resolveDir] - Directory bare specifiers resolve from
|
|
266
|
+
* @param {boolean} [minify] - Minify the emitted bundles (defaults to the browser-target default)
|
|
219
267
|
* @returns {Promise<{ written: number; errors: string[] }>}
|
|
220
268
|
*/
|
|
221
269
|
export async function writeRuntimeSubpaths(
|
|
222
270
|
outDir: string,
|
|
223
271
|
resolveDir: string = PACKAGE_DIR,
|
|
272
|
+
minify = true,
|
|
224
273
|
): Promise<{ written: number; errors: string[] }> {
|
|
225
274
|
const errors: string[] = [];
|
|
226
275
|
const done = new Set<string>();
|
|
@@ -280,6 +329,7 @@ export async function writeRuntimeSubpaths(
|
|
|
280
329
|
`export * from ${JSON.stringify(specifier)};\n`,
|
|
281
330
|
{ outfile, resolveDir },
|
|
282
331
|
{
|
|
332
|
+
minify,
|
|
283
333
|
resolver: shareRuntimeCore(mod, outfile, await coreOf(mod), mirrors),
|
|
284
334
|
target: "browser",
|
|
285
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
|
}
|
package/src/site/i18n.ts
CHANGED
|
@@ -12,7 +12,11 @@
|
|
|
12
12
|
* @docs framework/site/i18n
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
canonicalizeLocale,
|
|
17
|
+
localeDirection,
|
|
18
|
+
localeOfRoute as localeForRoute,
|
|
19
|
+
} from "@jxsuite/schema/locale";
|
|
16
20
|
import type { ResolvedI18n, TextDirection } from "@jxsuite/schema/locale";
|
|
17
21
|
|
|
18
22
|
/*
|
|
@@ -26,30 +30,10 @@ import type { ResolvedI18n, TextDirection } from "@jxsuite/schema/locale";
|
|
|
26
30
|
*/
|
|
27
31
|
export { resolveI18n } from "@jxsuite/schema/locale";
|
|
28
32
|
export type { LocaleRouting, ResolvedI18n } from "@jxsuite/schema/locale";
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
* Matching is on the **first path segment**, compared canonically, so `/fr-CA/about/` resolves for
|
|
34
|
-
* a project that declared `fr-ca`. A path under no declared locale is the default locale — under
|
|
35
|
-
* `prefix-except-default` that is the point, and under `prefix-always` it is a page the author put
|
|
36
|
-
* outside the locale tree, which is theirs to place.
|
|
37
|
-
*
|
|
38
|
-
* @param {string} urlPattern - Site-absolute route pattern, e.g. `/fr/about/`
|
|
39
|
-
* @param {ResolvedI18n | null} i18n
|
|
40
|
-
* @returns {string | null} A canonical tag, or null when the project has no i18n
|
|
41
|
-
*/
|
|
42
|
-
export function localeOfRoute(urlPattern: string, i18n: ResolvedI18n | null): string | null {
|
|
43
|
-
if (i18n === null) {
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
const first = urlPattern.split("/").find(Boolean);
|
|
47
|
-
if (first === undefined) {
|
|
48
|
-
return i18n.defaultLocale;
|
|
49
|
-
}
|
|
50
|
-
const canonical = canonicalizeLocale(first);
|
|
51
|
-
return canonical !== null && i18n.locales.includes(canonical) ? canonical : i18n.defaultLocale;
|
|
52
|
-
}
|
|
33
|
+
/* `localeOfRoute` lives beside `resolveI18n` in `@jxsuite/schema/locale` for the reason that one
|
|
34
|
+
does: the studio and the cloud preview both have to answer "what language is this URL" and
|
|
35
|
+
neither can import this package. Re-exported so nothing in the build had to change its import. */
|
|
36
|
+
export { localeOfRoute } from "@jxsuite/schema/locale";
|
|
53
37
|
|
|
54
38
|
/**
|
|
55
39
|
* Routes sitting outside the locale tree under `prefix-always`.
|
|
@@ -239,7 +223,7 @@ export function translationSets(
|
|
|
239
223
|
if (route.translationKey !== undefined) {
|
|
240
224
|
declared.add(route.urlPattern);
|
|
241
225
|
}
|
|
242
|
-
const locale =
|
|
226
|
+
const locale = localeForRoute(route.urlPattern, i18n);
|
|
243
227
|
if (locale === null) {
|
|
244
228
|
continue;
|
|
245
229
|
}
|
|
@@ -124,10 +124,24 @@ export function contentHash(srcPath: string) {
|
|
|
124
124
|
* @param {ImageConfig} config
|
|
125
125
|
* @returns {string}
|
|
126
126
|
*/
|
|
127
|
+
/**
|
|
128
|
+
* Bumped whenever the pipeline decides variants differently for the same settings.
|
|
129
|
+
*
|
|
130
|
+
* The cache key is settings plus source bytes, which answers "would this produce the same output?"
|
|
131
|
+
* only while the code between them is fixed. It is not: capping variants at the largest configured
|
|
132
|
+
* width changed the answer for every image already cached, and without a version here those images
|
|
133
|
+
* keep their stale manifests — including the oversized variants the change exists to stop
|
|
134
|
+
* emitting.
|
|
135
|
+
*
|
|
136
|
+
* 1 → 2: variants stop at the largest configured width (compiler.md §7.2).
|
|
137
|
+
*/
|
|
138
|
+
const PIPELINE_VERSION = 2;
|
|
139
|
+
|
|
127
140
|
export function configHash(config: ImageConfig) {
|
|
128
141
|
const key = JSON.stringify({
|
|
129
142
|
formats: config.formats,
|
|
130
143
|
quality: config.quality,
|
|
144
|
+
v: PIPELINE_VERSION,
|
|
131
145
|
widths: config.widths,
|
|
132
146
|
});
|
|
133
147
|
return createHash("md5").update(key).digest("hex").slice(0, 8);
|
|
@@ -166,8 +180,24 @@ export async function processImage(srcPath: string, cacheImgDir: string, config:
|
|
|
166
180
|
|
|
167
181
|
const variants: ImageVariant[] = [];
|
|
168
182
|
|
|
183
|
+
/*
|
|
184
|
+
* The configured ladder, capped at the source's own width — upscaling only wastes bytes.
|
|
185
|
+
*
|
|
186
|
+
* A source SMALLER than every rung would otherwise produce no variants at all, and one that falls
|
|
187
|
+
* between rungs would never be offered at its native resolution, so both get their own width
|
|
188
|
+
* added. A source LARGER than the top rung does not: the largest configured width is the ceiling
|
|
189
|
+
* the project chose, and honouring it is the point of configuring it.
|
|
190
|
+
*
|
|
191
|
+
* This used to add the native width unconditionally, so a 3840 px screenshot shipped a 3840 px
|
|
192
|
+
* AVIF *and* WebP — encoded on every cold build, copied into dist, and offered as a `3840w`
|
|
193
|
+
* candidate that no `sizes` on any real viewport would pick.
|
|
194
|
+
*/
|
|
195
|
+
const ceiling = config.widths.length > 0 ? Math.max(...config.widths) : meta.width;
|
|
169
196
|
const widths = config.widths.filter((w) => w <= meta.width);
|
|
170
|
-
if (
|
|
197
|
+
if (meta.width < ceiling && !widths.includes(meta.width)) {
|
|
198
|
+
widths.push(meta.width);
|
|
199
|
+
}
|
|
200
|
+
if (widths.length === 0) {
|
|
171
201
|
widths.push(meta.width);
|
|
172
202
|
}
|
|
173
203
|
widths.sort((a, b) => a - b);
|