@jxsuite/compiler 0.23.2 → 0.24.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.
@@ -11,6 +11,13 @@
11
11
 
12
12
  import { readFileSync, readdirSync, existsSync } from "node:fs";
13
13
  import { resolve, basename, extname } from "node:path";
14
+ import type {
15
+ ProjectConfig,
16
+ ContentTypeDef,
17
+ ContentTypeSchema,
18
+ JxMutableNode,
19
+ } from "@jxsuite/schema/types";
20
+ import type { MarkdownFileResult, ContentLoaderEntry } from "@jxsuite/parser/types";
14
21
 
15
22
  // ─── CSV Parser (minimal, spec-compliant) ─────────────────────────────────────
16
23
 
@@ -21,13 +28,11 @@ import { resolve, basename, extname } from "node:path";
21
28
  * @param {string} csv - Raw CSV text
22
29
  * @returns {Record<string, string>[]} Array of row objects
23
30
  */
24
- function parseCSV(csv) {
25
- /** @type {Record<string, string>[]} */
26
- const rows = [];
31
+ function parseCSV(csv: string) {
32
+ const rows: Record<string, string>[] = [];
27
33
  let current = "";
28
34
  let inQuotes = false;
29
- /** @type {string[]} */
30
- const lines = [];
35
+ const lines: string[] = [];
31
36
 
32
37
  // Split into rows respecting quoted newlines (preserve raw characters)
33
38
  for (let i = 0; i < csv.length; i++) {
@@ -53,9 +58,8 @@ function parseCSV(csv) {
53
58
  if (lines.length === 0) return [];
54
59
 
55
60
  /** @param {string} line */
56
- const parseRow = (line) => {
57
- /** @type {string[]} */
58
- const fields = [];
61
+ const parseRow = (line: string) => {
62
+ const fields: string[] = [];
59
63
  let field = "";
60
64
  let q = false;
61
65
  for (let i = 0; i < line.length; i++) {
@@ -81,8 +85,7 @@ function parseCSV(csv) {
81
85
  const headers = parseRow(lines[0]);
82
86
  for (let i = 1; i < lines.length; i++) {
83
87
  const fields = parseRow(lines[i]);
84
- /** @type {Record<string, string>} */
85
- const obj = {};
88
+ const obj: Record<string, string> = {};
86
89
  for (let j = 0; j < headers.length; j++) {
87
90
  obj[headers[j].trim()] = fields[j]?.trim() ?? "";
88
91
  }
@@ -98,7 +101,9 @@ function parseCSV(csv) {
98
101
  * MarkdownFile: new (opts: Record<string, unknown>) => { resolve(): MarkdownFileResult };
99
102
  * } | null}
100
103
  */
101
- let _mdModule = null;
104
+ let _mdModule: {
105
+ MarkdownFile: new (opts: Record<string, unknown>) => { resolve(): MarkdownFileResult };
106
+ } | null = null;
102
107
 
103
108
  /**
104
109
  * Lazily import @jxsuite/parser for Markdown support. This avoids hard dependency — only loads when
@@ -110,9 +115,9 @@ let _mdModule = null;
110
115
  */
111
116
  async function getMarkdownModule() {
112
117
  if (!_mdModule) {
113
- _mdModule = /** @type {any} */ (await import("@jxsuite/parser"));
118
+ _mdModule = (await import("@jxsuite/parser")) as unknown as NonNullable<typeof _mdModule>;
114
119
  }
115
- return /** @type {NonNullable<typeof _mdModule>} */ (_mdModule);
120
+ return _mdModule as NonNullable<typeof _mdModule>;
116
121
  }
117
122
 
118
123
  /**
@@ -123,21 +128,21 @@ async function getMarkdownModule() {
123
128
  * @param {unknown} [directiveOptions] - Options for the MarkdownDirective plugin
124
129
  * @returns {Promise<ContentLoaderEntry>} ContentEntry shape
125
130
  */
126
- async function loadMarkdownEntry(filePath, directiveOptions) {
131
+ async function loadMarkdownEntry(filePath: string, directiveOptions?: unknown) {
127
132
  const { MarkdownFile } = await getMarkdownModule();
128
133
  const file = new MarkdownFile({ src: filePath, directiveOptions });
129
134
  const result = file.resolve();
135
+ const _meta: ContentLoaderEntry["_meta"] = {};
136
+ if (result.$excerpt != null) _meta.excerpt = result.$excerpt;
137
+ if (result.$toc != null) _meta.toc = result.$toc;
138
+ if (result.$readingTime != null) _meta.readingTime = result.$readingTime;
139
+ if (result.$wordCount != null) _meta.wordCount = result.$wordCount;
130
140
  return {
131
141
  id: result.slug,
132
142
  data: result.frontmatter,
133
143
  body: readFileSync(filePath, "utf-8"),
134
144
  $children: result.$children,
135
- _meta: {
136
- excerpt: result.$excerpt,
137
- toc: result.$toc,
138
- readingTime: result.$readingTime,
139
- wordCount: result.$wordCount,
140
- },
145
+ _meta,
141
146
  };
142
147
  }
143
148
 
@@ -148,21 +153,20 @@ async function loadMarkdownEntry(filePath, directiveOptions) {
148
153
  * @param {string} filePath - Absolute path to .json file
149
154
  * @returns {ContentLoaderEntry[]} Array of ContentEntry shapes
150
155
  */
151
- function loadJSONEntries(filePath) {
156
+ function loadJSONEntries(filePath: string) {
152
157
  const raw = JSON.parse(readFileSync(filePath, "utf-8"));
153
158
  if (Array.isArray(raw)) {
154
- return raw.map((/** @type {Record<string, unknown>} */ item, /** @type {number} */ i) => ({
155
- id: /** @type {string} */ (item.id) ?? basename(filePath, ".json") + "-" + i,
159
+ return raw.map((item: Record<string, unknown>, i: number) => ({
160
+ id: (item.id as string) ?? basename(filePath, ".json") + "-" + i,
156
161
  data: item,
157
162
  body: null,
158
163
  }));
159
164
  }
160
165
  // Single object file — filename is the id
161
- /** @type {Record<string, unknown>} */
162
- const rawObj = raw;
166
+ const rawObj: Record<string, unknown> = raw;
163
167
  return [
164
168
  {
165
- id: /** @type {string} */ (rawObj.id) ?? basename(filePath, ".json"),
169
+ id: (rawObj.id as string) ?? basename(filePath, ".json"),
166
170
  data: rawObj,
167
171
  body: null,
168
172
  },
@@ -176,13 +180,12 @@ function loadJSONEntries(filePath) {
176
180
  * @param {ContentTypeSchema} [schema] - Content type schema (for type coercion)
177
181
  * @returns {ContentLoaderEntry[]} Array of ContentEntry shapes
178
182
  */
179
- function loadCSVEntries(filePath, schema) {
183
+ function loadCSVEntries(filePath: string, schema?: ContentTypeSchema) {
180
184
  const csv = readFileSync(filePath, "utf-8");
181
185
  const rows = parseCSV(csv);
182
- return rows.map((/** @type {Record<string, string>} */ row, /** @type {number} */ i) => {
186
+ return rows.map((row: Record<string, string>, i: number) => {
183
187
  // Apply type coercion based on schema if available
184
- /** @type {Record<string, unknown>} */
185
- const data = { ...row };
188
+ const data: Record<string, unknown> = { ...row };
186
189
  if (schema?.properties) {
187
190
  for (const [key, def] of Object.entries(schema.properties)) {
188
191
  if (key in data) {
@@ -192,9 +195,7 @@ function loadCSVEntries(filePath, schema) {
192
195
  }
193
196
  }
194
197
  // Use `id` column, `sku` column, or row index as the entry ID
195
- const id = /** @type {string | undefined} */ (data.id) ??
196
- /** @type {string | undefined} */ (data.sku) ??
197
- String(i);
198
+ const id = (data.id as string | undefined) ?? (data.sku as string | undefined) ?? String(i);
198
199
  return { id, data, body: null };
199
200
  });
200
201
  }
@@ -212,7 +213,7 @@ function loadCSVEntries(filePath, schema) {
212
213
  * } | null}
213
214
  * Parsed config or null if no content dir
214
215
  */
215
- export function loadContentConfig(projectRoot, projectConfig = undefined) {
216
+ export function loadContentConfig(projectRoot: string, projectConfig?: ProjectConfig) {
216
217
  const contentDir = resolve(projectRoot, "content");
217
218
 
218
219
  const config = { contentTypes: projectConfig?.contentTypes ?? {} };
@@ -230,13 +231,12 @@ export function loadContentConfig(projectRoot, projectConfig = undefined) {
230
231
  * @returns {Promise<Map<string, ContentLoaderEntry[]>>} Map of content type name → array of
231
232
  * ContentEntry
232
233
  */
233
- export async function loadContentTypes(projectRoot, projectConfig = undefined) {
234
+ export async function loadContentTypes(projectRoot: string, projectConfig?: ProjectConfig) {
234
235
  const result = loadContentConfig(projectRoot, projectConfig);
235
236
  if (!result) return new Map();
236
237
 
237
238
  const { config } = result;
238
- /** @type {Map<string, ContentLoaderEntry[]>} */
239
- const contentTypes = new Map();
239
+ const contentTypes: Map<string, ContentLoaderEntry[]> = new Map();
240
240
 
241
241
  for (const [name, contentTypeDef] of Object.entries(config.contentTypes)) {
242
242
  const entries = await loadContentType(name, contentTypeDef, projectRoot);
@@ -254,7 +254,11 @@ export async function loadContentTypes(projectRoot, projectConfig = undefined) {
254
254
  * @param {ProjectConfig} [projectConfig] - Already-loaded project config
255
255
  * @returns {(string | JxElement)[] | undefined}
256
256
  */
257
- export function getContentTypeElements(projectRoot, contentTypeName, projectConfig = undefined) {
257
+ export function getContentTypeElements(
258
+ projectRoot: string,
259
+ contentTypeName: string,
260
+ projectConfig?: ProjectConfig,
261
+ ) {
258
262
  const result = loadContentConfig(projectRoot, projectConfig);
259
263
  if (!result) return undefined;
260
264
  const def = result.config.contentTypes?.[contentTypeName];
@@ -269,8 +273,9 @@ export function getContentTypeElements(projectRoot, contentTypeName, projectConf
269
273
  * @param {string} projectRoot - Absolute path to project root directory
270
274
  * @returns {Promise<ContentLoaderEntry[]>} Array of ContentEntry
271
275
  */
272
- async function loadContentType(name, contentTypeDef, projectRoot) {
276
+ async function loadContentType(name: string, contentTypeDef: ContentTypeDef, projectRoot: string) {
273
277
  const source = contentTypeDef.source;
278
+ if (!source) return [];
274
279
  const schema = contentTypeDef.schema;
275
280
 
276
281
  // Derive directive allowedNames from content type $elements (tag names from npm packages)
@@ -278,18 +283,16 @@ async function loadContentType(name, contentTypeDef, projectRoot) {
278
283
  ? {
279
284
  allowedNames: contentTypeDef.$elements
280
285
  .filter(
281
- (e) =>
282
- typeof e === "string" ||
283
- (typeof e === "object" && /** @type {JxMutableNode} */ (e)?.$ref),
286
+ (e) => typeof e === "string" || (typeof e === "object" && (e as JxMutableNode)?.$ref),
284
287
  )
285
- .map((e) => (typeof e === "string" ? e : /** @type {JxMutableNode} */ (e).$ref)),
288
+ .map((e) => (typeof e === "string" ? e : (e as JxMutableNode).$ref)),
286
289
  }
287
290
  : undefined;
288
291
 
289
292
  // Resolve source path and discover files
290
293
  const resolvedSource = resolve(projectRoot, source).split("\\").join("/");
291
294
  /** @type {string[]} */
292
- let files;
295
+ let files: string[];
293
296
 
294
297
  if (extname(source)) {
295
298
  files = existsSync(resolvedSource) ? [resolvedSource] : [];
@@ -306,8 +309,7 @@ async function loadContentType(name, contentTypeDef, projectRoot) {
306
309
  }
307
310
  }
308
311
 
309
- /** @type {ContentLoaderEntry[]} */
310
- const entries = [];
312
+ const entries: ContentLoaderEntry[] = [];
311
313
 
312
314
  for (const filePath of files) {
313
315
  const ext = extname(filePath).toLowerCase();
@@ -339,7 +341,11 @@ async function loadContentType(name, contentTypeDef, projectRoot) {
339
341
  * @param {ContentTypeSchema} schema - JSON Schema for the content type
340
342
  * @param {string} contentTypeName - For error messages
341
343
  */
342
- function validateEntries(entries, schema, contentTypeName) {
344
+ function validateEntries(
345
+ entries: ContentLoaderEntry[],
346
+ schema: ContentTypeSchema,
347
+ contentTypeName: string,
348
+ ) {
343
349
  const required = schema.required ?? [];
344
350
  const properties = schema.properties ?? {};
345
351
 
@@ -393,7 +399,10 @@ export { queryContentType, findEntry } from "@jxsuite/parser/content";
393
399
  * @param {Map<string, ContentLoaderEntry[]>} contentTypes - All loaded content types @param {{
394
400
  * contentTypes: Record<string, ContentTypeDef> }} config - Content config
395
401
  */
396
- export function resolveContentTypeRefs(contentTypes, config) {
402
+ export function resolveContentTypeRefs(
403
+ contentTypes: Map<string, ContentLoaderEntry[]>,
404
+ config: { contentTypes: Record<string, ContentTypeDef> },
405
+ ) {
397
406
  for (const [name, contentTypeDef] of Object.entries(config.contentTypes)) {
398
407
  const schema = contentTypeDef.schema;
399
408
  if (!schema?.properties) continue;
@@ -13,6 +13,14 @@
13
13
  */
14
14
 
15
15
  import { resolve, dirname, relative } from "node:path";
16
+ import type {
17
+ JxElement,
18
+ JxDocument,
19
+ JxStateDefinition,
20
+ ProjectConfig,
21
+ } from "@jxsuite/schema/types";
22
+ import type { ContentLoaderEntry } from "@jxsuite/parser/types";
23
+ import type { SiteRoute } from "../types.ts";
16
24
 
17
25
  /**
18
26
  * Inject $site and $page context into a page document's state.
@@ -25,11 +33,11 @@ import { resolve, dirname, relative } from "node:path";
25
33
  * @returns {JxDocument} The mutated document
26
34
  */
27
35
  export function injectContext(
28
- doc,
29
- projectConfig,
30
- route,
31
- contentTypes = new Map(),
32
- projectRoot = null,
36
+ doc: JxDocument,
37
+ projectConfig: ProjectConfig,
38
+ route: SiteRoute,
39
+ contentTypes: Map<string, ContentLoaderEntry[]> = new Map(),
40
+ projectRoot: string | null = null,
33
41
  ) {
34
42
  if (!doc.state) doc.state = {};
35
43
 
@@ -51,7 +59,7 @@ export function injectContext(
51
59
  if (projectConfig.state) {
52
60
  for (const [key, value] of Object.entries(projectConfig.state)) {
53
61
  if (key !== "$site" && key !== "$page" && !(key in doc.state)) {
54
- doc.state[key] = /** @type {JxStateDefinition} */ (value);
62
+ doc.state[key] = value as JxStateDefinition;
55
63
  }
56
64
  }
57
65
  }
@@ -66,7 +74,7 @@ export function injectContext(
66
74
  if (!doc.imports) doc.imports = {};
67
75
  for (const [name, srcPath] of Object.entries(projectConfig.imports)) {
68
76
  if (!(name in doc.imports)) {
69
- const src = /** @type {string} */ (srcPath);
77
+ const src = srcPath as string;
70
78
  // Only rebase relative paths — bare/npm specifiers pass through unmodified
71
79
  if (projectRoot && route.sourcePath && (src.startsWith("./") || src.startsWith("../"))) {
72
80
  const abs = resolve(projectRoot, src);
@@ -83,13 +91,11 @@ export function injectContext(
83
91
  if (!doc.$elements?.length) {
84
92
  doc.$elements = [...projectConfig.$elements];
85
93
  } else {
86
- /** @type {Set<string>} */
87
- const seen = new Set();
88
- /** @type {(string | JxElement)[]} */
89
- const merged = [];
94
+ const seen: Set<string> = new Set();
95
+ const merged: (string | JxElement)[] = [];
90
96
  for (const entry of [
91
97
  ...projectConfig.$elements,
92
- .../** @type {(string | JxElement)[]} */ (doc.$elements),
98
+ ...(doc.$elements as (string | JxElement)[]),
93
99
  ]) {
94
100
  const key =
95
101
  typeof entry === "string" ? entry : /** @type {{ $ref?: string }} */ (entry)?.$ref;
@@ -14,6 +14,9 @@
14
14
  * - Charset and viewport are auto-injected if missing
15
15
  */
16
16
 
17
+ import type { JxHeadEntry } from "@jxsuite/schema/types";
18
+ import type { HeadMergeContext } from "../types.ts";
19
+
17
20
  /**
18
21
  * Merge $head arrays from site, layout, and page levels.
19
22
  *
@@ -23,10 +26,14 @@
23
26
  * @param {HeadMergeContext} [context] - { title, lang, charset, url, pageUrl }
24
27
  * @returns {JxHeadEntry[]} Merged, deduplicated $head array
25
28
  */
26
- export function mergeHead(siteHead = [], layoutHead = [], pageHead = [], context = {}) {
29
+ export function mergeHead(
30
+ siteHead: JxHeadEntry[] = [],
31
+ layoutHead: JxHeadEntry[] = [],
32
+ pageHead: JxHeadEntry[] = [],
33
+ context: HeadMergeContext = {},
34
+ ) {
27
35
  // Start with auto-injected defaults
28
- /** @type {JxHeadEntry[]} */
29
- const defaults = [
36
+ const defaults: JxHeadEntry[] = [
30
37
  { tagName: "meta", attributes: { charset: context.charset ?? "utf-8" } },
31
38
  {
32
39
  tagName: "meta",
@@ -35,8 +42,7 @@ export function mergeHead(siteHead = [], layoutHead = [], pageHead = [], context
35
42
  ];
36
43
 
37
44
  // Merge layers: site → layout → page (later wins)
38
- /** @type {Map<string, JxHeadEntry>} */
39
- const merged = new Map();
45
+ const merged: Map<string, JxHeadEntry> = new Map();
40
46
 
41
47
  for (const entry of [...defaults, ...siteHead, ...layoutHead, ...pageHead]) {
42
48
  const key = headEntryKey(entry);
@@ -66,7 +72,7 @@ export function mergeHead(siteHead = [], layoutHead = [], pageHead = [], context
66
72
  * @param {JxHeadEntry} entry
67
73
  * @returns {string}
68
74
  */
69
- function headEntryKey(entry) {
75
+ function headEntryKey(entry: JxHeadEntry) {
70
76
  if (!entry || typeof entry !== "object") return String(entry);
71
77
 
72
78
  const tag = entry.tagName ?? "unknown";
@@ -110,7 +116,7 @@ function headEntryKey(entry) {
110
116
  * @param {string} str
111
117
  * @returns {string}
112
118
  */
113
- function simpleHash(str) {
119
+ function simpleHash(str: string) {
114
120
  let hash = 0;
115
121
  for (let i = 0; i < str.length; i++) {
116
122
  hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
@@ -124,8 +130,8 @@ function simpleHash(str) {
124
130
  * @param {JxHeadEntry[]} headEntries - Merged head entries
125
131
  * @returns {string} HTML string
126
132
  */
127
- export function renderHead(headEntries) {
128
- return headEntries.map((/** @type {JxHeadEntry} */ e) => renderHeadEntry(e)).join("\n ");
133
+ export function renderHead(headEntries: JxHeadEntry[]) {
134
+ return headEntries.map((e: JxHeadEntry) => renderHeadEntry(e)).join("\n ");
129
135
  }
130
136
 
131
137
  /**
@@ -134,7 +140,7 @@ export function renderHead(headEntries) {
134
140
  * @param {JxHeadEntry} entry
135
141
  * @returns {string}
136
142
  */
137
- function renderHeadEntry(entry) {
143
+ function renderHeadEntry(entry: JxHeadEntry) {
138
144
  if (typeof entry === "string") return entry;
139
145
  if (!entry || typeof entry !== "object") return "";
140
146
 
@@ -161,6 +167,6 @@ function renderHeadEntry(entry) {
161
167
  * @param {unknown} val
162
168
  * @returns {string}
163
169
  */
164
- function escapeAttr(val) {
170
+ function escapeAttr(val: unknown) {
165
171
  return String(val).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
166
172
  }
@@ -7,26 +7,21 @@
7
7
 
8
8
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
9
9
  import { resolve } from "node:path";
10
- import { contentHash, configHash } from "./image-optimizer.js";
10
+ import { contentHash, configHash } from "./image-optimizer.ts";
11
11
 
12
- /**
13
- * @typedef {import("./image-optimizer.js").ImageManifest} ImageManifest
14
- *
15
- * @typedef {import("./image-optimizer.js").ImageConfig} ImageConfig
16
- */
12
+ import type { ImageManifest } from "./image-optimizer.ts";
13
+ import type { ImageConfig } from "./image-optimizer.ts";
17
14
 
18
- /**
19
- * @typedef {Object} CacheEntry
20
- * @property {string} source - Relative path to source image
21
- * @property {ImageManifest} manifest - Processed image manifest
22
- * @property {number} timestamp - When the entry was cached
23
- */
15
+ interface CacheEntry {
16
+ source: string; // Relative path to source image
17
+ manifest: ImageManifest; // Processed image manifest
18
+ timestamp: number; // When the entry was cached
19
+ }
24
20
 
25
- /**
26
- * @typedef {Object} CacheManifest
27
- * @property {number} version - Cache format version
28
- * @property {Record<string, CacheEntry>} entries - Cached entries keyed by content+config hash
29
- */
21
+ export interface CacheManifest {
22
+ version: number; // Cache format version
23
+ entries: Record<string, CacheEntry>; // Cached entries keyed by content+config hash
24
+ }
30
25
 
31
26
  /**
32
27
  * Build a cache key from source file content and config.
@@ -35,7 +30,7 @@ import { contentHash, configHash } from "./image-optimizer.js";
35
30
  * @param {ImageConfig} config
36
31
  * @returns {string}
37
32
  */
38
- export function cacheKey(srcPath, config) {
33
+ export function cacheKey(srcPath: string, config: ImageConfig) {
39
34
  return `${contentHash(srcPath)}:${configHash(config)}`;
40
35
  }
41
36
 
@@ -45,7 +40,7 @@ export function cacheKey(srcPath, config) {
45
40
  * @param {string} projectRoot
46
41
  * @returns {CacheManifest}
47
42
  */
48
- export function loadCache(projectRoot) {
43
+ export function loadCache(projectRoot: string) {
49
44
  const manifestPath = resolve(projectRoot, ".jx-cache/images/manifest.json");
50
45
  if (!existsSync(manifestPath)) {
51
46
  return { version: 1, entries: {} };
@@ -63,7 +58,7 @@ export function loadCache(projectRoot) {
63
58
  * @param {string} projectRoot
64
59
  * @param {CacheManifest} cache
65
60
  */
66
- export function saveCache(projectRoot, cache) {
61
+ export function saveCache(projectRoot: string, cache: CacheManifest) {
67
62
  const cacheDir = resolve(projectRoot, ".jx-cache/images");
68
63
  mkdirSync(cacheDir, { recursive: true });
69
64
  writeFileSync(resolve(cacheDir, "manifest.json"), JSON.stringify(cache, null, 2), "utf8");
@@ -76,7 +71,7 @@ export function saveCache(projectRoot, cache) {
76
71
  * @param {string} key
77
72
  * @returns {ImageManifest | null}
78
73
  */
79
- export function getCached(cache, key) {
74
+ export function getCached(cache: CacheManifest, key: string) {
80
75
  const entry = cache.entries[key];
81
76
  if (!entry) return null;
82
77
  return entry.manifest;
@@ -90,7 +85,12 @@ export function getCached(cache, key) {
90
85
  * @param {string} sourcePath - Relative source path for reference
91
86
  * @param {ImageManifest} manifest
92
87
  */
93
- export function setCached(cache, key, sourcePath, manifest) {
88
+ export function setCached(
89
+ cache: CacheManifest,
90
+ key: string,
91
+ sourcePath: string,
92
+ manifest: ImageManifest,
93
+ ) {
94
94
  cache.entries[key] = {
95
95
  source: sourcePath,
96
96
  manifest,
@@ -9,8 +9,29 @@ import { createHash } from "node:crypto";
9
9
  import { readFileSync, mkdirSync, existsSync } from "node:fs";
10
10
  import { resolve, basename, extname } from "node:path";
11
11
 
12
- /** @type {typeof import("sharp") | null} */
13
- let _sharp = null;
12
+ export interface ImageVariant {
13
+ width: number; // Pixel width of the variant
14
+ format: string; // "webp", "avif", "jpeg", "png"
15
+ outputPath: string; // Relative path from outDir (e.g.
16
+ absolutePath: string; // Absolute filesystem path to the generated file
17
+ }
18
+
19
+ export interface ImageManifest {
20
+ variants: ImageVariant[]; // Array of generated responsive variants
21
+ contentHash: string; // 8-char content hash for cache busting
22
+ original?: { width: number; height: number }; // Original image dimensions
23
+ }
24
+
25
+ export interface ImageConfig {
26
+ optimize: boolean;
27
+ widths: number[];
28
+ formats: string[];
29
+ quality: { webp?: number; avif?: number; jpeg?: number; png?: number };
30
+ sizes: string;
31
+ lazyLoad: boolean;
32
+ }
33
+
34
+ let _sharp: typeof import("sharp") | null = null;
14
35
 
15
36
  async function getSharp() {
16
37
  if (_sharp) return _sharp;
@@ -20,39 +41,11 @@ async function getSharp() {
20
41
  return _sharp;
21
42
  } catch (e) {
22
43
  throw new Error(
23
- `Sharp is required for image optimization but failed to load: ${/** @type {Error} */ (e).message}`,
44
+ `Sharp is required for image optimization but failed to load: ${(e as Error).message}`,
24
45
  );
25
46
  }
26
47
  }
27
48
 
28
- /**
29
- * @typedef {Object} ImageVariant
30
- * @property {number} width - Pixel width of the variant
31
- * @property {string} format - "webp", "avif", "jpeg", "png"
32
- * @property {string} outputPath - Relative path from outDir (e.g.
33
- * "/images/_optimized/hero-640-a1b2c3d4.webp")
34
- * @property {string} absolutePath - Absolute filesystem path to the generated file
35
- */
36
-
37
- /**
38
- * @typedef {Object} ImageManifest
39
- * @property {{ width: number; height: number; format: string }} original - Original image
40
- * dimensions and format
41
- * @property {ImageVariant[]} variants - Array of generated responsive variants
42
- * @property {string} contentHash - 8-char content hash for cache busting
43
- */
44
-
45
- /**
46
- * @typedef {Object} ImageConfig
47
- * @property {boolean} optimize - Enable image optimization
48
- * @property {number[]} widths - Breakpoint widths to generate
49
- * @property {string[]} formats - Output formats ("webp", "avif", etc.)
50
- * @property {{ webp?: number; avif?: number; jpeg?: number; png?: number }} quality - Compression
51
- * quality per format
52
- * @property {string} sizes - CSS sizes attribute for srcset
53
- * @property {boolean} lazyLoad - Add loading="lazy" and decoding="async"
54
- */
55
-
56
49
  const OPTIMIZED_DIR = "images/_optimized";
57
50
 
58
51
  /**
@@ -61,13 +54,13 @@ const OPTIMIZED_DIR = "images/_optimized";
61
54
  * @param {string} srcPath - Absolute path to source image
62
55
  * @returns {Promise<{ width: number; height: number; format: string }>}
63
56
  */
64
- export async function getImageMetadata(srcPath) {
57
+ export async function getImageMetadata(srcPath: string) {
65
58
  const sharp = await getSharp();
66
59
  const meta = await sharp(srcPath).metadata();
67
60
  return {
68
61
  width: meta.width ?? 0,
69
62
  height: meta.height ?? 0,
70
- format: meta.format ?? "unknown",
63
+ format: (meta.format ?? "unknown") as string,
71
64
  };
72
65
  }
73
66
 
@@ -77,7 +70,7 @@ export async function getImageMetadata(srcPath) {
77
70
  * @param {string} srcPath - Absolute path to source image
78
71
  * @returns {string} 8-character hex hash
79
72
  */
80
- export function contentHash(srcPath) {
73
+ export function contentHash(srcPath: string) {
81
74
  const buf = readFileSync(srcPath);
82
75
  return createHash("md5").update(buf).digest("hex").slice(0, 8);
83
76
  }
@@ -88,7 +81,7 @@ export function contentHash(srcPath) {
88
81
  * @param {ImageConfig} config
89
82
  * @returns {string}
90
83
  */
91
- export function configHash(config) {
84
+ export function configHash(config: ImageConfig) {
92
85
  const key = JSON.stringify({
93
86
  widths: config.widths,
94
87
  formats: config.formats,
@@ -106,7 +99,7 @@ export function configHash(config) {
106
99
  * @param {string} format - "webp", "avif", "jpeg", "png"
107
100
  * @returns {string}
108
101
  */
109
- export function variantFilename(stem, width, hash8, format) {
102
+ export function variantFilename(stem: string, width: number, hash8: string, format: string) {
110
103
  return `${stem}-${width}-${hash8}.${format}`;
111
104
  }
112
105
 
@@ -118,7 +111,7 @@ export function variantFilename(stem, width, hash8, format) {
118
111
  * @param {ImageConfig} config
119
112
  * @returns {Promise<ImageManifest>}
120
113
  */
121
- export async function processImage(srcPath, outDir, config) {
114
+ export async function processImage(srcPath: string, outDir: string, config: ImageConfig) {
122
115
  const sharp = await getSharp();
123
116
  const meta = await getImageMetadata(srcPath);
124
117
  const hash8 = contentHash(srcPath);
@@ -127,8 +120,7 @@ export async function processImage(srcPath, outDir, config) {
127
120
  const optimizedDir = resolve(outDir, OPTIMIZED_DIR);
128
121
  mkdirSync(optimizedDir, { recursive: true });
129
122
 
130
- /** @type {ImageVariant[]} */
131
- const variants = [];
123
+ const variants: ImageVariant[] = [];
132
124
 
133
125
  const widths = config.widths.filter((w) => w <= meta.width);
134
126
  if (widths.length === 0 || !widths.includes(meta.width)) {
@@ -136,8 +128,7 @@ export async function processImage(srcPath, outDir, config) {
136
128
  }
137
129
  widths.sort((a, b) => a - b);
138
130
 
139
- /** @type {Promise<void>[]} */
140
- const tasks = [];
131
+ const tasks: Promise<void>[] = [];
141
132
 
142
133
  for (const width of widths) {
143
134
  for (const format of config.formats) {
@@ -149,10 +140,10 @@ export async function processImage(srcPath, outDir, config) {
149
140
 
150
141
  if (existsSync(absolutePath)) continue;
151
142
 
152
- const quality = config.quality[/** @type {keyof ImageConfig["quality"]} */ (format)] ?? 80;
143
+ const quality = config.quality[format as keyof ImageConfig["quality"]] ?? 80;
153
144
  const task = sharp(srcPath)
154
145
  .resize(width)
155
- .toFormat(/** @type {keyof import("sharp").FormatEnum} */ (format), { quality })
146
+ .toFormat(format as keyof import("sharp").FormatEnum, { quality })
156
147
  .toFile(absolutePath)
157
148
  .then(() => {});
158
149
 
@@ -179,7 +170,7 @@ export async function processImage(srcPath, outDir, config) {
179
170
  * @param {string} format
180
171
  * @returns {string}
181
172
  */
182
- export function buildSrcset(variants, format) {
173
+ export function buildSrcset(variants: ImageVariant[], format: string) {
183
174
  return variants
184
175
  .filter((v) => v.format === format)
185
176
  .map((v) => `${v.outputPath} ${v.width}w`)