@jxsuite/compiler 0.28.2 → 0.28.3

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.
@@ -13,15 +13,16 @@
13
13
  * @module content-loader
14
14
  */
15
15
 
16
- import { readFileSync, readdirSync, existsSync } from "node:fs";
17
- import { resolve, basename, extname } from "node:path";
16
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
17
+ import { errorMessage } from "@jxsuite/schema/parse";
18
+ import { basename, extname, resolve } from "node:path";
18
19
  import { buildProjectFormatRegistry, unknownFormatError } from "./format-host.ts";
19
- import type { FormatRegistry, FormatEntry } from "@jxsuite/schema/format-registry";
20
+ import type { FormatEntry, FormatRegistry } from "@jxsuite/schema/format-registry";
20
21
  import type {
21
- ProjectConfig,
22
22
  ContentTypeDef,
23
23
  ContentTypeSchema,
24
24
  JxMutableNode,
25
+ ProjectConfig,
25
26
  } from "@jxsuite/schema/types";
26
27
  import type { ContentLoaderEntry } from "@jxsuite/parser/types";
27
28
 
@@ -35,21 +36,21 @@ import type { ContentLoaderEntry } from "@jxsuite/parser/types";
35
36
  * @returns {ContentLoaderEntry[]} Array of ContentEntry shapes
36
37
  */
37
38
  function loadJSONEntries(filePath: string) {
38
- const raw = JSON.parse(readFileSync(filePath, "utf-8"));
39
+ const raw = JSON.parse(readFileSync(filePath, "utf8"));
39
40
  if (Array.isArray(raw)) {
40
41
  return raw.map((item: Record<string, unknown>, i: number) => ({
41
- id: (item.id as string) ?? basename(filePath, ".json") + "-" + i,
42
- data: item,
43
42
  body: null,
43
+ data: item,
44
+ id: (item.id as string) ?? `${basename(filePath, ".json")}-${i}`,
44
45
  }));
45
46
  }
46
47
  // Single object file — filename is the id
47
48
  const rawObj: Record<string, unknown> = raw;
48
49
  return [
49
50
  {
50
- id: (rawObj.id as string) ?? basename(filePath, ".json"),
51
- data: rawObj,
52
51
  body: null,
52
+ data: rawObj,
53
+ id: (rawObj.id as string) ?? basename(filePath, ".json"),
53
54
  },
54
55
  ];
55
56
  }
