@jxsuite/compiler 0.19.0 → 0.20.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.
@@ -20,10 +20,10 @@ import { globSync } from "glob";
20
20
  * with commas and newlines.
21
21
  *
22
22
  * @param {string} csv - Raw CSV text
23
- * @returns {Record<string, any>[]} Array of row objects
23
+ * @returns {Record<string, string>[]} Array of row objects
24
24
  */
25
25
  function parseCSV(csv) {
26
- /** @type {Record<string, any>[]} */
26
+ /** @type {Record<string, string>[]} */
27
27
  const rows = [];
28
28
  let current = "";
29
29
  let inQuotes = false;
@@ -82,7 +82,7 @@ function parseCSV(csv) {
82
82
  const headers = parseRow(lines[0]);
83
83
  for (let i = 1; i < lines.length; i++) {
84
84
  const fields = parseRow(lines[i]);
85
- /** @type {Record<string, any>} */
85
+ /** @type {Record<string, string>} */
86
86
  const obj = {};
87
87
  for (let j = 0; j < headers.length; j++) {
88
88
  obj[headers[j].trim()] = fields[j]?.trim() ?? "";
@@ -94,35 +94,35 @@ function parseCSV(csv) {
94
94
 
95
95
  // ─── Markdown loader ──────────────────────────────────────────────────────────
96
96
 
97
- /** @type {any} */
97
+ /**
98
+ * @type {{
99
+ * MarkdownFile: new (opts: Record<string, unknown>) => { resolve(): MarkdownFileResult };
100
+ * } | null}
101
+ */
98
102
  let _mdModule = null;
99
103
 
100
104
  /**
101
105
  * Lazily import @jxsuite/parser for Markdown support. This avoids hard dependency — only loads when
102
106
  * MD content types exist.
103
107
  *
104
- * @returns {Promise<any>}
108
+ * @returns {Promise<{
109
+ * MarkdownFile: new (opts: Record<string, unknown>) => { resolve(): MarkdownFileResult };
110
+ * }>}
105
111
  */
106
112
  async function getMarkdownModule() {
107
113
  if (!_mdModule) {
108
- _mdModule = await import("@jxsuite/parser");
114
+ _mdModule = /** @type {any} */ (await import("@jxsuite/parser"));
109
115
  }
110
- return _mdModule;
116
+ return /** @type {NonNullable<typeof _mdModule>} */ (_mdModule);
111
117
  }
112
118
 
113
- /**
114
- * Load a single markdown file into a ContentEntry.
115
- *
116
- * @param {string} filePath - Absolute path to .md file
117
- * @returns {Promise<object>} ContentEntry shape
118
- */
119
119
  /**
120
120
  * Load a markdown file into a ContentEntry. If directiveOptions are provided, they control which
121
121
  * custom element directives are available in the markdown.
122
122
  *
123
123
  * @param {string} filePath - Absolute path to .md file
124
- * @param {any} [directiveOptions] - Options for the MarkdownDirective plugin
125
- * @returns {Promise<object>} ContentEntry shape
124
+ * @param {unknown} [directiveOptions] - Options for the MarkdownDirective plugin
125
+ * @returns {Promise<ContentLoaderEntry>} ContentEntry shape
126
126
  */
127
127
  async function loadMarkdownEntry(filePath, directiveOptions) {
128
128
  const { MarkdownFile } = await getMarkdownModule();
@@ -147,22 +147,24 @@ async function loadMarkdownEntry(filePath, directiveOptions) {
147
147
  * an object with an `id` field, it's a single entry.
148
148
  *
149
149
  * @param {string} filePath - Absolute path to .json file
150
- * @returns {object[]} Array of ContentEntry shapes
150
+ * @returns {ContentLoaderEntry[]} Array of ContentEntry shapes
151
151
  */
152
152
  function loadJSONEntries(filePath) {
153
153
  const raw = JSON.parse(readFileSync(filePath, "utf-8"));
154
154
  if (Array.isArray(raw)) {
155
- return raw.map((/** @type {any} */ item, /** @type {number} */ i) => ({
156
- id: item.id ?? basename(filePath, ".json") + "-" + i,
155
+ return raw.map((/** @type {Record<string, unknown>} */ item, /** @type {number} */ i) => ({
156
+ id: /** @type {string} */ (item.id) ?? basename(filePath, ".json") + "-" + i,
157
157
  data: item,
158
158
  body: null,
159
159
  }));
160
160
  }
161
161
  // Single object file — filename is the id
162
+ /** @type {Record<string, unknown>} */
163
+ const rawObj = raw;
162
164
  return [
163
165
  {
164
- id: raw.id ?? basename(filePath, ".json"),
165
- data: raw,
166
+ id: /** @type {string} */ (rawObj.id) ?? basename(filePath, ".json"),
167
+ data: rawObj,
166
168
  body: null,
167
169
  },
168
170
  ];
@@ -172,26 +174,29 @@ function loadJSONEntries(filePath) {
172
174
  * Load a CSV file into ContentEntry(s).
173
175
  *
174
176
  * @param {string} filePath - Absolute path to .csv file
175
- * @param {any} [schema] - Content type schema (for type coercion)
176
- * @returns {object[]} Array of ContentEntry shapes
177
+ * @param {ContentTypeSchema} [schema] - Content type schema (for type coercion)
178
+ * @returns {ContentLoaderEntry[]} Array of ContentEntry shapes
177
179
  */
178
180
  function loadCSVEntries(filePath, schema) {
179
181
  const csv = readFileSync(filePath, "utf-8");
180
182
  const rows = parseCSV(csv);
181
- return rows.map((/** @type {Record<string, any>} */ row, /** @type {number} */ i) => {
183
+ return rows.map((/** @type {Record<string, string>} */ row, /** @type {number} */ i) => {
182
184
  // Apply type coercion based on schema if available
185
+ /** @type {Record<string, unknown>} */
186
+ const data = { ...row };
183
187
  if (schema?.properties) {
184
188
  for (const [key, def] of Object.entries(schema.properties)) {
185
- const d = /** @type {any} */ (def);
186
- if (key in row) {
187
- if (d.type === "number") row[key] = Number(row[key]);
188
- else if (d.type === "boolean") row[key] = row[key] === "true";
189
+ if (key in data) {
190
+ if (def.type === "number") data[key] = Number(data[key]);
191
+ else if (def.type === "boolean") data[key] = data[key] === "true";
189
192
  }
190
193
  }
191
194
  }
192
195
  // Use `id` column, `sku` column, or row index as the entry ID
193
- const id = row.id ?? row.sku ?? String(i);
194
- return { id, data: row, body: null };
196
+ const id = /** @type {string | undefined} */ (data.id) ??
197
+ /** @type {string | undefined} */ (data.sku) ??
198
+ String(i);
199
+ return { id, data, body: null };
195
200
  });
196
201
  }
197
202
 
@@ -201,14 +206,16 @@ function loadCSVEntries(filePath, schema) {
201
206
  * Load and parse content types config from project.json.
202
207
  *
203
208
  * @param {string} projectRoot - Project root directory
204
- * @param {Record<string, any>} [projectConfig] - Already-loaded project config with `contentTypes`
205
- * key
206
- * @returns {{ config: any; contentDir: string } | null} Parsed config or null if no content dir
209
+ * @param {ProjectConfig} [projectConfig] - Already-loaded project config with `contentTypes` key
210
+ * @returns {{
211
+ * config: { contentTypes: Record<string, ContentTypeDef> };
212
+ * contentDir: string;
213
+ * } | null}
214
+ * Parsed config or null if no content dir
207
215
  */
208
216
  export function loadContentConfig(projectRoot, projectConfig = undefined) {
209
217
  const contentDir = resolve(projectRoot, "content");
210
218
 
211
- /** @type {any} */
212
219
  const config = { contentTypes: projectConfig?.contentTypes ?? {} };
213
220
 
214
221
  return { config, contentDir };
@@ -220,19 +227,20 @@ export function loadContentConfig(projectRoot, projectConfig = undefined) {
220
227
  * Load all content types defined in project.json.
221
228
  *
222
229
  * @param {string} projectRoot - Project root directory
223
- * @param {Record<string, any>} [projectConfig] - Already-loaded project config
224
- * @returns {Promise<Map<string, any[]>>} Map of content type name → array of ContentEntry
230
+ * @param {ProjectConfig} [projectConfig] - Already-loaded project config
231
+ * @returns {Promise<Map<string, ContentLoaderEntry[]>>} Map of content type name → array of
232
+ * ContentEntry
225
233
  */
226
234
  export async function loadContentTypes(projectRoot, projectConfig = undefined) {
227
235
  const result = loadContentConfig(projectRoot, projectConfig);
228
236
  if (!result) return new Map();
229
237
 
230
238
  const { config } = result;
231
- /** @type {Map<string, any[]>} */
239
+ /** @type {Map<string, ContentLoaderEntry[]>} */
232
240
  const contentTypes = new Map();
233
241
 
234
242
  for (const [name, contentTypeDef] of Object.entries(config.contentTypes)) {
235
- const entries = await loadContentType(name, /** @type {any} */ (contentTypeDef), projectRoot);
243
+ const entries = await loadContentType(name, contentTypeDef, projectRoot);
236
244
  contentTypes.set(name, entries);
237
245
  }
238
246
 
@@ -244,8 +252,8 @@ export async function loadContentTypes(projectRoot, projectConfig = undefined) {
244
252
  *
245
253
  * @param {string} projectRoot - Project root directory
246
254
  * @param {string} contentTypeName - Name of the content type
247
- * @param {Record<string, any>} [projectConfig] - Already-loaded project config
248
- * @returns {any[] | undefined}
255
+ * @param {ProjectConfig} [projectConfig] - Already-loaded project config
256
+ * @returns {(string | JxElement)[] | undefined}
249
257
  */
250
258
  export function getContentTypeElements(projectRoot, contentTypeName, projectConfig = undefined) {
251
259
  const result = loadContentConfig(projectRoot, projectConfig);
@@ -258,21 +266,24 @@ export function getContentTypeElements(projectRoot, contentTypeName, projectConf
258
266
  * Load a single content type by its definition.
259
267
  *
260
268
  * @param {string} name - Content type name
261
- * @param {any} contentTypeDef - Content type definition from project.json
269
+ * @param {ContentTypeDef} contentTypeDef - Content type definition from project.json
262
270
  * @param {string} projectRoot - Absolute path to project root directory
263
- * @returns {Promise<any[]>} Array of ContentEntry
271
+ * @returns {Promise<ContentLoaderEntry[]>} Array of ContentEntry
264
272
  */
265
273
  async function loadContentType(name, contentTypeDef, projectRoot) {
266
274
  const source = contentTypeDef.source;
267
275
  const schema = contentTypeDef.schema;
268
276
 
269
277
  // Derive directive allowedNames from content type $elements (tag names from npm packages)
270
- /** @type {any} */
271
278
  const directiveOptions = contentTypeDef.$elements?.length
272
279
  ? {
273
280
  allowedNames: contentTypeDef.$elements
274
- .filter((/** @type {any} */ e) => typeof e === "string" || e?.$ref)
275
- .map((/** @type {any} */ e) => (typeof e === "string" ? e : e.$ref)),
281
+ .filter(
282
+ (e) =>
283
+ typeof e === "string" ||
284
+ (typeof e === "object" && /** @type {JxMutableNode} */ (e)?.$ref),
285
+ )
286
+ .map((e) => (typeof e === "string" ? e : /** @type {JxMutableNode} */ (e).$ref)),
276
287
  }
277
288
  : undefined;
278
289
 
@@ -280,7 +291,7 @@ async function loadContentType(name, contentTypeDef, projectRoot) {
280
291
  const pattern = resolve(projectRoot, source).split("\\").join("/");
281
292
  const files = globSync(pattern, { absolute: true });
282
293
 
283
- /** @type {any[]} */
294
+ /** @type {ContentLoaderEntry[]} */
284
295
  const entries = [];
285
296
 
286
297
  for (const filePath of files) {
@@ -309,8 +320,8 @@ async function loadContentType(name, contentTypeDef, projectRoot) {
309
320
  * Validate content entries against their content type schema. Logs warnings for missing required
310
321
  * fields and type mismatches.
311
322
  *
312
- * @param {any[]} entries - Array of ContentEntry
313
- * @param {any} schema - JSON Schema for the content type
323
+ * @param {ContentLoaderEntry[]} entries - Array of ContentEntry
324
+ * @param {ContentTypeSchema} schema - JSON Schema for the content type
314
325
  * @param {string} contentTypeName - For error messages
315
326
  */
316
327
  function validateEntries(entries, schema, contentTypeName) {
@@ -329,23 +340,22 @@ function validateEntries(entries, schema, contentTypeName) {
329
340
 
330
341
  // Check types
331
342
  for (const [field, def] of Object.entries(properties)) {
332
- const d = /** @type {any} */ (def);
333
343
  const value = entry.data[field];
334
344
  if (value == null) continue;
335
345
 
336
- if (d.type === "string" && typeof value !== "string") {
346
+ if (def.type === "string" && typeof value !== "string") {
337
347
  console.warn(
338
348
  `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected string, got ${typeof value}`,
339
349
  );
340
- } else if (d.type === "number" && typeof value !== "number") {
350
+ } else if (def.type === "number" && typeof value !== "number") {
341
351
  console.warn(
342
352
  `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected number, got ${typeof value}`,
343
353
  );
344
- } else if (d.type === "boolean" && typeof value !== "boolean") {
354
+ } else if (def.type === "boolean" && typeof value !== "boolean") {
345
355
  console.warn(
346
356
  `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected boolean, got ${typeof value}`,
347
357
  );
348
- } else if (d.type === "array" && !Array.isArray(value)) {
358
+ } else if (def.type === "array" && !Array.isArray(value)) {
349
359
  console.warn(
350
360
  `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected array, got ${typeof value}`,
351
361
  );
@@ -360,18 +370,23 @@ function validateEntries(entries, schema, contentTypeName) {
360
370
  * Query a loaded content type with filter, sort, and limit. Implements the ContentCollection
361
371
  * $prototype resolution.
362
372
  *
363
- * @param {any[]} entries - Full content type entries
364
- * @param {any} [query] - Query options
365
- * @returns {any[]} Filtered, sorted, limited entries
373
+ * @param {ContentLoaderEntry[]} entries - Full content type entries
374
+ * @param {{
375
+ * filter?: Record<string, unknown>;
376
+ * sort?: { field: string; order?: string };
377
+ * limit?: number;
378
+ * }} [query]
379
+ * - Query options
380
+ * @returns {ContentLoaderEntry[]} Filtered, sorted, limited entries
366
381
  */
367
382
  export function queryContentType(entries, query = {}) {
368
383
  let result = [...entries];
369
384
 
370
385
  // Filter
371
386
  if (query.filter && typeof query.filter === "object") {
372
- result = result.filter((/** @type {any} */ entry) => {
387
+ result = result.filter((entry) => {
373
388
  for (const [key, expected] of Object.entries(
374
- /** @type {Record<string, any>} */ (query.filter),
389
+ /** @type {Record<string, unknown>} */ (query.filter),
375
390
  )) {
376
391
  const actual = entry.data[key];
377
392
  if (actual !== expected) return false;
@@ -383,9 +398,9 @@ export function queryContentType(entries, query = {}) {
383
398
  // Sort
384
399
  if (query.sort) {
385
400
  const { field, order = "asc" } = query.sort;
386
- result.sort((/** @type {any} */ a, /** @type {any} */ b) => {
387
- const aVal = a.data[field] ?? "";
388
- const bVal = b.data[field] ?? "";
401
+ result.sort((a, b) => {
402
+ const aVal = /** @type {string | number} */ (a.data[field] ?? "");
403
+ const bVal = /** @type {string | number} */ (b.data[field] ?? "");
389
404
  if (aVal < bVal) return order === "asc" ? -1 : 1;
390
405
  if (aVal > bVal) return order === "asc" ? 1 : -1;
391
406
  return 0;
@@ -403,12 +418,12 @@ export function queryContentType(entries, query = {}) {
403
418
  /**
404
419
  * Find a single entry by ID in a content type. Implements the ContentEntry $prototype resolution.
405
420
  *
406
- * @param {any[]} entries - Full content type entries
421
+ * @param {ContentLoaderEntry[]} entries - Full content type entries
407
422
  * @param {string} id - Entry ID to find
408
- * @returns {any | null} The matching entry or null
423
+ * @returns {ContentLoaderEntry | null} The matching entry or null
409
424
  */
410
425
  export function findEntry(entries, id) {
411
- return entries.find((/** @type {any} */ e) => e.id === id) ?? null;
426
+ return entries.find((e) => e.id === id) ?? null;
412
427
  }
413
428
 
414
429
  // ─── Content Type Reference Resolution ──────────────────────────────────────
@@ -418,29 +433,27 @@ export function findEntry(entries, id) {
418
433
  * "jane-doe"` with a schema `$ref` to the authors content type gets resolved to the full author
419
434
  * entry.
420
435
  *
421
- * @param {Map<string, any[]>} contentTypes - All loaded content types @param {any} config -
422
- * Content.config.json
436
+ * @param {Map<string, ContentLoaderEntry[]>} contentTypes - All loaded content types @param {{
437
+ * contentTypes: Record<string, ContentTypeDef> }} config - Content config
423
438
  */
424
439
  export function resolveContentTypeRefs(contentTypes, config) {
425
440
  for (const [name, contentTypeDef] of Object.entries(config.contentTypes)) {
426
- const cd = /** @type {any} */ (contentTypeDef);
427
- const schema = cd.schema;
441
+ const schema = contentTypeDef.schema;
428
442
  if (!schema?.properties) continue;
429
443
 
430
444
  const entries = contentTypes.get(name);
431
445
  if (!entries) continue;
432
446
 
433
447
  for (const [field, def] of Object.entries(schema.properties)) {
434
- const d = /** @type {any} */ (def);
435
- if (!d.$ref?.startsWith("#/contentTypes/")) continue;
436
- const refContentType = d.$ref.replace("#/contentTypes/", "");
448
+ if (!def.$ref?.startsWith("#/contentTypes/")) continue;
449
+ const refContentType = def.$ref.replace("#/contentTypes/", "");
437
450
  const refEntries = contentTypes.get(refContentType);
438
451
  if (!refEntries) continue;
439
452
 
440
453
  for (const entry of entries) {
441
454
  const refId = entry.data[field];
442
455
  if (typeof refId === "string") {
443
- const resolved = refEntries.find((/** @type {any} */ e) => e.id === refId);
456
+ const resolved = refEntries.find((e) => e.id === refId);
444
457
  if (resolved) {
445
458
  entry.data[field] = resolved;
446
459
  }
@@ -18,12 +18,12 @@ import { resolve, dirname, relative } from "node:path";
18
18
  /**
19
19
  * Inject $site and $page context into a page document's state.
20
20
  *
21
- * @param {any} doc - The page document (mutated)
22
- * @param {any} projectConfig - Loaded project configuration
23
- * @param {any} route - The resolved route for this page
24
- * @param {Map<string, any[]>} [contentTypes] - Loaded content types
21
+ * @param {JxDocument} doc - The page document (mutated)
22
+ * @param {ProjectConfig} projectConfig - Loaded project configuration
23
+ * @param {SiteRoute} route - The resolved route for this page
24
+ * @param {Map<string, ContentLoaderEntry[]>} [contentTypes] - Loaded content types
25
25
  * @param {string | null} [projectRoot] - Absolute path to the project root (for import rebasing)
26
- * @returns {any} The mutated document
26
+ * @returns {JxDocument} The mutated document
27
27
  */
28
28
  export function injectContext(
29
29
  doc,
@@ -57,7 +57,7 @@ export function injectContext(
57
57
  if (projectConfig.state) {
58
58
  for (const [key, value] of Object.entries(projectConfig.state)) {
59
59
  if (key !== "$site" && key !== "$page" && !(key in doc.state)) {
60
- doc.state[key] = value;
60
+ doc.state[key] = /** @type {JxStateDefinition} */ (value);
61
61
  }
62
62
  }
63
63
  }
@@ -91,10 +91,14 @@ export function injectContext(
91
91
  } else {
92
92
  /** @type {Set<string>} */
93
93
  const seen = new Set();
94
- /** @type {any[]} */
94
+ /** @type {(string | JxElement)[]} */
95
95
  const merged = [];
96
- for (const entry of [...projectConfig.$elements, ...doc.$elements]) {
97
- const key = typeof entry === "string" ? entry : entry?.$ref;
96
+ for (const entry of [
97
+ ...projectConfig.$elements,
98
+ .../** @type {(string | JxElement)[]} */ (doc.$elements),
99
+ ]) {
100
+ const key =
101
+ typeof entry === "string" ? entry : /** @type {{ $ref?: string }} */ (entry)?.$ref;
98
102
  if (key && !seen.has(key)) {
99
103
  seen.add(key);
100
104
  merged.push(entry);
@@ -113,40 +117,42 @@ export function injectContext(
113
117
  * Replaces state entries like: { "$prototype": "ContentCollection", "contentType": "blog", ... }
114
118
  * with the actual resolved content type data.
115
119
  *
116
- * @param {Record<string, any>} state - Page state (mutated)
117
- * @param {Map<string, any[]>} contentTypes - Loaded content types
118
- * @param {Record<string, any>} params - Route parameters for $ref resolution
120
+ * @param {Record<string, unknown>} state - Page state (mutated)
121
+ * @param {Map<string, ContentLoaderEntry[]>} contentTypes - Loaded content types
122
+ * @param {Record<string, string>} params - Route parameters for $ref resolution
119
123
  */
120
124
  function resolveContentPrototypes(state, contentTypes, params) {
121
125
  for (const [key, value] of Object.entries(state)) {
122
- if (!value || typeof value !== "object" || !value.$prototype) continue;
126
+ if (!value || typeof value !== "object" || !(/** @type {JxMutableNode} */ (value).$prototype))
127
+ continue;
128
+ const v = /** @type {JxPrototypeDef} */ (value);
123
129
 
124
- if (value.$prototype === "ContentCollection") {
125
- const entries = contentTypes.get(value.contentType);
130
+ if (v.$prototype === "ContentCollection") {
131
+ const entries = contentTypes.get(/** @type {string} */ (v.contentType));
126
132
  if (!entries) {
127
- console.warn(`ContentCollection: content type "${value.contentType}" not found`);
133
+ console.warn(`ContentCollection: content type "${v.contentType}" not found`);
128
134
  state[key] = [];
129
135
  continue;
130
136
  }
131
137
  state[key] = queryContentType(entries, {
132
- filter: value.filter,
133
- sort: value.sort,
134
- limit: value.limit,
138
+ filter: v.filter,
139
+ sort: v.sort,
140
+ limit: v.limit,
135
141
  });
136
- } else if (value.$prototype === "ContentEntry") {
137
- const entries = contentTypes.get(value.contentType);
142
+ } else if (v.$prototype === "ContentEntry") {
143
+ const entries = contentTypes.get(/** @type {string} */ (v.contentType));
138
144
  if (!entries) {
139
- console.warn(`ContentEntry: content type "${value.contentType}" not found`);
145
+ console.warn(`ContentEntry: content type "${v.contentType}" not found`);
140
146
  state[key] = null;
141
147
  continue;
142
148
  }
143
149
  // Resolve the ID — may reference $params
144
- let id = value.id;
150
+ let id = v.id;
145
151
  if (id && typeof id === "object" && id.$ref?.startsWith("#/$params/")) {
146
152
  const paramName = id.$ref.replace("#/$params/", "");
147
153
  id = params[paramName];
148
154
  }
149
- state[key] = findEntry(entries, id);
155
+ state[key] = findEntry(entries, /** @type {string} */ (id));
150
156
  }
151
157
  }
152
158
  }
@@ -17,14 +17,15 @@
17
17
  /**
18
18
  * Merge $head arrays from site, layout, and page levels.
19
19
  *
20
- * @param {any[]} [siteHead] - Site.json $head entries
21
- * @param {any[]} [layoutHead] - Layout $head entries (may be empty)
22
- * @param {any[]} [pageHead] - Page $head entries (may be empty)
23
- * @param {any} [context] - { title, lang, charset, url, pageUrl }
24
- * @returns {any[]} Merged, deduplicated $head array
20
+ * @param {JxHeadEntry[]} [siteHead] - Site.json $head entries
21
+ * @param {JxHeadEntry[]} [layoutHead] - Layout $head entries (may be empty)
22
+ * @param {JxHeadEntry[]} [pageHead] - Page $head entries (may be empty)
23
+ * @param {HeadMergeContext} [context] - { title, lang, charset, url, pageUrl }
24
+ * @returns {JxHeadEntry[]} Merged, deduplicated $head array
25
25
  */
26
26
  export function mergeHead(siteHead = [], layoutHead = [], pageHead = [], context = {}) {
27
27
  // Start with auto-injected defaults
28
+ /** @type {JxHeadEntry[]} */
28
29
  const defaults = [
29
30
  { tagName: "meta", attributes: { charset: context.charset ?? "utf-8" } },
30
31
  {
@@ -34,7 +35,7 @@ export function mergeHead(siteHead = [], layoutHead = [], pageHead = [], context
34
35
  ];
35
36
 
36
37
  // Merge layers: site → layout → page (later wins)
37
- /** @type {Map<string, any>} */
38
+ /** @type {Map<string, JxHeadEntry>} */
38
39
  const merged = new Map();
39
40
 
40
41
  for (const entry of [...defaults, ...siteHead, ...layoutHead, ...pageHead]) {
@@ -62,7 +63,7 @@ export function mergeHead(siteHead = [], layoutHead = [], pageHead = [], context
62
63
  * Generate a deduplication key for a <head> element. Elements with the same key are considered
63
64
  * duplicates; the last one wins.
64
65
  *
65
- * @param {any} entry
66
+ * @param {JxHeadEntry} entry
66
67
  * @returns {string}
67
68
  */
68
69
  function headEntryKey(entry) {
@@ -118,17 +119,17 @@ function simpleHash(str) {
118
119
  /**
119
120
  * Render a merged $head array to HTML string for insertion into <head>.
120
121
  *
121
- * @param {any[]} headEntries - Merged head entries
122
+ * @param {JxHeadEntry[]} headEntries - Merged head entries
122
123
  * @returns {string} HTML string
123
124
  */
124
125
  export function renderHead(headEntries) {
125
- return headEntries.map((/** @type {any} */ e) => renderHeadEntry(e)).join("\n ");
126
+ return headEntries.map((/** @type {JxHeadEntry} */ e) => renderHeadEntry(e)).join("\n ");
126
127
  }
127
128
 
128
129
  /**
129
130
  * Render a single $head entry to an HTML string.
130
131
  *
131
- * @param {any} entry
132
+ * @param {JxHeadEntry} entry
132
133
  * @returns {string}
133
134
  */
134
135
  function renderHeadEntry(entry) {
@@ -138,7 +139,7 @@ function renderHeadEntry(entry) {
138
139
  const tag = entry.tagName;
139
140
  const attrs = entry.attributes ?? {};
140
141
  const attrStr = Object.entries(attrs)
141
- .map(([k, v]) => (v === true ? k : `${k}="${escapeAttr(/** @type {any} */ (v))}"`))
142
+ .map(([k, v]) => (v === true ? k : `${k}="${escapeAttr(v)}"`))
142
143
  .join(" ");
143
144
 
144
145
  const open = attrStr ? `<${tag} ${attrStr}>` : `<${tag}>`;
@@ -153,7 +154,7 @@ function renderHeadEntry(entry) {
153
154
  }
154
155
 
155
156
  /**
156
- * @param {any} val
157
+ * @param {unknown} val
157
158
  * @returns {string}
158
159
  */
159
160
  function escapeAttr(val) {
@@ -20,7 +20,7 @@ async function getSharp() {
20
20
  return _sharp;
21
21
  } catch (e) {
22
22
  throw new Error(
23
- `Sharp is required for image optimization but failed to load: ${/** @type {any} */ (e).message}`,
23
+ `Sharp is required for image optimization but failed to load: ${/** @type {Error} */ (e).message}`,
24
24
  );
25
25
  }
26
26
  }
@@ -152,7 +152,7 @@ export async function processImage(srcPath, outDir, config) {
152
152
  const quality = config.quality[/** @type {keyof ImageConfig["quality"]} */ (format)] ?? 80;
153
153
  const task = sharp(srcPath)
154
154
  .resize(width)
155
- .toFormat(/** @type {any} */ (format), { quality })
155
+ .toFormat(/** @type {keyof import("sharp").FormatEnum} */ (format), { quality })
156
156
  .toFile(absolutePath)
157
157
  .then(() => {});
158
158
 
@@ -48,7 +48,7 @@ async function resolveManifest(absoluteSrc, src, config, outDir, cache) {
48
48
  /**
49
49
  * Check if a src value should be skipped for optimization.
50
50
  *
51
- * @param {any} src
51
+ * @param {string} src
52
52
  * @returns {boolean}
53
53
  */
54
54
  function shouldSkip(src) {
@@ -81,7 +81,7 @@ function resolveImagePath(src, projectRoot) {
81
81
  * Mutates img nodes in place, injecting srcset, sizes, width, height, loading, and decoding.
82
82
  * Returns a set of absolute source paths that need processing.
83
83
  *
84
- * @param {any} doc - The Jx document tree (mutated in place)
84
+ * @param {JxMutableNode | JxDocument} doc - The Jx document tree (mutated in place)
85
85
  * @param {ImageConfig} config
86
86
  * @param {string} projectRoot
87
87
  * @param {string} outDir
@@ -100,7 +100,7 @@ export async function transformImageNodes(doc, config, projectRoot, outDir, cach
100
100
  }
101
101
 
102
102
  /**
103
- * @param {any} node
103
+ * @param {JxMutableNode | JxDocument} node
104
104
  * @param {ImageConfig} config
105
105
  * @param {string} projectRoot
106
106
  * @param {string} outDir
@@ -127,13 +127,14 @@ async function walkAndTransform(node, config, projectRoot, outDir, cache, imageR
127
127
 
128
128
  if (Array.isArray(node.children)) {
129
129
  for (const child of node.children) {
130
+ if (typeof child === "string") continue;
130
131
  await walkAndTransform(child, config, projectRoot, outDir, cache, imageRefs);
131
132
  }
132
133
  }
133
134
  }
134
135
 
135
136
  /**
136
- * @param {any} node
137
+ * @param {JxMutableNode | JxDocument} node
137
138
  * @param {ImageConfig} config
138
139
  * @param {string} projectRoot
139
140
  * @param {string} outDir