@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "1.0.0",
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.0.0",
49
- "@jxsuite/runtime": "^1.0.0",
50
- "@jxsuite/schema": "^1.0.0",
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.2.0",
56
- "@jxsuite/parser": "^1.0.0",
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 (isMutating(node.operator)) {
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.body) {
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 fn = new Function("state", ...names, def.body) as (
346
+ const { body } = def;
347
+ const fn = new Function("state", ...names, body) as (
299
348
  state: Record<string, unknown>,
300
349
  ) => unknown;
301
- if (def.body.includes("return")) {
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
+ }
@@ -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()];
@@ -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
- return JSON.parse(readFileSync(manifestPath, "utf8")) as CacheManifest;
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(projectRoot: string, cache: CacheManifest) {
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(resolve(cacheDir, "manifest.json"), JSON.stringify(cache, null, 2), "utf8");
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
+ }
@@ -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
- saveCache(projectRoot, imageCache);
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 redirectFiles = generateRedirects(projectConfig.redirects, outDir);
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>