@jxsuite/compiler 1.0.0 → 1.2.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 +251 -176
- package/dist/compiler.js +117 -112
- package/package.json +7 -6
- package/src/cli.ts +30 -0
- package/src/compiler.ts +18 -2
- package/src/shared.ts +220 -112
- package/src/site/bundler.ts +187 -0
- 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/prototype-resolver.ts +15 -0
- package/src/site/site-build.ts +163 -15
- package/src/targets/compile-client.ts +53 -8
- package/src/targets/compile-element.ts +69 -7
- package/src/targets/compile-static.ts +17 -2
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundler.js — timing-aware module bundling for compiled sites
|
|
3
|
+
*
|
|
4
|
+
* One backend abstraction serving every bundling need of the build (spec.md §12, compiler.md):
|
|
5
|
+
* client-timing `$src` sidecars today, the server worker in a follow-up. Backend selection is
|
|
6
|
+
* runtime-adaptive: `Bun.build` when the build runs under Bun, esbuild (dynamically imported) under
|
|
7
|
+
* plain Node. Options are kept minimal and identical across backends so output stays as stable as
|
|
8
|
+
* possible; set `JX_BUNDLER=esbuild` to force the fallback (e.g. for parity checks).
|
|
9
|
+
*
|
|
10
|
+
* `timing: "compiler"` code is never bundled — it executes in the build host via the extension
|
|
11
|
+
* registry's `importImplementation` path.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
import { join, resolve } from "node:path";
|
|
17
|
+
import { isNpmSpecifier, NPM_SPECIFIER_PREFIX } from "@jxsuite/schema/asset-paths";
|
|
18
|
+
|
|
19
|
+
/** Import specifiers left external in client bundles — provided by the page importmap. */
|
|
20
|
+
export const CLIENT_EXTERNALS = ["@vue/reactivity", "lit-html"];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* True when a Function-def `$src` specifier is bundled by the build: `npm:` package specifiers and
|
|
24
|
+
* project-relative files. Absolute URL paths (`/lib/x.js`, `https://…`) are served as-is.
|
|
25
|
+
*/
|
|
26
|
+
export function isBundleableSrc(specifier: string): boolean {
|
|
27
|
+
return isNpmSpecifier(specifier) || specifier.startsWith("./") || specifier.startsWith("../");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* True when the Node code path (createRequire resolution, esbuild backend) should be used: always
|
|
32
|
+
* under plain Node, or when `JX_BUNDLER=esbuild` forces it for parity checks.
|
|
33
|
+
*/
|
|
34
|
+
function useNodeFallback(): boolean {
|
|
35
|
+
return typeof Bun === "undefined" || process.env.JX_BUNDLER === "esbuild";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve a bundleable `$src` specifier to the absolute entry file the bundler starts from. `npm:`
|
|
40
|
+
* specifiers resolve from the project root (project node_modules first, then up-tree); relative
|
|
41
|
+
* specifiers resolve against the directory of the document that declared them.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveSidecarEntry(
|
|
44
|
+
specifier: string,
|
|
45
|
+
docDir: string,
|
|
46
|
+
projectRoot: string,
|
|
47
|
+
): string {
|
|
48
|
+
if (isNpmSpecifier(specifier)) {
|
|
49
|
+
const bare = specifier.slice(NPM_SPECIFIER_PREFIX.length);
|
|
50
|
+
if (useNodeFallback()) {
|
|
51
|
+
return createRequire(join(projectRoot, "package.json")).resolve(bare);
|
|
52
|
+
}
|
|
53
|
+
return Bun.resolveSync(bare, projectRoot);
|
|
54
|
+
}
|
|
55
|
+
return resolve(docDir, specifier);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface BundleRequest {
|
|
59
|
+
/** Absolute path of the module to bundle. */
|
|
60
|
+
entryPath: string;
|
|
61
|
+
/** Absolute path of the bundled output file. */
|
|
62
|
+
outfile: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Resolution/runtime targets: client sidecars use "browser"; the site worker uses the rest. */
|
|
66
|
+
export type BundleTargetName = "browser" | "node" | "bun" | "workerd";
|
|
67
|
+
|
|
68
|
+
export interface BundleOptions {
|
|
69
|
+
target: BundleTargetName;
|
|
70
|
+
/** Bare specifiers left unresolved in the output. */
|
|
71
|
+
external?: string[];
|
|
72
|
+
/** Package.json export conditions for resolution (e.g. ["workerd", "worker"]). */
|
|
73
|
+
conditions?: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Per-adapter bundle options for the generated site worker (WinterTC runtimes). Cloudflare resolves
|
|
78
|
+
* with workerd conditions and keeps `cloudflare:*`/`node:*` imports external (nodejs_compat
|
|
79
|
+
* provides node builtins at runtime); node/bun bundle platform-native with builtins external by
|
|
80
|
+
* construction.
|
|
81
|
+
*/
|
|
82
|
+
export function workerBundleOptions(adapter: string): BundleOptions {
|
|
83
|
+
if (adapter === "cloudflare-workers" || adapter === "cloudflare-pages") {
|
|
84
|
+
return {
|
|
85
|
+
conditions: ["workerd", "worker"],
|
|
86
|
+
external: ["cloudflare:*", "node:*"],
|
|
87
|
+
target: "workerd",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { target: adapter === "bun" ? "bun" : "node" };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Map a bundle target to esbuild's platform (conditions carry the workerd specifics). */
|
|
94
|
+
function esbuildPlatform(target: BundleTargetName): "browser" | "node" {
|
|
95
|
+
return target === "node" || target === "bun" ? "node" : "browser";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Map a bundle target to Bun.build's target. */
|
|
99
|
+
function bunTarget(target: BundleTargetName): "browser" | "node" | "bun" {
|
|
100
|
+
if (target === "workerd") {
|
|
101
|
+
return "browser";
|
|
102
|
+
}
|
|
103
|
+
return target;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Bundle one entry module into a single self-contained ESM file. Throws with the backend's
|
|
108
|
+
* diagnostics on failure.
|
|
109
|
+
*/
|
|
110
|
+
export async function bundleEntry(request: BundleRequest, opts: BundleOptions): Promise<void> {
|
|
111
|
+
const external = opts.external ?? [];
|
|
112
|
+
|
|
113
|
+
if (useNodeFallback()) {
|
|
114
|
+
// Dynamic import keeps esbuild (a native-binary dependency) out of Bun-run builds entirely.
|
|
115
|
+
const esbuild = await import("esbuild");
|
|
116
|
+
await esbuild.build({
|
|
117
|
+
bundle: true,
|
|
118
|
+
...(opts.conditions ? { conditions: opts.conditions } : {}),
|
|
119
|
+
entryPoints: [request.entryPath],
|
|
120
|
+
external,
|
|
121
|
+
format: "esm",
|
|
122
|
+
logLevel: "silent",
|
|
123
|
+
minify: false,
|
|
124
|
+
outfile: request.outfile,
|
|
125
|
+
platform: esbuildPlatform(opts.target),
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const result = await Bun.build({
|
|
131
|
+
...(opts.conditions ? { conditions: opts.conditions } : {}),
|
|
132
|
+
entrypoints: [request.entryPath],
|
|
133
|
+
external,
|
|
134
|
+
format: "esm",
|
|
135
|
+
minify: false,
|
|
136
|
+
target: bunTarget(opts.target),
|
|
137
|
+
throw: false,
|
|
138
|
+
});
|
|
139
|
+
if (!result.success || result.outputs.length === 0) {
|
|
140
|
+
const logs = result.logs.map(String).join("\n");
|
|
141
|
+
throw new Error(`Bun.build failed for ${request.entryPath}:\n${logs}`);
|
|
142
|
+
}
|
|
143
|
+
writeFileSync(request.outfile, await result.outputs[0]!.text(), "utf8");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Bundle generated worker source (a string, not a file) into a self-contained module for the
|
|
148
|
+
* adapter's runtime. Bare and relative specifiers resolve from the project root — exactly how the
|
|
149
|
+
* unbundled source would have resolved when served from within the project.
|
|
150
|
+
*/
|
|
151
|
+
export async function bundleWorkerSource(
|
|
152
|
+
source: string,
|
|
153
|
+
{ projectRoot, outfile, adapter }: { projectRoot: string; outfile: string; adapter: string },
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
const opts = workerBundleOptions(adapter);
|
|
156
|
+
|
|
157
|
+
if (useNodeFallback()) {
|
|
158
|
+
const esbuild = await import("esbuild");
|
|
159
|
+
await esbuild.build({
|
|
160
|
+
bundle: true,
|
|
161
|
+
...(opts.conditions ? { conditions: opts.conditions } : {}),
|
|
162
|
+
external: opts.external ?? [],
|
|
163
|
+
format: "esm",
|
|
164
|
+
logLevel: "silent",
|
|
165
|
+
minify: false,
|
|
166
|
+
outfile,
|
|
167
|
+
platform: esbuildPlatform(opts.target),
|
|
168
|
+
stdin: {
|
|
169
|
+
contents: source,
|
|
170
|
+
loader: "js",
|
|
171
|
+
resolveDir: projectRoot,
|
|
172
|
+
sourcefile: "jx-worker-entry.js",
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Bun.build has no stdin input — write a transient entry at the project root so resolution
|
|
179
|
+
// Matches the stdin/resolveDir behavior, and always remove it.
|
|
180
|
+
const entryPath = join(projectRoot, ".jx-worker-entry.mjs");
|
|
181
|
+
writeFileSync(entryPath, source, "utf8");
|
|
182
|
+
try {
|
|
183
|
+
await bundleEntry({ entryPath, outfile }, opts);
|
|
184
|
+
} finally {
|
|
185
|
+
rmSync(entryPath, { force: true });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -79,6 +79,7 @@ export async function resolvePrototypes(
|
|
|
79
79
|
config?: Record<string, unknown>;
|
|
80
80
|
sections?: Record<string, unknown>;
|
|
81
81
|
registry?: ExtensionRegistry;
|
|
82
|
+
registerBundle?: (specifier: string) => void;
|
|
82
83
|
} = {},
|
|
83
84
|
) {
|
|
84
85
|
const imports = doc.imports ?? {};
|
|
@@ -107,6 +108,20 @@ export async function resolvePrototypes(
|
|
|
107
108
|
root: projectRoot,
|
|
108
109
|
route,
|
|
109
110
|
});
|
|
111
|
+
// A lowered def may name client modules it depends on via `$bundle`
|
|
112
|
+
// (extensions.md §8.3): the site build bundles each specifier for the browser.
|
|
113
|
+
// The key is host metadata, not part of the core def shape — strip it.
|
|
114
|
+
if (lowered && typeof lowered === "object" && !Array.isArray(lowered)) {
|
|
115
|
+
const bundles = (lowered as JsonObject).$bundle;
|
|
116
|
+
if (Array.isArray(bundles)) {
|
|
117
|
+
for (const specifier of bundles) {
|
|
118
|
+
if (typeof specifier === "string") {
|
|
119
|
+
projectContext.registerBundle?.(specifier);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
delete (lowered as JsonObject).$bundle;
|
|
124
|
+
}
|
|
110
125
|
state[key] = lowered as JxStateDefinition;
|
|
111
126
|
} catch (error) {
|
|
112
127
|
console.warn(
|