@jxsuite/compiler 0.19.0 → 0.21.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.
@@ -9,9 +9,8 @@
9
9
  * @module content-loader
10
10
  */
11
11
 
12
- import { readFileSync } from "node:fs";
12
+ import { readFileSync, readdirSync, existsSync, globSync } from "node:fs";
13
13
  import { resolve, basename, extname } from "node:path";
14
- import { globSync } from "glob";
15
14
 
16
15
  // ─── CSV Parser (minimal, spec-compliant) ─────────────────────────────────────
17
16
 
@@ -20,10 +19,10 @@ import { globSync } from "glob";
20
19
  * with commas and newlines.
21
20
  *
22
21
  * @param {string} csv - Raw CSV text
23
- * @returns {Record<string, any>[]} Array of row objects
22
+ * @returns {Record<string, string>[]} Array of row objects
24
23
  */
25
24
  function parseCSV(csv) {
26
- /** @type {Record<string, any>[]} */
25
+ /** @type {Record<string, string>[]} */
27
26
  const rows = [];
28
27
  let current = "";
29
28
  let inQuotes = false;
@@ -82,7 +81,7 @@ function parseCSV(csv) {
82
81
  const headers = parseRow(lines[0]);
83
82
  for (let i = 1; i < lines.length; i++) {
84
83
  const fields = parseRow(lines[i]);
85
- /** @type {Record<string, any>} */
84
+ /** @type {Record<string, string>} */
86
85
  const obj = {};
87
86
  for (let j = 0; j < headers.length; j++) {
88
87
  obj[headers[j].trim()] = fields[j]?.trim() ?? "";
@@ -94,35 +93,35 @@ function parseCSV(csv) {
94
93
 
95
94
  // ─── Markdown loader ──────────────────────────────────────────────────────────
96
95
 
97
- /** @type {any} */
96
+ /**
97
+ * @type {{
98
+ * MarkdownFile: new (opts: Record<string, unknown>) => { resolve(): MarkdownFileResult };
99
+ * } | null}
100
+ */
98
101
  let _mdModule = null;
99
102
 
100
103
  /**
101
104
  * Lazily import @jxsuite/parser for Markdown support. This avoids hard dependency — only loads when
102
105
  * MD content types exist.
103
106
  *
104
- * @returns {Promise<any>}
107
+ * @returns {Promise<{
108
+ * MarkdownFile: new (opts: Record<string, unknown>) => { resolve(): MarkdownFileResult };
109
+ * }>}
105
110
  */
106
111
  async function getMarkdownModule() {
107
112
  if (!_mdModule) {
108
- _mdModule = await import("@jxsuite/parser");
113
+ _mdModule = /** @type {any} */ (await import("@jxsuite/parser"));
109
114
  }
110
- return _mdModule;
115
+ return /** @type {NonNullable<typeof _mdModule>} */ (_mdModule);
111
116
  }
112
117
 
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
118
  /**
120
119
  * Load a markdown file into a ContentEntry. If directiveOptions are provided, they control which
121
120
  * custom element directives are available in the markdown.
122
121
  *
123
122
  * @param {string} filePath - Absolute path to .md file
124
- * @param {any} [directiveOptions] - Options for the MarkdownDirective plugin
125
- * @returns {Promise<object>} ContentEntry shape
123
+ * @param {unknown} [directiveOptions] - Options for the MarkdownDirective plugin
124
+ * @returns {Promise<ContentLoaderEntry>} ContentEntry shape
126
125
  */
127
126
  async function loadMarkdownEntry(filePath, directiveOptions) {
128
127
  const { MarkdownFile } = await getMarkdownModule();
@@ -147,22 +146,24 @@ async function loadMarkdownEntry(filePath, directiveOptions) {
147
146
  * an object with an `id` field, it's a single entry.
148
147
  *
149
148
  * @param {string} filePath - Absolute path to .json file
150
- * @returns {object[]} Array of ContentEntry shapes
149
+ * @returns {ContentLoaderEntry[]} Array of ContentEntry shapes
151
150
  */
152
151
  function loadJSONEntries(filePath) {
153
152
  const raw = JSON.parse(readFileSync(filePath, "utf-8"));
154
153
  if (Array.isArray(raw)) {
155
- return raw.map((/** @type {any} */ item, /** @type {number} */ i) => ({
156
- id: item.id ?? basename(filePath, ".json") + "-" + i,
154
+ return raw.map((/** @type {Record<string, unknown>} */ item, /** @type {number} */ i) => ({
155
+ id: /** @type {string} */ (item.id) ?? basename(filePath, ".json") + "-" + i,
157
156
  data: item,
158
157
  body: null,
159
158
  }));
160
159
  }
161
160
  // Single object file — filename is the id
161
+ /** @type {Record<string, unknown>} */
162
+ const rawObj = raw;
162
163
  return [
163
164
  {
164
- id: raw.id ?? basename(filePath, ".json"),
165
- data: raw,
165
+ id: /** @type {string} */ (rawObj.id) ?? basename(filePath, ".json"),
166
+ data: rawObj,
166
167
  body: null,
167
168
  },
168
169
  ];
@@ -172,26 +173,29 @@ function loadJSONEntries(filePath) {
172
173
  * Load a CSV file into ContentEntry(s).
173
174
  *
174
175
  * @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
176
+ * @param {ContentTypeSchema} [schema] - Content type schema (for type coercion)
177
+ * @returns {ContentLoaderEntry[]} Array of ContentEntry shapes
177
178
  */
178
179
  function loadCSVEntries(filePath, schema) {
179
180
  const csv = readFileSync(filePath, "utf-8");
180
181
  const rows = parseCSV(csv);
181
- return rows.map((/** @type {Record<string, any>} */ row, /** @type {number} */ i) => {
182
+ return rows.map((/** @type {Record<string, string>} */ row, /** @type {number} */ i) => {
182
183
  // Apply type coercion based on schema if available
184
+ /** @type {Record<string, unknown>} */
185
+ const data = { ...row };
183
186
  if (schema?.properties) {
184
187
  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";
188
+ if (key in data) {
189
+ if (def.type === "number") data[key] = Number(data[key]);
190
+ else if (def.type === "boolean") data[key] = data[key] === "true";
189
191
  }
190
192
  }
191
193
  }
192
194
  // 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 };
195
+ const id = /** @type {string | undefined} */ (data.id) ??
196
+ /** @type {string | undefined} */ (data.sku) ??
197
+ String(i);
198
+ return { id, data, body: null };
195
199
  });
196
200
  }
197
201
 
@@ -201,14 +205,16 @@ function loadCSVEntries(filePath, schema) {
201
205
  * Load and parse content types config from project.json.
202
206
  *
203
207
  * @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
208
+ * @param {ProjectConfig} [projectConfig] - Already-loaded project config with `contentTypes` key
209
+ * @returns {{
210
+ * config: { contentTypes: Record<string, ContentTypeDef> };
211
+ * contentDir: string;
212
+ * } | null}
213
+ * Parsed config or null if no content dir
207
214
  */
208
215
  export function loadContentConfig(projectRoot, projectConfig = undefined) {
209
216
  const contentDir = resolve(projectRoot, "content");
210
217
 
211
- /** @type {any} */
212
218
  const config = { contentTypes: projectConfig?.contentTypes ?? {} };
213
219
 
214
220
  return { config, contentDir };
@@ -220,19 +226,20 @@ export function loadContentConfig(projectRoot, projectConfig = undefined) {
220
226
  * Load all content types defined in project.json.
221
227
  *
222
228
  * @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
229
+ * @param {ProjectConfig} [projectConfig] - Already-loaded project config
230
+ * @returns {Promise<Map<string, ContentLoaderEntry[]>>} Map of content type name → array of
231
+ * ContentEntry
225
232
  */
226
233
  export async function loadContentTypes(projectRoot, projectConfig = undefined) {
227
234
  const result = loadContentConfig(projectRoot, projectConfig);
228
235
  if (!result) return new Map();
229
236
 
230
237
  const { config } = result;
231
- /** @type {Map<string, any[]>} */
238
+ /** @type {Map<string, ContentLoaderEntry[]>} */
232
239
  const contentTypes = new Map();
233
240
 
234
241
  for (const [name, contentTypeDef] of Object.entries(config.contentTypes)) {
235
- const entries = await loadContentType(name, /** @type {any} */ (contentTypeDef), projectRoot);
242
+ const entries = await loadContentType(name, contentTypeDef, projectRoot);
236
243
  contentTypes.set(name, entries);
237
244
  }
238
245
 
@@ -244,8 +251,8 @@ export async function loadContentTypes(projectRoot, projectConfig = undefined) {
244
251
  *
245
252
  * @param {string} projectRoot - Project root directory
246
253
  * @param {string} contentTypeName - Name of the content type
247
- * @param {Record<string, any>} [projectConfig] - Already-loaded project config
248
- * @returns {any[] | undefined}
254
+ * @param {ProjectConfig} [projectConfig] - Already-loaded project config
255
+ * @returns {(string | JxElement)[] | undefined}
249
256
  */
250
257
  export function getContentTypeElements(projectRoot, contentTypeName, projectConfig = undefined) {
251
258
  const result = loadContentConfig(projectRoot, projectConfig);
@@ -258,29 +265,50 @@ export function getContentTypeElements(projectRoot, contentTypeName, projectConf
258
265
  * Load a single content type by its definition.
259
266
  *
260
267
  * @param {string} name - Content type name
261
- * @param {any} contentTypeDef - Content type definition from project.json
268
+ * @param {ContentTypeDef} contentTypeDef - Content type definition from project.json
262
269
  * @param {string} projectRoot - Absolute path to project root directory
263
- * @returns {Promise<any[]>} Array of ContentEntry
270
+ * @returns {Promise<ContentLoaderEntry[]>} Array of ContentEntry
264
271
  */
265
272
  async function loadContentType(name, contentTypeDef, projectRoot) {
266
273
  const source = contentTypeDef.source;
267
274
  const schema = contentTypeDef.schema;
268
275
 
269
276
  // Derive directive allowedNames from content type $elements (tag names from npm packages)
270
- /** @type {any} */
271
277
  const directiveOptions = contentTypeDef.$elements?.length
272
278
  ? {
273
279
  allowedNames: contentTypeDef.$elements
274
- .filter((/** @type {any} */ e) => typeof e === "string" || e?.$ref)
275
- .map((/** @type {any} */ e) => (typeof e === "string" ? e : e.$ref)),
280
+ .filter(
281
+ (e) =>
282
+ typeof e === "string" ||
283
+ (typeof e === "object" && /** @type {JxMutableNode} */ (e)?.$ref),
284
+ )
285
+ .map((e) => (typeof e === "string" ? e : /** @type {JxMutableNode} */ (e).$ref)),
276
286
  }
277
287
  : undefined;
278
288
 
279
- // Resolve the glob pattern relative to project root
280
- const pattern = resolve(projectRoot, source).split("\\").join("/");
281
- const files = globSync(pattern, { absolute: true });
289
+ // Resolve source path and discover files
290
+ const resolvedSource = resolve(projectRoot, source).split("\\").join("/");
291
+ /** @type {string[]} */
292
+ let files;
293
+
294
+ if (source.includes("*") || source.includes("?")) {
295
+ files = globSync(source, { cwd: projectRoot }).map((f) => resolve(projectRoot, f));
296
+ } else if (extname(source)) {
297
+ files = existsSync(resolvedSource) ? [resolvedSource] : [];
298
+ } else {
299
+ const format = contentTypeDef.format || "md";
300
+ const ext = `.${format}`;
301
+ const dir = resolvedSource.endsWith("/") ? resolvedSource : resolvedSource + "/";
302
+ try {
303
+ files = readdirSync(dir, { recursive: true })
304
+ .filter((f) => String(f).endsWith(ext))
305
+ .map((f) => resolve(dir, String(f)));
306
+ } catch {
307
+ files = [];
308
+ }
309
+ }
282
310
 
283
- /** @type {any[]} */
311
+ /** @type {ContentLoaderEntry[]} */
284
312
  const entries = [];
285
313
 
286
314
  for (const filePath of files) {
@@ -309,8 +337,8 @@ async function loadContentType(name, contentTypeDef, projectRoot) {
309
337
  * Validate content entries against their content type schema. Logs warnings for missing required
310
338
  * fields and type mismatches.
311
339
  *
312
- * @param {any[]} entries - Array of ContentEntry
313
- * @param {any} schema - JSON Schema for the content type
340
+ * @param {ContentLoaderEntry[]} entries - Array of ContentEntry
341
+ * @param {ContentTypeSchema} schema - JSON Schema for the content type
314
342
  * @param {string} contentTypeName - For error messages
315
343
  */
316
344
  function validateEntries(entries, schema, contentTypeName) {
@@ -329,23 +357,22 @@ function validateEntries(entries, schema, contentTypeName) {
329
357
 
330
358
  // Check types
331
359
  for (const [field, def] of Object.entries(properties)) {
332
- const d = /** @type {any} */ (def);
333
360
  const value = entry.data[field];
334
361
  if (value == null) continue;
335
362
 
336
- if (d.type === "string" && typeof value !== "string") {
363
+ if (def.type === "string" && typeof value !== "string") {
337
364
  console.warn(
338
365
  `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected string, got ${typeof value}`,
339
366
  );
340
- } else if (d.type === "number" && typeof value !== "number") {
367
+ } else if (def.type === "number" && typeof value !== "number") {
341
368
  console.warn(
342
369
  `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected number, got ${typeof value}`,
343
370
  );
344
- } else if (d.type === "boolean" && typeof value !== "boolean") {
371
+ } else if (def.type === "boolean" && typeof value !== "boolean") {
345
372
  console.warn(
346
373
  `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected boolean, got ${typeof value}`,
347
374
  );
348
- } else if (d.type === "array" && !Array.isArray(value)) {
375
+ } else if (def.type === "array" && !Array.isArray(value)) {
349
376
  console.warn(
350
377
  `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected array, got ${typeof value}`,
351
378
  );
@@ -360,18 +387,23 @@ function validateEntries(entries, schema, contentTypeName) {
360
387
  * Query a loaded content type with filter, sort, and limit. Implements the ContentCollection
361
388
  * $prototype resolution.
362
389
  *
363
- * @param {any[]} entries - Full content type entries
364
- * @param {any} [query] - Query options
365
- * @returns {any[]} Filtered, sorted, limited entries
390
+ * @param {ContentLoaderEntry[]} entries - Full content type entries
391
+ * @param {{
392
+ * filter?: Record<string, unknown>;
393
+ * sort?: { field: string; order?: string };
394
+ * limit?: number;
395
+ * }} [query]
396
+ * - Query options
397
+ * @returns {ContentLoaderEntry[]} Filtered, sorted, limited entries
366
398
  */
367
399
  export function queryContentType(entries, query = {}) {
368
400
  let result = [...entries];
369
401
 
370
402
  // Filter
371
403
  if (query.filter && typeof query.filter === "object") {
372
- result = result.filter((/** @type {any} */ entry) => {
404
+ result = result.filter((entry) => {
373
405
  for (const [key, expected] of Object.entries(
374
- /** @type {Record<string, any>} */ (query.filter),
406
+ /** @type {Record<string, unknown>} */ (query.filter),
375
407
  )) {
376
408
  const actual = entry.data[key];
377
409
  if (actual !== expected) return false;
@@ -383,9 +415,9 @@ export function queryContentType(entries, query = {}) {
383
415
  // Sort
384
416
  if (query.sort) {
385
417
  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] ?? "";
418
+ result.sort((a, b) => {
419
+ const aVal = /** @type {string | number} */ (a.data[field] ?? "");
420
+ const bVal = /** @type {string | number} */ (b.data[field] ?? "");
389
421
  if (aVal < bVal) return order === "asc" ? -1 : 1;
390
422
  if (aVal > bVal) return order === "asc" ? 1 : -1;
391
423
  return 0;
@@ -403,12 +435,12 @@ export function queryContentType(entries, query = {}) {
403
435
  /**
404
436
  * Find a single entry by ID in a content type. Implements the ContentEntry $prototype resolution.
405
437
  *
406
- * @param {any[]} entries - Full content type entries
438
+ * @param {ContentLoaderEntry[]} entries - Full content type entries
407
439
  * @param {string} id - Entry ID to find
408
- * @returns {any | null} The matching entry or null
440
+ * @returns {ContentLoaderEntry | null} The matching entry or null
409
441
  */
410
442
  export function findEntry(entries, id) {
411
- return entries.find((/** @type {any} */ e) => e.id === id) ?? null;
443
+ return entries.find((e) => e.id === id) ?? null;
412
444
  }
413
445
 
414
446
  // ─── Content Type Reference Resolution ──────────────────────────────────────
@@ -418,29 +450,27 @@ export function findEntry(entries, id) {
418
450
  * "jane-doe"` with a schema `$ref` to the authors content type gets resolved to the full author
419
451
  * entry.
420
452
  *
421
- * @param {Map<string, any[]>} contentTypes - All loaded content types @param {any} config -
422
- * Content.config.json
453
+ * @param {Map<string, ContentLoaderEntry[]>} contentTypes - All loaded content types @param {{
454
+ * contentTypes: Record<string, ContentTypeDef> }} config - Content config
423
455
  */
424
456
  export function resolveContentTypeRefs(contentTypes, config) {
425
457
  for (const [name, contentTypeDef] of Object.entries(config.contentTypes)) {
426
- const cd = /** @type {any} */ (contentTypeDef);
427
- const schema = cd.schema;
458
+ const schema = contentTypeDef.schema;
428
459
  if (!schema?.properties) continue;
429
460
 
430
461
  const entries = contentTypes.get(name);
431
462
  if (!entries) continue;
432
463
 
433
464
  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/", "");
465
+ if (!def.$ref?.startsWith("#/contentTypes/")) continue;
466
+ const refContentType = def.$ref.replace("#/contentTypes/", "");
437
467
  const refEntries = contentTypes.get(refContentType);
438
468
  if (!refEntries) continue;
439
469
 
440
470
  for (const entry of entries) {
441
471
  const refId = entry.data[field];
442
472
  if (typeof refId === "string") {
443
- const resolved = refEntries.find((/** @type {any} */ e) => e.id === refId);
473
+ const resolved = refEntries.find((e) => e.id === refId);
444
474
  if (resolved) {
445
475
  entry.data[field] = resolved;
446
476
  }
@@ -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