@@ -106,11 +107,15 @@ export async function loadContentTypes(
106
107
  registry?: FormatRegistry,
107
108
  ) {
108
109
  const result = loadContentConfig(projectRoot, projectConfig);
109
- if (!result) return new Map();
110
+ if (!result) {
111
+ return new Map();
112
+ }
110
113
 
111
114
  const { config } = result;
112
- const contentTypes: Map<string, ContentLoaderEntry[]> = new Map();
113
- if (Object.keys(config.contentTypes).length === 0) return contentTypes;
115
+ const contentTypes = new Map<string, ContentLoaderEntry[]>();
116
+ if (Object.keys(config.contentTypes).length === 0) {
117
+ return contentTypes;
118
+ }
114
119
 
115
120
  const formats = registry ?? (await buildProjectFormatRegistry(projectRoot, projectConfig));
116
121
 
@@ -136,7 +141,9 @@ export function getContentTypeElements(
136
141
  projectConfig?: ProjectConfig,
137
142
  ) {
138
143
  const result = loadContentConfig(projectRoot, projectConfig);
139
- if (!result) return undefined;
144
+ if (!result) {
145
+ return;
146
+ }
140
147
  const def = result.config.contentTypes?.[contentTypeName];
141
148
  return def?.$elements;
142
149
  }
@@ -156,9 +163,11 @@ async function loadContentType(
156
163
  projectRoot: string,
157
164
  registry: FormatRegistry,
158
165
  ) {
159
- const source = contentTypeDef.source;
160
- if (!source) return [];
161
- const schema = contentTypeDef.schema;
166
+ const { source } = contentTypeDef;
167
+ if (!source) {
168
+ return [];
169
+ }
170
+ const { schema } = contentTypeDef;
162
171
 
163
172
  // Derive directive allowedNames from content type $elements (tag names from npm packages)
164
173
  const directiveOptions = contentTypeDef.$elements?.length
@@ -200,13 +209,15 @@ async function loadContentType(
200
209
  }
201
210
  try {
202
211
  const entries = (await entry.call("load", source, {
203
- schema,
204
212
  directiveOptions,
213
+ schema,
205
214
  })) as ContentLoaderEntry[];
206
- if (schema) validateEntries(entries, schema, name);
215
+ if (schema) {
216
+ validateEntries(entries, schema, name);
217
+ }
207
218
  return entries;
208
- } catch (e) {
209
- console.warn(`Content type "${name}": ${(e as Error).message}`);
219
+ } catch (error) {
220
+ console.warn(`Content type "${name}": ${errorMessage(error)}`);
210
221
  return [];
211
222
  }
212
223
  }
@@ -218,8 +229,12 @@ async function loadContentType(
218
229
  if (formatName === "json" || (!entry && ext === ".json")) {
219
230
  const files = discoverJSONFiles(resolvedSource);
220
231
  const entries: ContentLoaderEntry[] = [];
221
- for (const filePath of files) entries.push(...loadJSONEntries(filePath));
222
- if (schema) validateEntries(entries, schema, name);
232
+ for (const filePath of files) {
233
+ entries.push(...loadJSONEntries(filePath));
234
+ }
235
+ if (schema) {
236
+ validateEntries(entries, schema, name);
237
+ }
223
238
  return entries;
224
239
  }
225
240
 
@@ -245,8 +260,8 @@ async function loadContentType(
245
260
  for (const filePath of files) {
246
261
  entries.push(
247
262
  ...((await entry.call("load", filePath, {
248
- schema,
249
263
  directiveOptions,
264
+ schema,
250
265
  })) as ContentLoaderEntry[]),
251
266
  );
252
267
  }
@@ -290,7 +305,9 @@ function validateEntries(
290
305
  // Check types
291
306
  for (const [field, def] of Object.entries(properties)) {
292
307
  const value = entry.data[field];
293
- if (value == null) continue;
308
+ if (value == null) {
309
+ continue;
310
+ }
294
311
 
295
312
  if (def.type === "string" && typeof value !== "string") {
296
313
  console.warn(
@@ -332,17 +349,25 @@ export function resolveContentTypeRefs(
332
349
  config: { contentTypes: Record<string, ContentTypeDef> },
333
350
  ) {
334
351
  for (const [name, contentTypeDef] of Object.entries(config.contentTypes)) {
335
- const schema = contentTypeDef.schema;
336
- if (!schema?.properties) continue;
352
+ const { schema } = contentTypeDef;
353
+ if (!schema?.properties) {
354
+ continue;
355
+ }
337
356
 
338
357
  const entries = contentTypes.get(name);
339
- if (!entries) continue;
358
+ if (!entries) {
359
+ continue;
360
+ }
340
361
 
341
362
  for (const [field, def] of Object.entries(schema.properties)) {
342
- if (!def.$ref?.startsWith("#/contentTypes/")) continue;
363
+ if (!def.$ref?.startsWith("#/contentTypes/")) {
364
+ continue;
365
+ }
343
366
  const refContentType = def.$ref.replace("#/contentTypes/", "");
344
367
  const refEntries = contentTypes.get(refContentType);
345
- if (!refEntries) continue;
368
+ if (!refEntries) {
369
+ continue;
370
+ }
346
371
 
347
372
  for (const entry of entries) {
348
373
  const refId = entry.data[field];
@@ -12,10 +12,10 @@
12
12
  * title $page.params — dynamic route parameters (if any)
13
13
  */
14
14
 
15
- import { resolve, dirname, relative } from "node:path";
15
+ import { dirname, relative, resolve } from "node:path";
16
16
  import type {
17
- JxElement,
18
17
  JxDocument,
18
+ JxElement,
19
19
  JxStateDefinition,
20
20
  ProjectConfig,
21
21
  } from "@jxsuite/schema/types";
@@ -36,10 +36,12 @@ export function injectContext(
36
36
  doc: JxDocument,
37
37
  projectConfig: ProjectConfig,
38
38
  route: SiteRoute,
39
- contentTypes: Map<string, ContentLoaderEntry[]> = new Map(),
39
+ contentTypes = new Map<string, ContentLoaderEntry[]>(),
40
40
  projectRoot: string | null = null,
41
41
  ) {
42
- if (!doc.state) doc.state = {};
42
+ if (!doc.state) {
43
+ doc.state = {};
44
+ }
43
45
 
44
46
  // $site context — read-only project-level data
45
47
  doc.state.$site = {
@@ -50,9 +52,9 @@ export function injectContext(
50
52
 
51
53
  // $page context — read-only page-level data
52
54
  doc.state.$page = {
53
- url: route.urlPattern,
54
- title: doc.title ?? doc._pageTitle ?? projectConfig.name ?? "",
55
55
  params: route._pathParams ?? {},
56
+ title: doc.title ?? doc._pageTitle ?? projectConfig.name ?? "",
57
+ url: route.urlPattern,
56
58
  };
57
59
 
58
60
  // Merge project-level state into page state (page wins on conflicts)
@@ -71,14 +73,16 @@ export function injectContext(
71
73
 
72
74
  // Merge project-level imports into page imports (page wins on collision)
73
75
  if (projectConfig.imports && Object.keys(projectConfig.imports).length > 0) {
74
- if (!doc.imports) doc.imports = {};
76
+ if (!doc.imports) {
77
+ doc.imports = {};
78
+ }
75
79
  for (const [name, srcPath] of Object.entries(projectConfig.imports)) {
76
80
  if (!(name in doc.imports)) {
77
81
  const src = srcPath as string;
78
82
  // Only rebase relative paths — bare/npm specifiers pass through unmodified
79
83
  if (projectRoot && route.sourcePath && (src.startsWith("./") || src.startsWith("../"))) {
80
84
  const abs = resolve(projectRoot, src);
81
- doc.imports[name] = "./" + relative(dirname(route.sourcePath), abs);
85
+ doc.imports[name] = `./${relative(dirname(route.sourcePath), abs)}`;
82
86
  } else {
83
87
  doc.imports[name] = src;
84
88
  }
@@ -91,7 +95,7 @@ export function injectContext(
91
95
  if (!doc.$elements?.length) {
92
96
  doc.$elements = [...projectConfig.$elements];
93
97
  } else {
94
- const seen: Set<string> = new Set();
98
+ const seen = new Set<string>();
95
99
  const merged: (string | JxElement)[] = [];
96
100
  for (const entry of [
97
101
  ...projectConfig.$elements,
@@ -7,14 +7,11 @@
7
7
  */
8
8
 
9
9
  import { readFileSync } from "node:fs";
10
- import { dirname, resolve, isAbsolute, sep } from "node:path";
10
+ import { dirname, isAbsolute, resolve, sep } from "node:path";
11
11
  import { pathToFileURL } from "node:url";
12
12
  import { createRequire } from "node:module";
13
- import {
14
- buildFormatRegistry,
15
- type FormatRegistry,
16
- type FormatHostIO,
17
- } from "@jxsuite/schema/format-registry";
13
+ import { buildFormatRegistry } from "@jxsuite/schema/format-registry";
14
+ import type { FormatHostIO, FormatRegistry } from "@jxsuite/schema/format-registry";
18
15
  import type { ProjectConfig } from "@jxsuite/schema/types";
19
16
 
20
17
  export type { FormatRegistry, FormatEntry } from "@jxsuite/schema/format-registry";
@@ -32,32 +29,46 @@ export async function importImplementation(path: string): Promise<Record<string,
32
29
  const candidates = [path];
33
30
  const srcSeg = `${sep}src${sep}`;
34
31
  const distSeg = `${sep}dist${sep}`;
35
- if (path.includes(srcSeg)) candidates.push(path.replace(srcSeg, distSeg));
36
- if (path.endsWith(".js")) candidates.push(path.slice(0, -3) + ".ts");
32
+ if (path.includes(srcSeg)) {
33
+ candidates.push(path.replace(srcSeg, distSeg));
34
+ }
35
+ if (path.endsWith(".js")) {
36
+ candidates.push(`${path.slice(0, -3)}.ts`);
37
+ }
37
38
 
38
39
  let lastError: unknown;
39
40
  for (const candidate of candidates) {
40
41
  try {
41
42
  return await import(pathToFileURL(candidate).href);
42
- } catch (e) {
43
- lastError = e;
43
+ } catch (error) {
44
+ lastError = error;
44
45
  }
45
46
  }
46
- throw lastError;
47
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
47
48
  }
48
49
 
49
50
  /** Create a node FormatHostIO rooted at a project (bare specifiers resolve via node_modules). */
50
51
  export function createNodeFormatIO(projectRoot: string): FormatHostIO {
51
52
  return {
52
- loadJson: async (path) => JSON.parse(readFileSync(path, "utf-8")),
53
53
  importModule: (path) => importImplementation(path),
54
+ loadJson: async (path) => JSON.parse(readFileSync(path, "utf8")),
54
55
  resolvePath: (base, ref) => {
55
56
  if (ref.startsWith("./") || ref.startsWith("../")) {
56
57
  return resolve(dirname(base), ref);
57
58
  }
58
- if (isAbsolute(ref)) return ref;
59
- const require = createRequire(resolve(projectRoot, "package.json"));
60
- return require.resolve(ref);
59
+ if (isAbsolute(ref)) {
60
+ return ref;
61
+ }
62
+ // Bare specifier — prefer the project's own node_modules, then fall back to the host's
63
+ // Resolution (the desktop app and dev server ship @jxsuite/parser, so format classes work
64
+ // Even when the project's dependencies are not installed).
65
+ const projRequire = createRequire(resolve(projectRoot, "package.json"));
66
+ try {
67
+ return projRequire.resolve(ref);
68
+ } catch {
69
+ const selfRequire = createRequire(import.meta.url);
70
+ return selfRequire.resolve(ref);
71
+ }
61
72
  },
62
73
  };
63
74
  }
@@ -34,18 +34,18 @@ export function mergeHead(
34
34
  ) {
35
35
  // Start with auto-injected defaults
36
36
  const defaults: JxHeadEntry[] = [
37
- { tagName: "meta", attributes: { charset: context.charset ?? "utf-8" } },
37
+ { attributes: { charset: context.charset ?? "utf8" }, tagName: "meta" },
38
38
  {
39
- tagName: "meta",
40
39
  attributes: {
41
- name: "viewport",
42
40
  content: "width=device-width, initial-scale=1",
41
+ name: "viewport",
43
42
  },
43
+ tagName: "meta",
44
44
  },
45
45
  ];
46
46
 
47
47
  // Merge layers: site → layout → page (later wins)
48
- const merged: Map<string, JxHeadEntry> = new Map();
48
+ const merged = new Map<string, JxHeadEntry>();
49
49
 
50
50
  for (const entry of [...defaults, ...siteHead, ...layoutHead, ...pageHead]) {
51
51
  const key = headEntryKey(entry);
@@ -54,18 +54,18 @@ export function mergeHead(
54
54
 
55
55
  // Insert <title> if present
56
56
  const title = context.title ?? context.siteName ?? "Jx Site";
57
- merged.set("title", { tagName: "title", children: [title] });
57
+ merged.set("title", { children: [title], tagName: "title" });
58
58
 
59
59
  // Add canonical URL if provided
60
60
  if (context.pageUrl && context.siteUrl) {
61
61
  const canonical = new URL(context.pageUrl, context.siteUrl).href;
62
62
  merged.set("link:canonical", {
63
+ attributes: { href: canonical, rel: "canonical" },
63
64
  tagName: "link",
64
- attributes: { rel: "canonical", href: canonical },
65
65
  });
66
66
  }
67
67
 
68
- return Array.from(merged.values());
68
+ return [...merged.values()];
69
69
  }
70
70
 
71
71
  /**
@@ -76,22 +76,32 @@ export function mergeHead(
76
76
  * @returns {string}
77
77
  */
78
78
  function headEntryKey(entry: JxHeadEntry) {
79
- if (!entry || typeof entry !== "object") return String(entry);
79
+ if (!entry || typeof entry !== "object") {
80
+ return String(entry);
81
+ }
80
82
 
81
83
  const tag = entry.tagName ?? "unknown";
82
84
  const attrs = entry.attributes ?? {};
83
85
 
84
86
  // <title> — singleton
85
- if (tag === "title") return "title";
87
+ if (tag === "title") {
88
+ return "title";
89
+ }
86
90
 
87
91
  // <meta charset> — singleton
88
- if (attrs.charset) return "meta:charset";
92
+ if (attrs.charset) {
93
+ return "meta:charset";
94
+ }
89
95
 
90
96
  // <meta name="...""> — keyed by name
91
- if (tag === "meta" && attrs.name) return `meta:${attrs.name}`;
97
+ if (tag === "meta" && attrs.name) {
98
+ return `meta:${attrs.name}`;
99
+ }
92
100
 
93
101
  // <meta property="..."> — keyed by property (Open Graph)
94
- if (tag === "meta" && attrs.property) return `meta:${attrs.property}`;
102
+ if (tag === "meta" && attrs.property) {
103
+ return `meta:${attrs.property}`;
104
+ }
95
105
 
96
106
  // <link rel="..." href="..."> — keyed by rel+href
97
107
  if (tag === "link" && attrs.rel) {
@@ -99,7 +109,9 @@ function headEntryKey(entry: JxHeadEntry) {
99
109
  }
100
110
 
101
111
  // <script src="..."> — keyed by src
102
- if (tag === "script" && attrs.src) return `script:${attrs.src}`;
112
+ if (tag === "script" && attrs.src) {
113
+ return `script:${attrs.src}`;
114
+ }
103
115
 
104
116
  // <style> — unique per content hash
105
117
  if (tag === "style") {
@@ -122,7 +134,8 @@ function headEntryKey(entry: JxHeadEntry) {
122
134
  function simpleHash(str: string) {
123
135
  let hash = 0;
124
136
  for (let i = 0; i < str.length; i++) {
125
- hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
137
+ // oxlint-disable-next-line no-bitwise -- classic 31x string hash; the shift is the algorithm
138
+ hash = Math.trunc((hash << 5) - hash + (str.codePointAt(i) ?? 0));
126
139
  }
127
140
  return hash.toString(36);
128
141
  }
@@ -144,8 +157,12 @@ export function renderHead(headEntries: JxHeadEntry[]) {
144
157
  * @returns {string}
145
158
  */
146
159
  function renderHeadEntry(entry: JxHeadEntry) {
147
- if (typeof entry === "string") return entry;
148
- if (!entry || typeof entry !== "object") return "";
160
+ if (typeof entry === "string") {
161
+ return entry;
162
+ }
163
+ if (!entry || typeof entry !== "object") {
164
+ return "";
165
+ }
149
166
 
150
167
  const tag = entry.tagName;
151
168
  const attrs = entry.attributes ?? {};
@@ -157,7 +174,9 @@ function renderHeadEntry(entry: JxHeadEntry) {
157
174
 
158
175
  // Void elements (no closing tag)
159
176
  const VOID = new Set(["meta", "link", "base", "br", "hr", "img", "input"]);
160
- if (VOID.has(tag)) return open;
177
+ if (VOID.has(tag)) {
178
+ return open;
179
+ }
161
180
 
162
181
  // Elements with content
163
182
  const content = Array.isArray(entry.children)
@@ -171,5 +190,5 @@ function renderHeadEntry(entry: JxHeadEntry) {
171
190
  * @returns {string}
172
191
  */
173
192
  function escapeAttr(val: unknown) {
174
- return String(val).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
193
+ return String(val).replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
175
194
  }
@@ -6,14 +6,13 @@
6
6
  * CI environments (e.g. Cloudflare Pages) can persist it via their npm cache layer.
7
7
  */
8
8
 
9
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
10
- import { resolve, basename } from "node:path";
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { basename, resolve } from "node:path";
11
11
  import { execSync } from "node:child_process";
12
12
  import { tmpdir } from "node:os";
13
- import { contentHash, configHash } from "./image-optimizer.ts";
13
+ import { configHash, contentHash } from "./image-optimizer.ts";
14
14
 
15
- import type { ImageManifest } from "./image-optimizer.ts";
16
- import type { ImageConfig } from "./image-optimizer.ts";
15
+ import type { ImageConfig, ImageManifest } from "./image-optimizer.ts";
17
16
 
18
17
  interface CacheEntry {
19
18
  source: string; // Relative path to source image
@@ -28,7 +27,7 @@ export interface CacheManifest {
28
27
 
29
28
  // ─── Cache directory resolution ──────────────────────────────────────────────
30
29
 
31
- let _npmCacheBase: string | null | undefined = undefined; // undefined = not yet resolved
30
+ let _npmCacheBase: string | null | undefined; // Undefined = not yet resolved
32
31
 
33
32
  /** Reset the memoized npm cache base so the next call re-evaluates. Tests only. */
34
33
  export function _testResetNpmCacheBase() {
@@ -59,9 +58,9 @@ export function getImageCacheDir(projectRoot: string): string {
59
58
  if (_npmCacheBase === undefined) {
60
59
  try {
61
60
  const result = execSync("npm config get cache", {
62
- encoding: "utf-8",
61
+ cwd: tmpdir(), // Avoid ENOWORKSPACES when cwd is inside an npm workspace
62
+ encoding: "utf8",
63
63
  stdio: ["ignore", "pipe", "ignore"],
64
- cwd: tmpdir(), // avoid ENOWORKSPACES when cwd is inside an npm workspace
65
64
  }).trim();
66
65
  _npmCacheBase = result && result !== "undefined" ? result : null;
67
66
  } catch {
@@ -98,12 +97,12 @@ export function cacheKey(srcPath: string, config: ImageConfig) {
98
97
  export function loadCache(projectRoot: string) {
99
98
  const manifestPath = resolve(getImageCacheDir(projectRoot), "manifest.json");
100
99
  if (!existsSync(manifestPath)) {
101
- return { version: 1, entries: {} };
100
+ return { entries: {}, version: 1 };
102
101
  }
103
102
  try {
104
103
  return JSON.parse(readFileSync(manifestPath, "utf8"));
105
104
  } catch {
106
- return { version: 1, entries: {} };
105
+ return { entries: {}, version: 1 };
107
106
  }
108
107
  }
109
108
 
@@ -130,7 +129,9 @@ export function saveCache(projectRoot: string, cache: CacheManifest) {
130
129
  */
131
130
  export function getCached(cache: CacheManifest, key: string) {
132
131
  const entry = cache.entries[key];
133
- if (!entry) return null;
132
+ if (!entry) {
133
+ return null;
134
+ }
134
135
  return entry.manifest;
135
136
  }
136
137
 
@@ -149,8 +150,8 @@ export function setCached(
149
150
  manifest: ImageManifest,
150
151
  ) {
151
152
  cache.entries[key] = {
152
- source: sourcePath,
153
153
  manifest,
154
+ source: sourcePath,
154
155
  timestamp: Date.now(),
155
156
  };
156
157
  }
@@ -6,9 +6,11 @@
6
6
  */
7
7
 
8
8
  import { createHash } from "node:crypto";
9
- import { readFileSync, mkdirSync, existsSync } from "node:fs";
10
- import { resolve, basename, extname } from "node:path";
9
+ import { errorMessage } from "@jxsuite/schema/parse";
10
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
11
+ import { basename, extname, resolve } from "node:path";
11
12
  import { createRequire } from "node:module";
13
+ import type { FormatEnum } from "sharp";
12
14
 
13
15
  export interface ImageVariant {
14
16
  width: number; // Pixel width of the variant
@@ -39,30 +41,36 @@ export interface ImageConfig {
39
41
  remoteDomains?: string[];
40
42
  }
41
43
 
42
- let _sharp: typeof import("sharp") | null = null;
44
+ // oxlint-disable-next-line typescript/consistent-type-imports -- sharp is an optional native dep loaded lazily; its `export =` module type is only nameable via import()
45
+ type SharpModule = typeof import("sharp");
46
+
47
+ let _sharp: SharpModule | null = null;
43
48
 
44
49
  async function getSharp() {
45
- if (_sharp) return _sharp;
50
+ if (_sharp) {
51
+ return _sharp;
52
+ }
46
53
  // Primary: dynamic import — works in tests (mock.module intercepts it) and in
47
- // production installs where @img/sharp-* native packages are adjacent to cli.js.
54
+ // Production installs where @img/sharp-* native packages are adjacent to cli.js.
48
55
  try {
49
56
  const sharpMod = await import("sharp");
50
- _sharp = sharpMod.default as typeof import("sharp");
57
+ _sharp = sharpMod.default as SharpModule;
51
58
  return _sharp;
52
59
  } catch {
53
60
  // Fall through to CJS fallback below.
54
61
  }
55
62
  // Fallback: resolve from the project being compiled. This covers symlinked
56
- // monorepo dev environments (e.g. NixOS) where Node.js resolves import.meta.url
57
- // to the compiler package's real path, making the @img/sharp-* packages
58
- // unreachable via the primary import path.
63
+ // Monorepo dev environments (e.g. NixOS) where Node.js resolves import.meta.url
64
+ // To the compiler package's real path, making the @img/sharp-* packages
65
+ // Unreachable via the primary import path.
59
66
  try {
60
67
  const req = createRequire(resolve(process.cwd(), "package.json"));
61
- _sharp = req("sharp") as typeof import("sharp");
68
+ _sharp = req("sharp") as SharpModule;
62
69
  return _sharp;
63
- } catch (e) {
70
+ } catch (error) {
64
71
  throw new Error(
65
- `Sharp is required for image optimization but failed to load: ${(e as Error).message}`,
72
+ `Sharp is required for image optimization but failed to load: ${errorMessage(error)}`,
73
+ { cause: error },
66
74
  );
67
75
  }
68
76
  }
@@ -79,9 +87,9 @@ export async function getImageMetadata(srcPath: string) {
79
87
  const sharp = await getSharp();
80
88
  const meta = await sharp(srcPath).metadata();
81
89
  return {
82
- width: meta.width ?? 0,
83
- height: meta.height ?? 0,
84
90
  format: (meta.format ?? "unknown") as string,
91
+ height: meta.height ?? 0,
92
+ width: meta.width ?? 0,
85
93
  };
86
94
  }
87
95
 
@@ -104,9 +112,9 @@ export function contentHash(srcPath: string) {
104
112
  */
105
113
  export function configHash(config: ImageConfig) {
106
114
  const key = JSON.stringify({
107
- widths: config.widths,
108
115
  formats: config.formats,
109
116
  quality: config.quality,
117
+ widths: config.widths,
110
118
  });
111
119
  return createHash("md5").update(key).digest("hex").slice(0, 8);
112
120
  }
@@ -158,14 +166,16 @@ export async function processImage(srcPath: string, cacheImgDir: string, config:
158
166
  const outputPath = `/${OPTIMIZED_DIR}/${filename}`;
159
167
  const absolutePath = resolve(optimizedDir, filename);
160
168
 
161
- variants.push({ width, format, outputPath, absolutePath });
169
+ variants.push({ absolutePath, format, outputPath, width });
162
170
 
163
- if (existsSync(absolutePath)) continue;
171
+ if (existsSync(absolutePath)) {
172
+ continue;
173
+ }
164
174
 
165
175
  const quality = config.quality[format as keyof ImageConfig["quality"]] ?? 80;
166
176
  const task = sharp(srcPath)
167
177
  .resize(width)
168
- .toFormat(format as keyof import("sharp").FormatEnum, { quality })
178
+ .toFormat(format as keyof FormatEnum, { quality })
169
179
  .toFile(absolutePath)
170
180
  .then(() => {});
171
181
 
@@ -179,9 +189,9 @@ export async function processImage(srcPath: string, cacheImgDir: string, config:
179
189
  }
180
190
 
181
191
  return {
182
- original: { width: meta.width, height: meta.height, format: meta.format },
183
- variants,
184
192
  contentHash: hash8,
193
+ original: { format: meta.format, height: meta.height, width: meta.width },
194
+ variants,
185
195
  };
186
196
  }
187
197