@jxsuite/compiler 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +163 -157
- package/dist/compiler.js +102 -99
- package/package.json +6 -6
- package/src/cli.ts +30 -0
- package/src/shared.ts +53 -4
- package/src/site/dev-command.ts +84 -0
- package/src/site/head-merger.ts +13 -0
- package/src/site/image-cache.ts +57 -6
- package/src/site/image-transform.ts +21 -0
- package/src/site/preview-server.ts +93 -0
- package/src/site/site-build.ts +16 -2
- package/src/targets/compile-client.ts +36 -5
- package/src/targets/compile-element.ts +41 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/compiler",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Jx static HTML compiler, island detector, and site builder",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -45,15 +45,15 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@apidevtools/json-schema-ref-parser": "^15.5.0",
|
|
48
|
-
"@jxsuite/create": "^1.
|
|
49
|
-
"@jxsuite/runtime": "^1.
|
|
50
|
-
"@jxsuite/schema": "^1.
|
|
48
|
+
"@jxsuite/create": "^1.1.0",
|
|
49
|
+
"@jxsuite/runtime": "^1.1.0",
|
|
50
|
+
"@jxsuite/schema": "^1.1.0",
|
|
51
51
|
"sharp": "^0.35.3"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@happy-dom/global-registrator": "^20.10.6",
|
|
55
|
-
"@jxsuite/connector": "^0.
|
|
56
|
-
"@jxsuite/parser": "^1.
|
|
55
|
+
"@jxsuite/connector": "^0.3.0",
|
|
56
|
+
"@jxsuite/parser": "^1.1.0",
|
|
57
57
|
"@types/mdast": "^4.0.4"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/cli.ts
CHANGED
|
@@ -23,6 +23,8 @@ async function main() {
|
|
|
23
23
|
|
|
24
24
|
Commands:
|
|
25
25
|
build [root] Build a Jx site to dist/
|
|
26
|
+
dev [root] Start the dev server (requires @jxsuite/server and Bun)
|
|
27
|
+
preview [root] Serve an already-built dist/ directory
|
|
26
28
|
schema [root] Generate project.schema.json + document.schema.json from project.json#/extensions
|
|
27
29
|
validate [root] Validate project.json against its generated project.schema.json
|
|
28
30
|
db push [root] Sync the data section's tables to their connections (additive-only)
|
|
@@ -30,6 +32,7 @@ Commands:
|
|
|
30
32
|
Options:
|
|
31
33
|
--verbose Print detailed build progress
|
|
32
34
|
--no-clean Don't clean outDir before building
|
|
35
|
+
--port <n> dev/preview: port to listen on (default 3000 / 4173)
|
|
33
36
|
--dry-run db push: print the statements without executing them
|
|
34
37
|
--connection db push: restrict to one connection name`);
|
|
35
38
|
process.exit(0);
|
|
@@ -42,11 +45,18 @@ Options:
|
|
|
42
45
|
const flags = new Set<string>();
|
|
43
46
|
const positionals: string[] = [];
|
|
44
47
|
let connectionArg: string | undefined;
|
|
48
|
+
let portArg: number | undefined;
|
|
45
49
|
for (let index = 0; index < rest.length; index += 1) {
|
|
46
50
|
const arg = rest[index]!;
|
|
47
51
|
if (arg === "--connection") {
|
|
48
52
|
index += 1;
|
|
49
53
|
connectionArg = rest[index];
|
|
54
|
+
} else if (arg === "--port") {
|
|
55
|
+
index += 1;
|
|
56
|
+
const parsed = Math.trunc(Number(rest[index] ?? ""));
|
|
57
|
+
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
58
|
+
portArg = parsed;
|
|
59
|
+
}
|
|
50
60
|
} else if (arg.startsWith("--")) {
|
|
51
61
|
flags.add(arg);
|
|
52
62
|
} else {
|
|
@@ -118,6 +128,26 @@ Options:
|
|
|
118
128
|
console.error(`Build failed: ${err.message}`);
|
|
119
129
|
process.exit(1);
|
|
120
130
|
}
|
|
131
|
+
} else if (command === "dev") {
|
|
132
|
+
try {
|
|
133
|
+
const { runDev } = await import("./site/dev-command.ts");
|
|
134
|
+
runDev(projectRoot, portArg);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const err = error as Error;
|
|
137
|
+
console.error(err.message);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
} else if (command === "preview") {
|
|
141
|
+
const { existsSync } = await import("node:fs");
|
|
142
|
+
const distDir = resolve(projectRoot, "dist");
|
|
143
|
+
if (!existsSync(distDir)) {
|
|
144
|
+
console.error(`No build output at ${distDir} — run \`jx build\` first.`);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
const { startPreviewServer } = await import("./site/preview-server.ts");
|
|
148
|
+
const port = portArg ?? 4173;
|
|
149
|
+
startPreviewServer(distDir, port);
|
|
150
|
+
console.log(`Previewing ${distDir} at http://localhost:${port}`);
|
|
121
151
|
} else if (command === "schema") {
|
|
122
152
|
try {
|
|
123
153
|
const { writeProjectSchemas } = await import("./site/schema-command.ts");
|
package/src/shared.ts
CHANGED
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
|
|
9
9
|
import { RESERVED_KEYS, camelToKebab, toCSSText } from "@jxsuite/runtime";
|
|
10
10
|
import { evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
|
|
11
|
+
import { runStatements } from "@jxsuite/runtime/statements";
|
|
11
12
|
import {
|
|
12
13
|
childrenContainArray,
|
|
14
|
+
hasStructuredBody,
|
|
13
15
|
isExpressionDef,
|
|
14
16
|
isFunctionDef,
|
|
15
17
|
isMappedArray,
|
|
18
|
+
isNamedFormulaDef,
|
|
16
19
|
isPrototypeDef,
|
|
17
20
|
isRef,
|
|
18
21
|
isSchemaOnlyDef as isSchemaOnly,
|
|
@@ -35,6 +38,28 @@ import type {
|
|
|
35
38
|
// Re-export runtime utilities used by submodules
|
|
36
39
|
export { RESERVED_KEYS, camelToKebab, toCSSText } from "@jxsuite/runtime";
|
|
37
40
|
export { compileExpression, evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
|
|
41
|
+
export { compileStatements, runStatements } from "@jxsuite/runtime/statements";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Emit the JS source of a named formula's scope callable: positional arguments are mapped onto the
|
|
45
|
+
* declared parameter names (honoring CemParameter defaults) as the `_args` object the compiled
|
|
46
|
+
* body's `$args/` refs read from. Call sites stay positional, matching the interpreter.
|
|
47
|
+
*/
|
|
48
|
+
export function emitFormulaFn(def: { parameters?: unknown[] }, compiledBody: string): string {
|
|
49
|
+
const entries: string[] = [];
|
|
50
|
+
for (const [i, p] of (def.parameters ?? []).entries()) {
|
|
51
|
+
const name = typeof p === "string" ? p : ((p as { name?: string } | null)?.name ?? "");
|
|
52
|
+
if (!name) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const hasDefault = typeof p === "object" && p !== null && "default" in p;
|
|
56
|
+
const arg = hasDefault
|
|
57
|
+
? `_a[${i}] === undefined ? ${JSON.stringify((p as { default?: unknown }).default)} : _a[${i}]`
|
|
58
|
+
: `_a[${i}]`;
|
|
59
|
+
entries.push(`${JSON.stringify(name)}: ${arg}`);
|
|
60
|
+
}
|
|
61
|
+
return `(..._a) => { const _args = { ${entries.join(", ")} }; return ${compiledBody}; }`;
|
|
62
|
+
}
|
|
38
63
|
export type { ExpressionNode } from "@jxsuite/runtime/expression";
|
|
39
64
|
|
|
40
65
|
// CDN defaults
|
|
@@ -283,7 +308,24 @@ export function buildInitialScope(
|
|
|
283
308
|
}
|
|
284
309
|
if (isExpressionDef(def)) {
|
|
285
310
|
const node = def.$expression as ExpressionNode;
|
|
286
|
-
if (
|
|
311
|
+
if (isNamedFormulaDef(def)) {
|
|
312
|
+
// Named formula: callable, positional args mapped onto its declared parameters.
|
|
313
|
+
const params = def.parameters as (string | { name?: string; default?: unknown })[];
|
|
314
|
+
setOwnScopeValue(scope, key, (...argValues: unknown[]) => {
|
|
315
|
+
const args: Record<string, unknown> = {};
|
|
316
|
+
for (const [i, p] of params.entries()) {
|
|
317
|
+
const name = typeof p === "string" ? p : (p?.name ?? "");
|
|
318
|
+
if (!name) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
args[name] =
|
|
322
|
+
argValues[i] === undefined && typeof p === "object" && p !== null && "default" in p
|
|
323
|
+
? p.default
|
|
324
|
+
: argValues[i];
|
|
325
|
+
}
|
|
326
|
+
return evaluateExpression(node, scope, null, { args });
|
|
327
|
+
});
|
|
328
|
+
} else if (isMutating(node.operator)) {
|
|
287
329
|
setOwnScopeValue(scope, key, (s: Record<string, unknown>, event: Event) =>
|
|
288
330
|
evaluateExpression(node, s, event),
|
|
289
331
|
);
|
|
@@ -293,12 +335,19 @@ export function buildInitialScope(
|
|
|
293
335
|
continue;
|
|
294
336
|
}
|
|
295
337
|
if (isFunctionDef(def)) {
|
|
296
|
-
if (def
|
|
338
|
+
if (hasStructuredBody(def)) {
|
|
339
|
+
// Structured body (spec §20): a side-effecting handler in the build-time scope.
|
|
340
|
+
const { body } = def;
|
|
341
|
+
setOwnScopeValue(scope, key, (s: Record<string, unknown>, event?: Event) => {
|
|
342
|
+
void runStatements(body, s ?? scope, event ?? null);
|
|
343
|
+
});
|
|
344
|
+
} else if (typeof def.body === "string") {
|
|
297
345
|
const names = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
|
|
298
|
-
const
|
|
346
|
+
const { body } = def;
|
|
347
|
+
const fn = new Function("state", ...names, body) as (
|
|
299
348
|
state: Record<string, unknown>,
|
|
300
349
|
) => unknown;
|
|
301
|
-
if (
|
|
350
|
+
if (body.includes("return")) {
|
|
302
351
|
defineLazyScopeValue(scope, key, () => fn(scope));
|
|
303
352
|
} else {
|
|
304
353
|
setOwnScopeValue(scope, key, fn);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev command — `jx dev` resolves the project's @jxsuite/server dev entry and spawns it under Bun.
|
|
3
|
+
*
|
|
4
|
+
* The compiler cannot depend on @jxsuite/server (the server already depends on the compiler), and
|
|
5
|
+
* the dev server is Bun-native while the jx bin runs under Node — so the command is pure resolution
|
|
6
|
+
* + spawn: the entry module is resolved from the PROJECT's node_modules and executed with Bun.
|
|
7
|
+
*
|
|
8
|
+
* @module dev-command
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createRequire } from "node:module";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import type { ChildProcess } from "node:child_process";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the project's `@jxsuite/server/dev` entry module.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} projectRoot - Absolute project root
|
|
20
|
+
* @returns {string} Absolute path to the dev entry module
|
|
21
|
+
* @throws {Error} With an install hint when the package is not present
|
|
22
|
+
*/
|
|
23
|
+
export function resolveDevEntry(projectRoot: string): string {
|
|
24
|
+
try {
|
|
25
|
+
const projectRequire = createRequire(join(projectRoot, "package.json"));
|
|
26
|
+
return projectRequire.resolve("@jxsuite/server/dev");
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`@jxsuite/server is not installed in ${projectRoot}. ` +
|
|
30
|
+
`Add it to devDependencies (e.g. \`bun add -d @jxsuite/server\`) and retry.`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The slice of `process` runDev touches — injectable for tests. */
|
|
36
|
+
export interface ProcessLike {
|
|
37
|
+
on: (event: string, handler: () => void) => unknown;
|
|
38
|
+
exitCode?: number | string | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Spawn the dev server under Bun with inherited stdio.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} projectRoot - Absolute project root
|
|
45
|
+
* @param {number | undefined} port - Optional port override
|
|
46
|
+
* @param {typeof spawn} [spawnImpl] - Injectable for tests
|
|
47
|
+
* @param {ProcessLike} [proc] - Injectable for tests
|
|
48
|
+
* @returns {ChildProcess} The spawned Bun process
|
|
49
|
+
*/
|
|
50
|
+
export function runDev(
|
|
51
|
+
projectRoot: string,
|
|
52
|
+
port: number | undefined,
|
|
53
|
+
spawnImpl: typeof spawn = spawn,
|
|
54
|
+
proc: ProcessLike = process as unknown as ProcessLike,
|
|
55
|
+
): ChildProcess {
|
|
56
|
+
const entry = resolveDevEntry(projectRoot);
|
|
57
|
+
const args = [entry, "--root", projectRoot];
|
|
58
|
+
if (port !== undefined) {
|
|
59
|
+
args.push("--port", String(port));
|
|
60
|
+
}
|
|
61
|
+
const child = spawnImpl("bun", args, { stdio: "inherit" });
|
|
62
|
+
child.on("error", (error: NodeJS.ErrnoException) => {
|
|
63
|
+
if (error.code === "ENOENT") {
|
|
64
|
+
console.error(
|
|
65
|
+
"The Jx dev server runs on Bun, which was not found on your PATH. " +
|
|
66
|
+
"Install it from https://bun.sh and retry.",
|
|
67
|
+
);
|
|
68
|
+
} else {
|
|
69
|
+
console.error(`Failed to start the dev server: ${error.message}`);
|
|
70
|
+
}
|
|
71
|
+
proc.exitCode = 1;
|
|
72
|
+
});
|
|
73
|
+
// Ctrl+C reaches the child through the shared process group; also mirror explicit kills.
|
|
74
|
+
proc.on("SIGINT", () => {
|
|
75
|
+
child.kill("SIGINT");
|
|
76
|
+
});
|
|
77
|
+
proc.on("SIGTERM", () => {
|
|
78
|
+
child.kill("SIGTERM");
|
|
79
|
+
});
|
|
80
|
+
child.on("exit", (code) => {
|
|
81
|
+
proc.exitCode = code ?? 0;
|
|
82
|
+
});
|
|
83
|
+
return child;
|
|
84
|
+
}
|
package/src/site/head-merger.ts
CHANGED
|
@@ -63,6 +63,19 @@ export function mergeHead(
|
|
|
63
63
|
attributes: { href: canonical, rel: "canonical" },
|
|
64
64
|
tagName: "link",
|
|
65
65
|
});
|
|
66
|
+
// Auto OpenGraph identity (site-architecture §8.4) — author-supplied values win.
|
|
67
|
+
if (!merged.has("meta:og:url")) {
|
|
68
|
+
merged.set("meta:og:url", {
|
|
69
|
+
attributes: { content: canonical, property: "og:url" },
|
|
70
|
+
tagName: "meta",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (context.siteName && !merged.has("meta:og:site_name")) {
|
|
75
|
+
merged.set("meta:og:site_name", {
|
|
76
|
+
attributes: { content: context.siteName, property: "og:site_name" },
|
|
77
|
+
tagName: "meta",
|
|
78
|
+
});
|
|
66
79
|
}
|
|
67
80
|
|
|
68
81
|
return [...merged.values()];
|
package/src/site/image-cache.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* CI environments (e.g. Cloudflare Pages) can persist it via their npm cache layer.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { basename, resolve } from "node:path";
|
|
11
11
|
import { execSync } from "node:child_process";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
@@ -23,6 +23,7 @@ interface CacheEntry {
|
|
|
23
23
|
export interface CacheManifest {
|
|
24
24
|
version: number; // Cache format version
|
|
25
25
|
entries: Record<string, CacheEntry>; // Cached entries keyed by content+config hash
|
|
26
|
+
touched?: Set<string>; // Keys resolved during this build — runtime only, never serialized
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
// ─── Cache directory resolution ──────────────────────────────────────────────
|
|
@@ -97,25 +98,73 @@ export function cacheKey(srcPath: string, config: ImageConfig) {
|
|
|
97
98
|
export function loadCache(projectRoot: string): CacheManifest {
|
|
98
99
|
const manifestPath = resolve(getImageCacheDir(projectRoot), "manifest.json");
|
|
99
100
|
if (!existsSync(manifestPath)) {
|
|
100
|
-
return { entries: {}, version: 1 };
|
|
101
|
+
return { entries: {}, touched: new Set(), version: 1 };
|
|
101
102
|
}
|
|
102
103
|
try {
|
|
103
|
-
|
|
104
|
+
const parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as CacheManifest;
|
|
105
|
+
parsed.touched = new Set();
|
|
106
|
+
return parsed;
|
|
104
107
|
} catch {
|
|
105
|
-
return { entries: {}, version: 1 };
|
|
108
|
+
return { entries: {}, touched: new Set(), version: 1 };
|
|
106
109
|
}
|
|
107
110
|
}
|
|
108
111
|
|
|
109
112
|
/**
|
|
110
113
|
* Save the cache manifest to disk.
|
|
111
114
|
*
|
|
115
|
+
* With `prune: true`, entries whose key was never touched during this build (deleted/replaced
|
|
116
|
+
* sources, superseded configs) are dropped and their variant files deleted first, so a persisted
|
|
117
|
+
* cache — and the dist copy made from it — stays bounded to images the build actually uses.
|
|
118
|
+
*
|
|
112
119
|
* @param {string} projectRoot
|
|
113
120
|
* @param {CacheManifest} cache
|
|
121
|
+
* @param {{ prune?: boolean }} [options]
|
|
114
122
|
*/
|
|
115
|
-
export function saveCache(
|
|
123
|
+
export function saveCache(
|
|
124
|
+
projectRoot: string,
|
|
125
|
+
cache: CacheManifest,
|
|
126
|
+
options: { prune?: boolean } = {},
|
|
127
|
+
) {
|
|
128
|
+
if (options.prune && cache.touched) {
|
|
129
|
+
pruneStale(cache, cache.touched);
|
|
130
|
+
}
|
|
116
131
|
const cacheDir = getImageCacheDir(projectRoot);
|
|
117
132
|
mkdirSync(cacheDir, { recursive: true });
|
|
118
|
-
writeFileSync(
|
|
133
|
+
writeFileSync(
|
|
134
|
+
resolve(cacheDir, "manifest.json"),
|
|
135
|
+
JSON.stringify({ entries: cache.entries, version: cache.version }, null, 2),
|
|
136
|
+
"utf8",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Remove entries (and their variant files) that were not touched during this build. A file shared
|
|
142
|
+
* with a surviving entry is kept — the same source under an older config produces overlapping
|
|
143
|
+
* width/format filenames, so deleting blindly would remove files a live entry still references.
|
|
144
|
+
*
|
|
145
|
+
* @param {CacheManifest} cache
|
|
146
|
+
* @param {Set<string>} touched
|
|
147
|
+
*/
|
|
148
|
+
function pruneStale(cache: CacheManifest, touched: Set<string>) {
|
|
149
|
+
const keep = new Set<string>();
|
|
150
|
+
for (const [key, entry] of Object.entries(cache.entries)) {
|
|
151
|
+
if (touched.has(key)) {
|
|
152
|
+
for (const variant of entry.manifest.variants ?? []) {
|
|
153
|
+
keep.add(variant.absolutePath);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const [key, entry] of Object.entries(cache.entries)) {
|
|
158
|
+
if (touched.has(key)) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
for (const variant of entry.manifest.variants ?? []) {
|
|
162
|
+
if (!keep.has(variant.absolutePath)) {
|
|
163
|
+
rmSync(variant.absolutePath, { force: true });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
delete cache.entries[key];
|
|
167
|
+
}
|
|
119
168
|
}
|
|
120
169
|
|
|
121
170
|
// ─── Entry access ─────────────────────────────────────────────────────────────
|
|
@@ -128,6 +177,7 @@ export function saveCache(projectRoot: string, cache: CacheManifest) {
|
|
|
128
177
|
* @returns {ImageManifest | null}
|
|
129
178
|
*/
|
|
130
179
|
export function getCached(cache: CacheManifest, key: string) {
|
|
180
|
+
cache.touched?.add(key);
|
|
131
181
|
const entry = cache.entries[key];
|
|
132
182
|
if (!entry) {
|
|
133
183
|
return null;
|
|
@@ -149,6 +199,7 @@ export function setCached(
|
|
|
149
199
|
sourcePath: string,
|
|
150
200
|
manifest: ImageManifest,
|
|
151
201
|
) {
|
|
202
|
+
cache.touched?.add(key);
|
|
152
203
|
cache.entries[key] = {
|
|
153
204
|
manifest,
|
|
154
205
|
source: sourcePath,
|
|
@@ -318,6 +318,7 @@ async function transformImgNode(
|
|
|
318
318
|
}
|
|
319
319
|
|
|
320
320
|
let srcset;
|
|
321
|
+
let dims: { width: number; height: number } | null = null;
|
|
321
322
|
|
|
322
323
|
if (remote) {
|
|
323
324
|
// Original dimensions are unknown without fetching — emit every configured width and let
|
|
@@ -332,6 +333,7 @@ async function transformImgNode(
|
|
|
332
333
|
if (config.service === "cloudflare") {
|
|
333
334
|
const meta = await resolveCfMeta(absoluteSrc, metaCache);
|
|
334
335
|
srcset = buildCloudflareSrcset(src, config, meta.width ?? Infinity, meta.hash);
|
|
336
|
+
dims = meta.width && meta.height ? { height: meta.height, width: meta.width } : null;
|
|
335
337
|
} else {
|
|
336
338
|
let manifest = imageRefs.get(absoluteSrc);
|
|
337
339
|
|
|
@@ -342,6 +344,10 @@ async function transformImgNode(
|
|
|
342
344
|
|
|
343
345
|
const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0]!;
|
|
344
346
|
srcset = buildSrcset(manifest.variants, preferredFormat);
|
|
347
|
+
dims =
|
|
348
|
+
manifest.original?.width && manifest.original.height
|
|
349
|
+
? { height: manifest.original.height, width: manifest.original.width }
|
|
350
|
+
: null;
|
|
345
351
|
}
|
|
346
352
|
}
|
|
347
353
|
|
|
@@ -350,6 +356,12 @@ async function transformImgNode(
|
|
|
350
356
|
node.attributes.sizes ??= config.sizes;
|
|
351
357
|
}
|
|
352
358
|
|
|
359
|
+
// Intrinsic dimensions prevent layout shift; author-supplied values win.
|
|
360
|
+
if (dims && node.attributes.width === undefined && node.attributes.height === undefined) {
|
|
361
|
+
node.attributes.width = String(dims.width);
|
|
362
|
+
node.attributes.height = String(dims.height);
|
|
363
|
+
}
|
|
364
|
+
|
|
353
365
|
if (config.lazyLoad && node.attributes.loading !== "eager") {
|
|
354
366
|
node.attributes.loading = "lazy";
|
|
355
367
|
node.attributes.decoding = "async";
|
|
@@ -412,12 +424,14 @@ async function transformInnerHtmlImages(
|
|
|
412
424
|
}
|
|
413
425
|
|
|
414
426
|
let srcset;
|
|
427
|
+
let dims: { width: number; height: number } | null = null;
|
|
415
428
|
|
|
416
429
|
if (remote) {
|
|
417
430
|
srcset = buildCloudflareSrcset(src, config, Infinity, null);
|
|
418
431
|
} else if (config.service === "cloudflare") {
|
|
419
432
|
const meta = await resolveCfMeta(absoluteSrc as string, metaCache);
|
|
420
433
|
srcset = buildCloudflareSrcset(src, config, meta.width ?? Infinity, meta.hash);
|
|
434
|
+
dims = meta.width && meta.height ? { height: meta.height, width: meta.width } : null;
|
|
421
435
|
} else {
|
|
422
436
|
let manifest = imageRefs.get(absoluteSrc as string);
|
|
423
437
|
if (!manifest) {
|
|
@@ -427,12 +441,19 @@ async function transformInnerHtmlImages(
|
|
|
427
441
|
|
|
428
442
|
const preferredFormat = config.formats.includes("avif") ? "avif" : config.formats[0]!;
|
|
429
443
|
srcset = buildSrcset(manifest.variants, preferredFormat);
|
|
444
|
+
dims =
|
|
445
|
+
manifest.original?.width && manifest.original.height
|
|
446
|
+
? { height: manifest.original.height, width: manifest.original.width }
|
|
447
|
+
: null;
|
|
430
448
|
}
|
|
431
449
|
if (!srcset) {
|
|
432
450
|
continue;
|
|
433
451
|
}
|
|
434
452
|
|
|
435
453
|
let extra = ` srcset="${srcset}" sizes="${config.sizes}"`;
|
|
454
|
+
if (dims && !/\bwidth=/.test(attrs) && !/\bheight=/.test(attrs)) {
|
|
455
|
+
extra += ` width="${dims.width}" height="${dims.height}"`;
|
|
456
|
+
}
|
|
436
457
|
if (config.lazyLoad && !/\bloading="eager"/.test(attrs)) {
|
|
437
458
|
if (!/\bloading=/.test(attrs)) {
|
|
438
459
|
extra += ` loading="lazy"`;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preview server — `jx preview` serves an already-built dist/ directory.
|
|
3
|
+
*
|
|
4
|
+
* Dependency-free node:http static server, so the Node-run `jx` bin needs neither Bun nor the
|
|
5
|
+
* dev-server package. Maps directory URLs to index.html (the build's trailingSlash output) and
|
|
6
|
+
* falls back to <dist>/404.html when present.
|
|
7
|
+
*
|
|
8
|
+
* @module preview-server
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createServer } from "node:http";
|
|
12
|
+
import type { Server } from "node:http";
|
|
13
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
14
|
+
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
15
|
+
|
|
16
|
+
const MIME_TYPES: Record<string, string> = {
|
|
17
|
+
".avif": "image/avif",
|
|
18
|
+
".css": "text/css; charset=utf-8",
|
|
19
|
+
".gif": "image/gif",
|
|
20
|
+
".html": "text/html; charset=utf-8",
|
|
21
|
+
".ico": "image/x-icon",
|
|
22
|
+
".jpeg": "image/jpeg",
|
|
23
|
+
".jpg": "image/jpeg",
|
|
24
|
+
".js": "text/javascript; charset=utf-8",
|
|
25
|
+
".json": "application/json; charset=utf-8",
|
|
26
|
+
".md": "text/markdown; charset=utf-8",
|
|
27
|
+
".png": "image/png",
|
|
28
|
+
".svg": "image/svg+xml",
|
|
29
|
+
".txt": "text/plain; charset=utf-8",
|
|
30
|
+
".webp": "image/webp",
|
|
31
|
+
".woff2": "font/woff2",
|
|
32
|
+
".xml": "application/xml; charset=utf-8",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve a request pathname to a file inside distDir, or null. Directory URLs (with or without a
|
|
37
|
+
* trailing slash) map to their index.html; traversal outside distDir is rejected.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} distDir - Absolute dist directory
|
|
40
|
+
* @param {string} pathname - Decoded URL pathname
|
|
41
|
+
* @returns {string | null} Absolute file path to serve, or null
|
|
42
|
+
*/
|
|
43
|
+
export function resolvePreviewFile(distDir: string, pathname: string): string | null {
|
|
44
|
+
const safe = normalize(pathname).replaceAll("\\", "/");
|
|
45
|
+
const candidate = resolve(distDir, `.${safe.startsWith("/") ? safe : `/${safe}`}`);
|
|
46
|
+
if (candidate !== distDir && !candidate.startsWith(distDir + sep)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (existsSync(candidate)) {
|
|
50
|
+
if (statSync(candidate).isDirectory()) {
|
|
51
|
+
const index = join(candidate, "index.html");
|
|
52
|
+
return existsSync(index) ? index : null;
|
|
53
|
+
}
|
|
54
|
+
return candidate;
|
|
55
|
+
}
|
|
56
|
+
// Extensionless route without trailing slash → its directory index
|
|
57
|
+
const index = join(candidate, "index.html");
|
|
58
|
+
return existsSync(index) ? index : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Start the preview server on the given port (0 picks a free port).
|
|
63
|
+
*
|
|
64
|
+
* @param {string} distDir - Absolute path to the built dist directory
|
|
65
|
+
* @param {number} port - Port to listen on
|
|
66
|
+
* @returns {Server} The listening node:http server
|
|
67
|
+
*/
|
|
68
|
+
export function startPreviewServer(distDir: string, port: number): Server {
|
|
69
|
+
const dist = resolve(distDir);
|
|
70
|
+
const server = createServer((req, res) => {
|
|
71
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
72
|
+
const filePath = resolvePreviewFile(dist, decodeURIComponent(url.pathname));
|
|
73
|
+
|
|
74
|
+
if (filePath) {
|
|
75
|
+
res.writeHead(200, {
|
|
76
|
+
"Content-Type": MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream",
|
|
77
|
+
});
|
|
78
|
+
res.end(readFileSync(filePath));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const notFound = join(dist, "404.html");
|
|
83
|
+
if (existsSync(notFound)) {
|
|
84
|
+
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
|
|
85
|
+
res.end(readFileSync(notFound));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
89
|
+
res.end("Not found");
|
|
90
|
+
});
|
|
91
|
+
server.listen(port);
|
|
92
|
+
return server;
|
|
93
|
+
}
|
package/src/site/site-build.ts
CHANGED
|
@@ -367,7 +367,9 @@ export async function buildSite(
|
|
|
367
367
|
|
|
368
368
|
// ── 6b. Save image cache and copy variants to dist ──────────────────────
|
|
369
369
|
if (imageCache && projectConfig.images.optimize) {
|
|
370
|
-
|
|
370
|
+
// Prune only on fully successful builds — a route that failed to compile never
|
|
371
|
+
// Touched its images, and pruning would evict their still-valid entries.
|
|
372
|
+
saveCache(projectRoot, imageCache, { prune: errors.length === 0 });
|
|
371
373
|
const cacheOptimizedDir = resolve(getImageCacheDir(projectRoot), "_optimized");
|
|
372
374
|
if (existsSync(cacheOptimizedDir)) {
|
|
373
375
|
const distOptimizedDir = resolve(outDir, "images/_optimized");
|
|
@@ -441,7 +443,8 @@ export async function buildSite(
|
|
|
441
443
|
// ── 7. Generate redirects ───────────────────────────────────────────────
|
|
442
444
|
if (projectConfig.redirects && Object.keys(projectConfig.redirects).length > 0) {
|
|
443
445
|
log("Generating redirects...");
|
|
444
|
-
const
|
|
446
|
+
const compiledUrls = new Set(routes.map((r) => r.urlPattern));
|
|
447
|
+
const redirectFiles = generateRedirects(projectConfig.redirects, outDir, compiledUrls);
|
|
445
448
|
fileCount += redirectFiles;
|
|
446
449
|
}
|
|
447
450
|
|
|
@@ -1315,14 +1318,18 @@ function routeToOutputPath(urlPattern: string, outDir: string, trailingSlash: st
|
|
|
1315
1318
|
*
|
|
1316
1319
|
* @param {Record<string, string | { destination: string; status?: number }>} redirects
|
|
1317
1320
|
* @param {string} outDir
|
|
1321
|
+
* @param {Set<string>} [compiledUrls] - URL patterns of compiled routes, for conflict warnings
|
|
1318
1322
|
* @returns {number} Number of files written
|
|
1319
1323
|
*/
|
|
1320
1324
|
function generateRedirects(
|
|
1321
1325
|
redirects: Record<string, string | { destination: string; status?: number }>,
|
|
1322
1326
|
outDir: string,
|
|
1327
|
+
compiledUrls = new Set<string>(),
|
|
1323
1328
|
) {
|
|
1324
1329
|
let count = 0;
|
|
1325
1330
|
const redirectLines: string[] = [];
|
|
1331
|
+
const normalizeUrl = (u: string) => (u.length > 1 && u.endsWith("/") ? u.slice(0, -1) : u);
|
|
1332
|
+
const compiled = new Set([...compiledUrls].map((u) => normalizeUrl(u)));
|
|
1326
1333
|
|
|
1327
1334
|
for (const [source, target] of Object.entries(redirects)) {
|
|
1328
1335
|
const dest = typeof target === "object" ? target.destination : target;
|
|
@@ -1334,6 +1341,13 @@ function generateRedirects(
|
|
|
1334
1341
|
continue;
|
|
1335
1342
|
}
|
|
1336
1343
|
|
|
1344
|
+
if (compiled.has(normalizeUrl(source))) {
|
|
1345
|
+
console.warn(
|
|
1346
|
+
`Redirect "${source}" overwrites a compiled page at the same route — ` +
|
|
1347
|
+
`remove the redirect or the page.`,
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1337
1351
|
// Static redirect — emit an HTML file with meta refresh
|
|
1338
1352
|
const htmlPath = routeToOutputPath(source, outDir, "always");
|
|
1339
1353
|
const html = `<!DOCTYPE html>
|