@jxsuite/parser 2.0.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/dates.ts CHANGED
@@ -22,7 +22,8 @@
22
22
  * @docs framework/site/content-collections
23
23
  */
24
24
 
25
- import type { ContentTypeSchema } from "@jxsuite/schema/types";
25
+ import { isString } from "@jxsuite/schema/guards";
26
+ import type { ContentFieldValue, ContentTypeSchema } from "@jxsuite/schema/types";
26
27
  import type { ContentLoaderEntry } from "./types.ts";
27
28
 
28
29
  /** The two JSON Schema `format` values that carry build behavior. */
@@ -30,7 +31,7 @@ export const DATE_FORMATS = ["date", "date-time"] as const;
30
31
  export type DateFormat = (typeof DATE_FORMATS)[number];
31
32
 
32
33
  export function isDateFormat(format: unknown): format is DateFormat {
33
- return typeof format === "string" && (DATE_FORMATS as readonly string[]).includes(format);
34
+ return DATE_FORMATS.some((candidate) => candidate === format);
34
35
  }
35
36
 
36
37
  /** `YYYY-MM-DD`, with no time part. */
@@ -58,7 +59,10 @@ function toInstant(d: Date): string {
58
59
  * Accepts, in order: a `Date` (YAML frontmatter parsers routinely produce one for an unquoted
59
60
  * `date: 2025-03-04`), an RFC 3339 string, and a bare `YYYY-MM-DD`. Everything else is refused.
60
61
  */
61
- export function parseDateValue(value: unknown, format: DateFormat): string | null {
62
+ export function parseDateValue(
63
+ value: ContentFieldValue | undefined,
64
+ format: DateFormat,
65
+ ): string | null {
62
66
  if (value instanceof Date) {
63
67
  return Number.isNaN(value.getTime())
64
68
  ? null
@@ -66,7 +70,7 @@ export function parseDateValue(value: unknown, format: DateFormat): string | nul
66
70
  ? toDateOnly(value)
67
71
  : toInstant(value);
68
72
  }
69
- if (typeof value !== "string") {
73
+ if (!isString(value)) {
70
74
  return null;
71
75
  }
72
76
  const text = value.trim();
@@ -107,7 +111,7 @@ export function isCoercedDate(value: string): boolean {
107
111
  export interface DateCoercionWarning {
108
112
  entryId: string;
109
113
  field: string;
110
- value: unknown;
114
+ value: ContentFieldValue;
111
115
  message: string;
112
116
  }
113
117
 
@@ -128,9 +132,9 @@ export function coerceEntryDates(
128
132
  return warnings;
129
133
  }
130
134
 
131
- const dateFields: [string, DateFormat][] = Object.entries(properties)
132
- .map(([field, def]): [string, unknown] => [field, (def as { format?: unknown }).format])
133
- .filter((pair): pair is [string, DateFormat] => isDateFormat(pair[1]));
135
+ const dateFields = Object.entries(properties).flatMap(([field, def]): [string, DateFormat][] =>
136
+ isDateFormat(def.format) ? [[field, def.format]] : [],
137
+ );
134
138
  if (dateFields.length === 0) {
135
139
  return warnings;
136
140
  }
@@ -174,11 +178,12 @@ export function coerceEntryDates(
174
178
  * compares false against everything. Normalized RFC 3339 strings compare chronologically as text,
175
179
  * so the fix is to notice when both sides are dates and stay in string space.
176
180
  */
177
- export function parseComparable(a: unknown, b: unknown): [number, number] | [string, string] {
178
- const aDate = typeof a === "string" && (DATE_ONLY.test(a) || RFC_3339.test(a));
179
- const bDate = typeof b === "string" && (DATE_ONLY.test(b) || RFC_3339.test(b));
180
- if (aDate && bDate) {
181
- return [a as string, b as string];
181
+ export function parseComparable(
182
+ a: ContentFieldValue | undefined,
183
+ b: ContentFieldValue | undefined,
184
+ ): [number, number] | [string, string] {
185
+ if (isString(a) && isCoercedDate(a) && isString(b) && isCoercedDate(b)) {
186
+ return [a, b];
182
187
  }
183
188
  return [Number(a), Number(b)];
184
189
  }
package/src/highlight.ts CHANGED
@@ -39,6 +39,7 @@ import toml from "@shikijs/langs/toml";
39
39
  import typescript from "@shikijs/langs/typescript";
40
40
  import xml from "@shikijs/langs/xml";
41
41
  import yaml from "@shikijs/langs/yaml";
42
+ import { isNonNullObject, isString } from "@jxsuite/schema/guards";
42
43
  import type { JxElement } from "@jxsuite/schema/types";
43
44
  import type { HighlighterCore } from "shiki/core";
44
45
  import { caddyfile } from "./grammars/caddyfile.ts";
@@ -106,7 +107,7 @@ export function highlightFence(code: string, info: string): (JxElement | string)
106
107
  for (const token of line) {
107
108
  const span: JxElement = { tagName: "span", textContent: token.content };
108
109
  if (token.htmlStyle && Object.keys(token.htmlStyle).length > 0) {
109
- span.style = token.htmlStyle as Record<string, string>;
110
+ span.style = token.htmlStyle;
110
111
  }
111
112
  out.push(span);
112
113
  }
@@ -122,17 +123,17 @@ export function highlightFence(code: string, info: string): (JxElement | string)
122
123
  */
123
124
  export function highlightCodeBlocks(nodes: (JxElement | string)[]): void {
124
125
  for (const node of nodes) {
125
- if (!node || typeof node !== "object") {
126
+ if (!isNonNullObject(node)) {
126
127
  continue;
127
128
  }
128
129
  if (node.tagName === "pre" && Array.isArray(node.children)) {
129
130
  for (const child of node.children) {
130
- if (!child || typeof child !== "object" || child.tagName !== "code") {
131
+ if (!isNonNullObject(child) || child.tagName !== "code") {
131
132
  continue;
132
133
  }
133
134
  const lang = /^language-(\S+)/.exec(child.className ?? "")?.[1];
134
135
  const code = child.textContent;
135
- if (!lang || typeof code !== "string" || code.length === 0) {
136
+ if (!lang || !isString(code) || code.length === 0) {
136
137
  continue;
137
138
  }
138
139
  const spans = highlightFence(code, lang);
@@ -146,7 +147,7 @@ export function highlightCodeBlocks(nodes: (JxElement | string)[]): void {
146
147
  continue;
147
148
  }
148
149
  if (Array.isArray(node.children)) {
149
- highlightCodeBlocks(node.children as (JxElement | string)[]);
150
+ highlightCodeBlocks(node.children);
150
151
  }
151
152
  }
152
153
  }
package/src/inert.ts CHANGED
@@ -36,6 +36,7 @@
36
36
  * @docs framework/site/jx-markdown
37
37
  */
38
38
 
39
+ import { isNonNullObject, isString } from "@jxsuite/schema/guards";
39
40
  import type { JxElement } from "@jxsuite/schema/types";
40
41
 
41
42
  /**
@@ -68,7 +69,7 @@ export function neutralizeTemplateAttribute(value: string): string {
68
69
  */
69
70
  export function makeTemplatesInert(nodes: ContentNodes): void {
70
71
  for (const [i, node] of nodes.entries()) {
71
- if (typeof node === "string") {
72
+ if (isString(node)) {
72
73
  if (node.includes("${")) {
73
74
  nodes[i] = { innerHTML: escapeTemplateText(node), tagName: "span" };
74
75
  }
@@ -78,10 +79,11 @@ export function makeTemplatesInert(nodes: ContentNodes): void {
78
79
  makeTemplatesInert(node);
79
80
  continue;
80
81
  }
81
- if (typeof node !== "object" || node === null) {
82
+ // A hole in a hand-built tree (`null`) is skipped, not walked into.
83
+ if (!isNonNullObject(node)) {
82
84
  continue;
83
85
  }
84
- if (typeof node.textContent === "string" && node.textContent.includes("${")) {
86
+ if (isString(node.textContent) && node.textContent.includes("${")) {
85
87
  node.innerHTML = escapeTemplateText(node.textContent);
86
88
  delete node.textContent;
87
89
  }
@@ -89,13 +91,13 @@ export function makeTemplatesInert(nodes: ContentNodes): void {
89
91
  if (attributes) {
90
92
  for (const name of PROSE_ATTRIBUTES) {
91
93
  const value = attributes[name];
92
- if (typeof value === "string" && value.includes("${")) {
94
+ if (isString(value) && value.includes("${")) {
93
95
  attributes[name] = neutralizeTemplateAttribute(value);
94
96
  }
95
97
  }
96
98
  }
97
99
  if (Array.isArray(node.children)) {
98
- makeTemplatesInert(node.children as ContentNodes);
100
+ makeTemplatesInert(node.children);
99
101
  }
100
102
  }
101
103
  }
package/src/markdown.ts CHANGED
@@ -19,7 +19,7 @@ import { compileExclude } from "./content-rules.ts";
19
19
  import { transpileJxMarkdown } from "./transpile.ts";
20
20
  import { serializeJxMarkdown } from "./serialize.ts";
21
21
  import type { SerializeOptions } from "./serialize.ts";
22
- import type { JxDocument } from "@jxsuite/schema/types";
22
+ import type { JsonValue, JxDocument } from "@jxsuite/schema/types";
23
23
  import type { ContentLoaderEntry, MarkdownFileResult } from "./types.ts";
24
24
 
25
25
  export interface MarkdownLoadOptions {
@@ -37,9 +37,16 @@ export interface MarkdownLoadOptions {
37
37
  * The content type's `alerts` option: alert type → custom element, or `false` to leave GitHub
38
38
  * alerts (`> [!NOTE]`) as plain blockquotes. Absent renders the built-in callout markup.
39
39
  */
40
- alerts?: unknown;
40
+ alerts?: JsonValue;
41
41
  /** Told of each `[!type]` marker whose type is not enabled (it stays a blockquote). */
42
42
  onUnknownAlert?: (type: string) => void;
43
+ /**
44
+ * Read the frontmatter and leave the body unparsed: the entry comes back without `$children` and
45
+ * without the `_meta` fields read from the body (`excerpt`, `toc`, `readingTime`, `wordCount`).
46
+ * The content loader asks for this when a host loads a collection lazily, and loads the file
47
+ * again without it once the entry is actually returned (specs/extensions.md §8).
48
+ */
49
+ deferBody?: boolean;
43
50
  }
44
51
 
45
52
  /** Options for {@link Markdown.discover}. */
@@ -105,18 +112,9 @@ export class Markdown {
105
112
  path: string,
106
113
  options: MarkdownLoadOptions = {},
107
114
  ): Promise<ContentLoaderEntry[]> {
108
- const { processMarkdown } = await import("./md.ts");
115
+ const { processMarkdown, readFrontmatter } = await import("./md.ts");
109
116
  const { readFileSync, statSync } = await import("node:fs");
110
117
  const source = readFileSync(path, "utf8");
111
- const result = processMarkdown(source, path, {
112
- ...(options.directives !== undefined && { directives: options.directives }),
113
- ...(options.directiveOptions !== undefined && {
114
- directiveOptions: options.directiveOptions,
115
- }),
116
- ...(options.sourceRoot !== undefined && { sourceRoot: options.sourceRoot }),
117
- ...(options.alerts !== undefined && { alerts: options.alerts }),
118
- ...(options.onUnknownAlert !== undefined && { onUnknownAlert: options.onUnknownAlert }),
119
- });
120
118
  const _meta: ContentLoaderEntry["_meta"] = {};
121
119
  /*
122
120
  * The modification time is the only date a file always has. A feed falls back to it when the
@@ -130,18 +128,24 @@ export class Markdown {
130
128
  } catch {
131
129
  // A format class may be handed content that is not on disk; an absent mtime is not an error.
132
130
  }
133
- if (result.$excerpt != null) {
134
- _meta.excerpt = result.$excerpt;
135
- }
136
- if (result.$toc != null) {
137
- _meta.toc = result.$toc;
138
- }
139
- if (result.$readingTime != null) {
140
- _meta.readingTime = result.$readingTime;
141
- }
142
- if (result.$wordCount != null) {
143
- _meta.wordCount = result.$wordCount;
131
+ if (options.deferBody) {
132
+ const { frontmatter, slug } = readFrontmatter(source, path, options.sourceRoot);
133
+ return [{ _meta, body: source, data: frontmatter, id: slug }];
144
134
  }
135
+ const result = processMarkdown(source, path, {
136
+ ...(options.directives !== undefined && { directives: options.directives }),
137
+ ...(options.directiveOptions !== undefined && {
138
+ directiveOptions: options.directiveOptions,
139
+ }),
140
+ ...(options.sourceRoot !== undefined && { sourceRoot: options.sourceRoot }),
141
+ ...(options.alerts !== undefined && { alerts: options.alerts }),
142
+ ...(options.onUnknownAlert !== undefined && { onUnknownAlert: options.onUnknownAlert }),
143
+ });
144
+ // `processMarkdown` always derives all four, so every entry carries them.
145
+ _meta.excerpt = result.$excerpt;
146
+ _meta.toc = result.$toc;
147
+ _meta.readingTime = result.$readingTime;
148
+ _meta.wordCount = result.$wordCount;
145
149
  return [
146
150
  {
147
151
  $children: result.$children,
package/src/md.ts CHANGED
@@ -20,46 +20,42 @@ import remarkGfm from "remark-gfm";
20
20
  import remarkDirective from "remark-directive";
21
21
  import { readFileSync } from "node:fs";
22
22
  import { DEFAULT_FORMAT_LOCALE } from "@jxsuite/schema/intl";
23
- import { basename, extname, relative, resolve as resolvePath } from "node:path";
23
+ import { basename, extname, relative, resolve } from "node:path";
24
24
  import { globSync } from "glob";
25
25
  import { normalizeAlerts, transformAlerts } from "./alerts.ts";
26
26
  import { assignHeadingIds, mdastNodeToJx } from "./transpile.ts";
27
27
  import { highlightCodeBlocks } from "./highlight.ts";
28
28
  import { makeTemplatesInert } from "./inert.ts";
29
- import type { MarkdownFileResult, MdastNode, UnifiedProcessor } from "./types.ts";
30
- import type { JxElement } from "@jxsuite/schema/types";
29
+ import type {
30
+ MarkdownFileResult,
31
+ MdastNode,
32
+ UnifiedFile,
33
+ UnifiedPlugin,
34
+ UnifiedProcessor,
35
+ } from "./types.ts";
36
+ import { isFunction } from "@jxsuite/schema/guards";
37
+ import type { ContentFieldValue, JsonObject, JsonValue, JxElement } from "@jxsuite/schema/types";
31
38
 
32
39
  // ─── Tree utilities (inline to avoid Bun ESM resolution issues with unist-util-*) ──
33
40
 
34
41
  /**
35
- * Walk an AST tree, calling visitor for nodes matching the given type.
42
+ * Find the first node of a type in an AST tree, in document (depth-first, pre-order) order.
36
43
  *
37
- * @param {object} tree
38
- * @param {string | function} typeOrVisitor
39
- * @param {function} [maybeVisitor]
44
+ * @param {MdastNode} node
45
+ * @param {string} type
46
+ * @returns {MdastNode | null}
40
47
  */
41
- function visit(
42
- tree: MdastNode,
43
- typeOrVisitor: string | ((node: MdastNode) => void),
44
- maybeVisitor?: (node: MdastNode) => void,
45
- ) {
46
- const type = typeof typeOrVisitor === "string" ? typeOrVisitor : null;
47
- const visitor = type ? maybeVisitor : typeOrVisitor;
48
-
49
- function walk(node: MdastNode) {
50
- if (!node || typeof node !== "object") {
51
- return;
52
- }
53
- if (!type || node.type === type) {
54
- (visitor as (node: MdastNode) => void)(node);
55
- }
56
- if (Array.isArray(node.children)) {
57
- for (const child of node.children) {
58
- walk(child);
59
- }
48
+ function findFirst(node: MdastNode, type: string): MdastNode | null {
49
+ if (node.type === type) {
50
+ return node;
51
+ }
52
+ for (const child of node.children ?? []) {
53
+ const found = findFirst(child, type);
54
+ if (found) {
55
+ return found;
60
56
  }
61
57
  }
62
- walk(tree as MdastNode);
58
+ return null;
63
59
  }
64
60
 
65
61
  /**
@@ -69,12 +65,6 @@ function visit(
69
65
  * @returns {string}
70
66
  */
71
67
  function mdastToString(node: MdastNode): string {
72
- if (!node) {
73
- return "";
74
- }
75
- if (typeof node === "string") {
76
- return node;
77
- }
78
68
  if (node.value) {
79
69
  return node.value;
80
70
  }
@@ -86,6 +76,15 @@ function mdastToString(node: MdastNode): string {
86
76
 
87
77
  // ─── Helpers ──────────────────────────────────────────────────────────────────
88
78
 
79
+ /**
80
+ * The unified processor chain, through the hand-written surface `UnifiedProcessor` describes.
81
+ *
82
+ * SAFETY: `unified()` returns a remark-compatible Processor whose `use`, `parse`, `runSync` and
83
+ * `stringify` are the four members `UnifiedProcessor` declares; the assertion only states that
84
+ * surface for a type-aware linter that resolves the package's ESM-only types as `any`.
85
+ */
86
+ const createProcessor = unified as () => UnifiedProcessor;
87
+
89
88
  /** Average reading speed. See {@link readingTime} for why this is one number and not a table. */
90
89
  const WORDS_PER_MINUTE = 200;
91
90
 
@@ -151,12 +150,7 @@ function readingTime(text: string) {
151
150
  * @returns {string} Plain text of first paragraph, or empty string
152
151
  */
153
152
  function extractExcerpt(tree: MdastNode) {
154
- let firstParagraph: MdastNode | null = null;
155
- visit(tree, "paragraph", (node: MdastNode) => {
156
- if (!firstParagraph) {
157
- firstParagraph = node;
158
- }
159
- });
153
+ const firstParagraph = findFirst(tree, "paragraph");
160
154
  if (!firstParagraph) {
161
155
  return "";
162
156
  }
@@ -191,6 +185,64 @@ function deriveSlug(filePath: string, sourceRoot?: string): string {
191
185
  return basename(filePath, extname(filePath));
192
186
  }
193
187
 
188
+ /** A frontmatter fence line: `---`, optionally followed by spaces or tabs (micromark's rule). */
189
+ const FENCE_LINE = /^---[ \t]*\r?$/;
190
+
191
+ /**
192
+ * Where a leading frontmatter block ends: the index just past its closing fence line, or 0 when the
193
+ * source does not open with one. Only the cut is decided here; whether the block IS frontmatter is
194
+ * still decided by the same remark plugins {@link processMarkdown} runs.
195
+ */
196
+ function frontmatterEnd(source: string): number {
197
+ const firstBreak = source.indexOf("\n");
198
+ if (firstBreak === -1 || !FENCE_LINE.test(source.slice(0, firstBreak))) {
199
+ return 0;
200
+ }
201
+ let start = firstBreak + 1;
202
+ while (start < source.length) {
203
+ const lineEnd = source.indexOf("\n", start);
204
+ const end = lineEnd === -1 ? source.length : lineEnd;
205
+ if (FENCE_LINE.test(source.slice(start, end))) {
206
+ return end;
207
+ }
208
+ start = end + 1;
209
+ }
210
+ return 0;
211
+ }
212
+
213
+ /**
214
+ * The parsed frontmatter and slug of a markdown file, without parsing its body.
215
+ *
216
+ * A collection's queries, routes, ids and `where` rules read frontmatter alone, and the body is
217
+ * where the cost is: one knowledge base spent three of its four load seconds highlighting code
218
+ * blocks in articles no request had asked for. The block is parsed by the same plugins
219
+ * {@link processMarkdown} uses, cut off after its closing fence, so the two always agree on what the
220
+ * frontmatter is (specs/parser.md).
221
+ *
222
+ * @param {string} source - Raw markdown string
223
+ * @param {string} filePath - File path (for slug derivation)
224
+ * @param {string} [sourceRoot] - Content-source root; files below it get path-based slugs
225
+ * @returns {{ frontmatter: JsonObject; slug: string }}
226
+ */
227
+ export function readFrontmatter(source: string, filePath: string, sourceRoot?: string) {
228
+ const slug = deriveSlug(filePath, sourceRoot);
229
+ const end = frontmatterEnd(source);
230
+ if (end === 0) {
231
+ return { frontmatter: {}, slug };
232
+ }
233
+ /* SAFETY: as in processMarkdown below — the three remark packages are shorthand `declare module`
234
+ entries whose default exports are unified attachers taking optional JSON settings. */
235
+ const processor = createProcessor()
236
+ .use(remarkParse as UnifiedPlugin)
237
+ .use(remarkFrontmatter as UnifiedPlugin, ["yaml"])
238
+ .use(remarkParseFrontmatter as UnifiedPlugin);
239
+ const vfile: UnifiedFile = { data: {} };
240
+ processor.runSync(processor.parse(source.slice(0, end)), vfile);
241
+ /* SAFETY: the same claim processMarkdown makes of the same plugin's output: a mapping parses to
242
+ a JsonObject, and no block gives `{}`. */
243
+ return { frontmatter: (vfile.data.frontmatter ?? {}) as JsonObject, slug };
244
+ }
245
+
194
246
  /**
195
247
  * Process a single markdown source string into a MarkdownFileResult.
196
248
  *
@@ -215,14 +267,18 @@ export function processMarkdown(
215
267
  directives?: boolean;
216
268
  directiveOptions?: unknown;
217
269
  sourceRoot?: string;
218
- alerts?: unknown;
270
+ alerts?: JsonValue;
219
271
  onUnknownAlert?: (type: string) => void;
220
272
  } = {},
221
273
  ) {
222
- let processor = (unified as unknown as () => UnifiedProcessor)()
223
- .use(remarkParse)
224
- .use(remarkFrontmatter, ["yaml"])
225
- .use(remarkParseFrontmatter)
274
+ /* SAFETY: `remark-parse`, `remark-frontmatter` and `remark-parse-frontmatter` are shorthand
275
+ `declare module` entries in the root types.d.ts, so their default imports are `any` here. Each
276
+ package default-exports a unified attacher taking optional JSON settings (remark-frontmatter
277
+ receives the `["yaml"]` preset list), which is the UnifiedPlugin shape. */
278
+ let processor = createProcessor()
279
+ .use(remarkParse as UnifiedPlugin)
280
+ .use(remarkFrontmatter as UnifiedPlugin, ["yaml"])
281
+ .use(remarkParseFrontmatter as UnifiedPlugin)
226
282
  .use(remarkGfm);
227
283
 
228
284
  if (config.directives || config.directiveOptions) {
@@ -230,25 +286,34 @@ export function processMarkdown(
230
286
  }
231
287
 
232
288
  const tree = processor.parse(source);
233
- const vfile = { data: {} };
289
+ const vfile: UnifiedFile = { data: {} };
234
290
  processor.runSync(tree, vfile);
235
291
 
236
292
  // Callouts first, so the excerpt and word count read the alert's own text, not its `[!NOTE]` marker.
237
293
  const alerts = normalizeAlerts(config.alerts);
238
294
  if (alerts) {
239
- transformAlerts(tree as unknown as MdastNode, alerts, config.onUnknownAlert);
295
+ transformAlerts(tree, alerts, config.onUnknownAlert);
240
296
  }
241
297
 
242
- const vfileData = vfile.data as Record<string, unknown>;
243
- const frontmatter = (vfileData.frontmatter ?? {}) as Record<string, unknown>;
244
- const mdTree = tree as unknown as MdastNode;
245
- const plainText = mdastToString(mdTree);
298
+ /* SAFETY: a front-matter block is a YAML mapping by convention, which yaml.parse gives as a
299
+ JsonObject, and no block (or an empty one) gives `{}`. A block that parses to a scalar or a
300
+ list is not a mapping; it is relayed as the result's `frontmatter` unchanged, as it always has
301
+ been, so the claimed object type does not hold for that malformed input. */
302
+ const frontmatter = (vfile.data.frontmatter ?? {}) as JsonObject;
303
+ const plainText = mdastToString(tree);
246
304
  const excerpt = extractExcerpt(tree);
247
305
  const slug = deriveSlug(filePath, config.sourceRoot);
248
306
 
249
- const bodyNodes = tree.children!.filter(
307
+ const bodyNodes = (tree.children ?? []).filter(
250
308
  (n: MdastNode) => n.type !== "yaml" && n.type !== "toml",
251
- ) as MdastNode[];
309
+ );
310
+ /*
311
+ * SAFETY: `filter(Boolean)` drops the nulls (and empty text), and what remains is an element, a
312
+ * string, or the node array a raw HTML block converts to, kept nested as one element. `$children`
313
+ * is declared flat, and every walk over it handles that nested array: highlightCodeBlocks and
314
+ * assignHeadingIds skip it (it has no `tagName` or `children`), while makeTemplatesInert and the
315
+ * content loader's link and asset passes descend into it as `ContentNodes`.
316
+ */
252
317
  const $children = bodyNodes.map((n: MdastNode) => mdastNodeToJx(n)).filter(Boolean) as (
253
318
  | JxElement
254
319
  | string
@@ -280,14 +345,19 @@ export function processMarkdown(
280
345
  /**
281
346
  * Resolve a dot-notation path within an object.
282
347
  *
283
- * @param {Record<string, unknown> | MarkdownFileResult} obj
348
+ * @param {MarkdownFileResult} obj
284
349
  * @param {string} path
285
- * @returns {unknown}
350
+ * @returns {ContentFieldValue | MarkdownFileResult | undefined}
286
351
  */
287
- function getNestedValue(obj: Record<string, unknown> | MarkdownFileResult, path: string) {
288
- let current: unknown = obj;
352
+ function getNestedValue(obj: MarkdownFileResult, path: string) {
353
+ let current: ContentFieldValue | MarkdownFileResult | undefined = obj;
289
354
  for (const k of path.split(".")) {
290
- current = (current as Record<string, unknown> | undefined)?.[k];
355
+ /*
356
+ * SAFETY: `processMarkdown` builds a result out of strings, numbers, node arrays and the JSON
357
+ * frontmatter object, so every key a path steps through holds a content field value, or
358
+ * nothing, which `?.` carries to the end of the path.
359
+ */
360
+ current = (current as Record<string, ContentFieldValue | undefined> | null | undefined)?.[k];
291
361
  }
292
362
  return current;
293
363
  }
@@ -359,9 +429,10 @@ export class MarkdownCollection {
359
429
  ...processorConfig
360
430
  } = this.config;
361
431
 
362
- const resolved = basePath ? resolvePath(basePath, src) : src;
432
+ const resolved = basePath ? resolve(basePath, src) : src;
363
433
  // Normalize to forward slashes — glob requires POSIX paths on all platforms
364
434
  const pattern = resolved.split("\\").join("/");
435
+ // SAFETY: with `absolute: true` and no `withFileTypes`, `globSync` returns the matched paths as strings.
365
436
  const files = (globSync as (p: string, o: { absolute: boolean }) => string[])(pattern, {
366
437
  absolute: true,
367
438
  });
@@ -373,16 +444,14 @@ export class MarkdownCollection {
373
444
 
374
445
  // Filter
375
446
  let filtered = results;
376
- if (typeof filter === "function") {
377
- filtered = results.filter((r: MarkdownFileResult) =>
378
- (filter as (r: MarkdownFileResult) => boolean)(r),
379
- );
447
+ if (isFunction(filter)) {
448
+ filtered = results.filter((r: MarkdownFileResult) => filter(r));
380
449
  }
381
450
 
382
451
  // Sort
383
452
  filtered.sort((a: MarkdownFileResult, b: MarkdownFileResult) => {
384
- const aVal = getNestedValue(a, sortBy) ?? ("" as string | number);
385
- const bVal = getNestedValue(b, sortBy) ?? ("" as string | number);
453
+ const aVal = getNestedValue(a, sortBy) ?? "";
454
+ const bVal = getNestedValue(b, sortBy) ?? "";
386
455
  if (aVal < bVal) {
387
456
  return sortOrder === "asc" ? -1 : 1;
388
457
  }