@bliztek/mdx-utils 1.0.0 → 2.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/README.md CHANGED
@@ -88,15 +88,331 @@ Reads a directory and returns slugs (filenames without extension) of matching co
88
88
 
89
89
  Reads a file and returns its content as a UTF-8 string.
90
90
 
91
+ ## Collections
92
+
93
+ For content trees with more than a flat directory of posts — game guides, docs, multi-product changelogs — use `createMdxCollection` to get a typed handle that walks the tree, parses frontmatter, and (optionally) validates against a schema.
94
+
95
+ ### Flat blog
96
+
97
+ ```ts
98
+ import { createMdxCollection } from "@bliztek/mdx-utils/node";
99
+
100
+ const posts = createMdxCollection({ root: "content/blog" });
101
+
102
+ const all = await posts.getAll();
103
+ const post = await posts.get({ slug: "my-first-post" });
104
+ ```
105
+
106
+ ### Typed frontmatter
107
+
108
+ Pass a type parameter to make every getter return your shape:
109
+
110
+ ```ts
111
+ interface PostFrontmatter {
112
+ title: string;
113
+ publishedAt: string;
114
+ description?: string;
115
+ aliases?: string[];
116
+ relatedSlugs?: string[];
117
+ }
118
+
119
+ const posts = createMdxCollection<PostFrontmatter>({
120
+ root: "content/blog",
121
+ });
122
+
123
+ const all = await posts.getAll();
124
+ // all[0].metadata is typed as PostFrontmatter
125
+ ```
126
+
127
+ If you pair the generic with `frontmatterSchema`, the schema is the runtime enforcer and the generic is the static source of truth. They should agree — the package does not infer one from the other.
128
+
129
+ ### Nested namespaces
130
+
131
+ ```ts
132
+ // content/games/{game}/{slug}.mdx
133
+ const guides = createMdxCollection({
134
+ root: "content/games",
135
+ namespaceDepth: 1,
136
+ });
137
+
138
+ const arkGuides = await guides.getAllByNamespace("ark");
139
+ const specific = await guides.get({ namespace: "ark", slug: "boss-guide" });
140
+ ```
141
+
142
+ Files that sit at the wrong depth throw a clear error — there is no silent coercion.
143
+
144
+ ### Schema validation
145
+
146
+ The schema interface is structural: anything with a `parse(input): T` method works. No dependency on Zod, Valibot, or Yup.
147
+
148
+ ```ts
149
+ import { z } from "zod";
150
+
151
+ const schema = z.object({
152
+ title: z.string(),
153
+ publishedAt: z.string(),
154
+ aliases: z.array(z.string()).optional(),
155
+ });
156
+
157
+ const posts = createMdxCollection({
158
+ root: "content/blog",
159
+ frontmatterSchema: schema,
160
+ });
161
+
162
+ // In next.config.ts — fail the build if any file drifts:
163
+ await posts.validate();
164
+ ```
165
+
166
+ Or hand-rolled, no dependencies at all:
167
+
168
+ ```ts
169
+ const schema = {
170
+ parse(input: unknown) {
171
+ const obj = input as Record<string, unknown>;
172
+ if (typeof obj.title !== "string") throw new Error("title required");
173
+ return obj as { title: string };
174
+ },
175
+ };
176
+ ```
177
+
178
+ `validate()` aggregates every issue and throws `MdxValidationError` with a structured `.issues` array, so you can render your own output instead of parsing the message string. `getAll()` never throws on schema violations — it returns the raw frontmatter, cast to your declared type. Validation is an explicit build-time gate, not a runtime foot-gun.
179
+
180
+ Catching it in a build script:
181
+
182
+ ```ts
183
+ import { MdxValidationError } from "@bliztek/mdx-utils/node";
184
+
185
+ try {
186
+ await posts.validate();
187
+ } catch (err) {
188
+ if (err instanceof MdxValidationError) {
189
+ for (const issue of err.issues) {
190
+ console.error(`${issue.filePath} · ${issue.path}: ${issue.message}`);
191
+ }
192
+ process.exit(1);
193
+ }
194
+ throw err;
195
+ }
196
+ ```
197
+
198
+ ### Related entries
199
+
200
+ Use `resolveRelated` to turn a slug list on one entry into full entries from the same namespace:
201
+
202
+ ```ts
203
+ const post = await posts.get({ slug: "boss-guide" });
204
+ const related = await posts.resolveRelated(
205
+ post?.namespace ?? "",
206
+ post?.metadata.relatedSlugs ?? [],
207
+ );
208
+ // related: MdxEntry[] — unknown slugs skipped with a console.warn
209
+ ```
210
+
211
+ ### Redirects from renamed slugs
212
+
213
+ ```ts
214
+ // next.config.ts
215
+ import { collectRedirects } from "@bliztek/mdx-utils/node";
216
+
217
+ export default {
218
+ async redirects() {
219
+ return collectRedirects({
220
+ root: "content/games",
221
+ namespaceDepth: 1,
222
+ basePath: "/games/{namespace}/guides/{slug}",
223
+ });
224
+ },
225
+ };
226
+ ```
227
+
228
+ Any entry with an `aliases: [...]` field in its frontmatter gets one 301 per alias pointing at the current slug. Tokens `{namespace}` and `{slug}` are substituted from the entry's real location.
229
+
230
+ ### Composing the default frontmatter parser
231
+
232
+ The built-in parser handles a minimal YAML subset (quoted/unquoted scalars, inline arrays, block arrays). For the long tail, pass a `parseFrontmatter` override — or compose on top of the default:
233
+
234
+ ```ts
235
+ import {
236
+ createMdxCollection,
237
+ defaultParseFrontmatter,
238
+ } from "@bliztek/mdx-utils/node";
239
+
240
+ const posts = createMdxCollection({
241
+ root: "content/blog",
242
+ parseFrontmatter: (raw) => {
243
+ const base = defaultParseFrontmatter(raw);
244
+ // coerce numbers, parse dates, etc.
245
+ return base;
246
+ },
247
+ });
248
+ ```
249
+
250
+ Or replace it entirely with `gray-matter`:
251
+
252
+ ```ts
253
+ import matter from "gray-matter";
254
+ createMdxCollection({
255
+ root: "content/blog",
256
+ parseFrontmatter: (raw) => matter(raw).data,
257
+ });
258
+ ```
259
+
260
+ ### Caching
261
+
262
+ `createMdxCollection` reads and parses on the first `getAll()` and caches the result in process. Call `collection.invalidate()` to force a re-read — useful for dev-mode HMR. Production builds typically call each getter once so caching is a pure win.
263
+
264
+ ## Next.js App Router integration
265
+
266
+ End-to-end wiring for a static blog at `app/blog/[slug]/page.tsx`:
267
+
268
+ ```ts
269
+ // lib/posts.ts
270
+ import { createMdxCollection } from "@bliztek/mdx-utils/node";
271
+
272
+ export interface PostFrontmatter {
273
+ title: string;
274
+ description?: string;
275
+ publishedAt: string;
276
+ }
277
+
278
+ export const posts = createMdxCollection<PostFrontmatter>({
279
+ root: "content/blog",
280
+ });
281
+ ```
282
+
283
+ ```tsx
284
+ // app/blog/[slug]/page.tsx
285
+ import { notFound } from "next/navigation";
286
+ import { readMdxFile } from "@bliztek/mdx-utils/node";
287
+ import { posts } from "@/lib/posts";
288
+
289
+ export async function generateStaticParams() {
290
+ const all = await posts.getAll();
291
+ return all.map((p) => ({ slug: p.slug }));
292
+ }
293
+
294
+ export async function generateMetadata({
295
+ params,
296
+ }: {
297
+ params: Promise<{ slug: string }>;
298
+ }) {
299
+ const { slug } = await params;
300
+ const post = await posts.get({ slug });
301
+ if (!post) return {};
302
+ return {
303
+ title: post.metadata.title,
304
+ description: post.metadata.description,
305
+ };
306
+ }
307
+
308
+ export default async function Page({
309
+ params,
310
+ }: {
311
+ params: Promise<{ slug: string }>;
312
+ }) {
313
+ const { slug } = await params;
314
+ const post = await posts.get({ slug });
315
+ if (!post) notFound();
316
+
317
+ const raw = await readMdxFile(post.filePath);
318
+ // render `raw` with your MDX compiler of choice — see below
319
+ return (
320
+ <article>
321
+ <h1>{post.metadata.title}</h1>
322
+ <p>{post.readTime.minutes} min read</p>
323
+ {/* <MDXRemote source={raw} /> */}
324
+ </article>
325
+ );
326
+ }
327
+ ```
328
+
329
+ ### Rendering the MDX
330
+
331
+ This package deliberately does not compile MDX — that is the job of an MDX compiler, and you should pick the one that fits your runtime. Hand the `readMdxFile` output to:
332
+
333
+ - **[next-mdx-remote](https://github.com/hashicorp/next-mdx-remote)** — server-component-friendly, works inside App Router
334
+ - **[@mdx-js/mdx](https://mdxjs.com/packages/mdx/)** — lower-level, use when you want full control over the compile pipeline
335
+ - **[@next/mdx](https://nextjs.org/docs/app/building-your-application/configuring/mdx)** — if you want file-based MDX routing instead of a collection, though this conflicts with `createMdxCollection`'s indexing model
336
+
337
+ ```tsx
338
+ import { MDXRemote } from "next-mdx-remote/rsc";
339
+
340
+ const raw = await readMdxFile(post.filePath);
341
+ return <MDXRemote source={raw} />;
342
+ ```
343
+
344
+ ### Sitemap
345
+
346
+ ```ts
347
+ // app/sitemap.ts
348
+ import type { MetadataRoute } from "next";
349
+ import { posts } from "@/lib/posts";
350
+
351
+ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
352
+ const all = await posts.getAll();
353
+ return all.map((post) => ({
354
+ url: `https://example.com/blog/${post.slug}`,
355
+ lastModified: post.metadata.publishedAt,
356
+ }));
357
+ }
358
+ ```
359
+
360
+ ### API
361
+
362
+ #### `createMdxCollection(options)`
363
+
364
+ | Option | Type | Default | Description |
365
+ |---|---|---|---|
366
+ | `root` | `string` | required | Path to the content root |
367
+ | `namespaceDepth` | `number` | `0` | Directory segments between root and file to treat as namespace |
368
+ | `frontmatterSchema` | `{ parse(input): T }` | — | Optional structural validator |
369
+ | `parseFrontmatter` | `(raw) => object` | `defaultParseFrontmatter` | Frontmatter parser override |
370
+ | `wordsPerMinute` | `number` | `238` | Override for read-time calculation |
371
+
372
+ Returns `{ getAll, getAllByNamespace, get, resolveRelated, validate, invalidate }`.
373
+
374
+ #### `collectRedirects(options)`
375
+
376
+ | Option | Type | Default | Description |
377
+ |---|---|---|---|
378
+ | `root` | `string` | required | Path to the content root |
379
+ | `namespaceDepth` | `number` | `0` | Same meaning as above |
380
+ | `basePath` | `string` | required | URL template with `{namespace}`/`{slug}` tokens |
381
+ | `permanent` | `boolean` | `true` | `true` → 301, `false` → 302 |
382
+ | `aliasField` | `string` | `"aliases"` | Frontmatter field to read aliases from |
383
+
384
+ Returns `NextRedirect[]` — compatible with Next.js `redirects()` config.
385
+
386
+ ### Well-known frontmatter fields
387
+
388
+ The package treats these fields as well-known when present, but never requires them unless your schema does:
389
+
390
+ | Field | Type | Purpose |
391
+ |---|---|---|
392
+ | `title` | `string` | Display title |
393
+ | `description` | `string` | Excerpt / meta description |
394
+ | `publishedAt` | `string` (ISO) | Used for sort order |
395
+ | `updatedAt` | `string` (ISO) | For JSON-LD `dateModified` |
396
+ | `aliases` | `string[]` | Consumed by `collectRedirects` |
397
+ | `relatedSlugs` | `string[]` | Consumed by `resolveRelated` |
398
+
399
+ All other fields are passed through untouched.
400
+
91
401
  ## Types
92
402
 
93
403
  ```ts
94
- type TableOfContentsEntry = { title: string; link: string };
404
+ type TableOfContentsEntry = {
405
+ level: number;
406
+ text: string;
407
+ slug: string;
408
+ children?: TableOfContentsEntry[];
409
+ };
95
410
  type TableOfContents = TableOfContentsEntry[];
96
411
 
97
412
  interface ReadTimeOptions { wordsPerMinute?: number }
98
413
  interface ReadTimeResult { minutes: number; words: number }
99
414
  interface GetContentSlugsOptions { extensions?: string[]; recursive?: boolean }
415
+ interface FrontmatterSchema<T> { parse(input: unknown): T }
100
416
  ```
101
417
 
102
418
  ## Entry Points
@@ -104,7 +420,7 @@ interface GetContentSlugsOptions { extensions?: string[]; recursive?: boolean }
104
420
  | Import | Environment | Contents |
105
421
  |--------|------------|----------|
106
422
  | `@bliztek/mdx-utils` | Any (browser, edge, Node) | `calculateReadTime`, `stripMdxSyntax`, `sortByDateDescending`, types |
107
- | `@bliztek/mdx-utils/node` | Node.js | `getContentSlugs`, `readMdxFile` + re-exports from main |
423
+ | `@bliztek/mdx-utils/node` | Node.js | `createMdxCollection`, `collectRedirects`, `defaultParseFrontmatter`, `MdxValidationError`, `getContentSlugs`, `readMdxFile` + re-exports from main |
108
424
 
109
425
  ## License
110
426
 
@@ -0,0 +1,178 @@
1
+ type TableOfContentsEntry = {
2
+ /** Heading level: 2 for h2, 3 for h3, etc. */
3
+ level: number;
4
+ /** Rendered heading text with inline markup stripped. */
5
+ text: string;
6
+ /** URL-safe anchor id. */
7
+ slug: string;
8
+ /** Nested subheadings. */
9
+ children?: TableOfContentsEntry[];
10
+ };
11
+ type TableOfContents = TableOfContentsEntry[];
12
+ interface ReadTimeOptions {
13
+ /** Words per minute. Defaults to 238. */
14
+ wordsPerMinute?: number;
15
+ }
16
+ interface ReadTimeResult {
17
+ /** Estimated read time in minutes (minimum 1). */
18
+ minutes: number;
19
+ /** Total word count after stripping MDX/JSX syntax. */
20
+ words: number;
21
+ }
22
+ interface GetContentSlugsOptions {
23
+ /** File extensions to include. Defaults to `[".mdx"]`. */
24
+ extensions?: string[];
25
+ /** Recursively search subdirectories. Defaults to `false`. */
26
+ recursive?: boolean;
27
+ }
28
+ /**
29
+ * Structural schema interface. Any object with a `parse` method that
30
+ * returns the validated value (or throws) satisfies it — works for Zod,
31
+ * Valibot, Yup, and hand-rolled validators. No dependency added.
32
+ */
33
+ interface FrontmatterSchema<T> {
34
+ parse(input: unknown): T;
35
+ }
36
+ /**
37
+ * A parsed MDX entry with its metadata and derived fields.
38
+ */
39
+ interface MdxEntry<TFrontmatter> {
40
+ /** The namespace path segment(s), or "" when `namespaceDepth` is 0. */
41
+ namespace: string;
42
+ /** Filename without the `.mdx` extension. */
43
+ slug: string;
44
+ /** Parsed frontmatter, typed if a schema was provided. */
45
+ metadata: TFrontmatter;
46
+ /** Estimated read time in minutes. */
47
+ readTime: number;
48
+ /**
49
+ * Table of contents extracted from headings.
50
+ *
51
+ * Reserved for a future release — always `undefined` in 1.1.
52
+ * Consumers should treat it as optional and fall back to a
53
+ * client-side walker until the server-side implementation lands.
54
+ */
55
+ tableOfContents?: TableOfContents;
56
+ /** Absolute filesystem path. */
57
+ filePath: string;
58
+ }
59
+ interface CreateMdxCollectionOptions<TFrontmatter> {
60
+ /**
61
+ * Filesystem path to the content root. Resolved against `process.cwd()`
62
+ * if relative.
63
+ */
64
+ root: string;
65
+ /**
66
+ * How many directory segments between `root` and each `.mdx` file are
67
+ * treated as the namespace.
68
+ *
69
+ * - 0 → flat directory (`content/blog/{slug}.mdx`)
70
+ * - 1 → one nested dir (`content/games/{namespace}/{slug}.mdx`)
71
+ * - 2 → two nested dirs (`content/docs/{section}/{subsection}/{slug}.mdx`)
72
+ *
73
+ * Files whose depth doesn't match throw a walker error — there is no
74
+ * silent coercion.
75
+ *
76
+ * Defaults to `0`.
77
+ */
78
+ namespaceDepth?: number;
79
+ /**
80
+ * Optional schema to validate frontmatter against. Accepts anything with
81
+ * a `parse(input: unknown)` method, so Zod/Valibot/Yup/hand-rolled all
82
+ * work without adding a dependency.
83
+ *
84
+ * Note: `getAll()` does NOT throw on schema violations — it casts and
85
+ * returns the raw frontmatter. Call `validate()` explicitly (typically
86
+ * from `next.config`) to fail the build on drift.
87
+ */
88
+ frontmatterSchema?: FrontmatterSchema<TFrontmatter>;
89
+ /**
90
+ * Override the frontmatter parser. Receives the full file contents,
91
+ * returns a plain object. Default is `defaultParseFrontmatter`.
92
+ */
93
+ parseFrontmatter?: (raw: string) => Record<string, unknown>;
94
+ /** Words-per-minute override for read-time calculation. Defaults to 238. */
95
+ wordsPerMinute?: number;
96
+ }
97
+ interface MdxCollectionGetArgs {
98
+ /** Required when `namespaceDepth > 0`. Omit for depth-0 collections. */
99
+ namespace?: string;
100
+ slug: string;
101
+ }
102
+ interface MdxCollection<TFrontmatter> {
103
+ /**
104
+ * All entries in the collection. Entries with a parseable `publishedAt`
105
+ * are sorted descending first; the rest are appended in walk order.
106
+ */
107
+ getAll(): Promise<MdxEntry<TFrontmatter>[]>;
108
+ /** Entries filtered to a single namespace. */
109
+ getAllByNamespace(namespace: string): Promise<MdxEntry<TFrontmatter>[]>;
110
+ /**
111
+ * Fetch a single entry by namespace + slug (or just slug for depth-0
112
+ * collections). Returns `null` if not found.
113
+ */
114
+ get(args: MdxCollectionGetArgs): Promise<MdxEntry<TFrontmatter> | null>;
115
+ /**
116
+ * Resolve a list of related slugs within a namespace to full entries.
117
+ * Unknown slugs are skipped with a `console.warn`.
118
+ *
119
+ * Note: cross-namespace related content is a foreseeable future need
120
+ * and will require a signature change. Flagged intentionally.
121
+ */
122
+ resolveRelated(namespace: string, slugs: string[]): Promise<MdxEntry<TFrontmatter>[]>;
123
+ /**
124
+ * Validate every file against `frontmatterSchema` and throw an
125
+ * aggregated `MdxValidationError` if any fail. No-op when no schema
126
+ * is configured.
127
+ */
128
+ validate(): Promise<void>;
129
+ /** Drop the in-process cache so the next call re-reads the filesystem. */
130
+ invalidate(): void;
131
+ }
132
+ interface MdxValidationIssue {
133
+ /** Absolute path of the offending file. */
134
+ filePath: string;
135
+ /** Dotted path into the frontmatter object where validation failed. */
136
+ path: string;
137
+ /** Human-readable explanation. */
138
+ message: string;
139
+ }
140
+ interface CollectRedirectsOptions {
141
+ root: string;
142
+ namespaceDepth?: number;
143
+ /**
144
+ * URL template. Supported tokens: `{namespace}`, `{slug}`.
145
+ * Example: `"/games/{namespace}/guides/{slug}"`
146
+ */
147
+ basePath: string;
148
+ /** Defaults to `true` (301). Set `false` for 302. */
149
+ permanent?: boolean;
150
+ /** Frontmatter field to read aliases from. Defaults to `"aliases"`. */
151
+ aliasField?: string;
152
+ }
153
+ interface NextRedirect {
154
+ source: string;
155
+ destination: string;
156
+ permanent: boolean;
157
+ }
158
+
159
+ /**
160
+ * Strip MDX/markdown syntax from content, returning plain text.
161
+ * Removes export blocks, import statements, JSX tags, and markdown
162
+ * formatting characters. Useful for generating excerpts, search
163
+ * indexes, or OpenGraph descriptions from raw MDX.
164
+ */
165
+ declare function stripMdxSyntax(content: string): string;
166
+ /**
167
+ * Calculate estimated read time for MDX/markdown content.
168
+ * Strips export blocks, import statements, JSX tags, and markdown syntax
169
+ * before counting words.
170
+ */
171
+ declare function calculateReadTime(content: string, options?: ReadTimeOptions): ReadTimeResult;
172
+ /**
173
+ * Sort items by date in descending order (newest first).
174
+ * Returns a new array without mutating the original.
175
+ */
176
+ declare function sortByDateDescending<T>(items: T[], getDate: (item: T) => string): T[];
177
+
178
+ export { type CreateMdxCollectionOptions as C, type FrontmatterSchema as F, type GetContentSlugsOptions as G, type MdxCollection as M, type NextRedirect as N, type ReadTimeOptions as R, type TableOfContents as T, type CollectRedirectsOptions as a, type MdxValidationIssue as b, type MdxCollectionGetArgs as c, type MdxEntry as d, type ReadTimeResult as e, type TableOfContentsEntry as f, calculateReadTime as g, stripMdxSyntax as h, sortByDateDescending as s };
@@ -0,0 +1,178 @@
1
+ type TableOfContentsEntry = {
2
+ /** Heading level: 2 for h2, 3 for h3, etc. */
3
+ level: number;
4
+ /** Rendered heading text with inline markup stripped. */
5
+ text: string;
6
+ /** URL-safe anchor id. */
7
+ slug: string;
8
+ /** Nested subheadings. */
9
+ children?: TableOfContentsEntry[];
10
+ };
11
+ type TableOfContents = TableOfContentsEntry[];
12
+ interface ReadTimeOptions {
13
+ /** Words per minute. Defaults to 238. */
14
+ wordsPerMinute?: number;
15
+ }
16
+ interface ReadTimeResult {
17
+ /** Estimated read time in minutes (minimum 1). */
18
+ minutes: number;
19
+ /** Total word count after stripping MDX/JSX syntax. */
20
+ words: number;
21
+ }
22
+ interface GetContentSlugsOptions {
23
+ /** File extensions to include. Defaults to `[".mdx"]`. */
24
+ extensions?: string[];
25
+ /** Recursively search subdirectories. Defaults to `false`. */
26
+ recursive?: boolean;
27
+ }
28
+ /**
29
+ * Structural schema interface. Any object with a `parse` method that
30
+ * returns the validated value (or throws) satisfies it — works for Zod,
31
+ * Valibot, Yup, and hand-rolled validators. No dependency added.
32
+ */
33
+ interface FrontmatterSchema<T> {
34
+ parse(input: unknown): T;
35
+ }
36
+ /**
37
+ * A parsed MDX entry with its metadata and derived fields.
38
+ */
39
+ interface MdxEntry<TFrontmatter> {
40
+ /** The namespace path segment(s), or "" when `namespaceDepth` is 0. */
41
+ namespace: string;
42
+ /** Filename without the `.mdx` extension. */
43
+ slug: string;
44
+ /** Parsed frontmatter, typed if a schema was provided. */
45
+ metadata: TFrontmatter;
46
+ /** Estimated read time in minutes. */
47
+ readTime: number;
48
+ /**
49
+ * Table of contents extracted from headings.
50
+ *
51
+ * Reserved for a future release — always `undefined` in 1.1.
52
+ * Consumers should treat it as optional and fall back to a
53
+ * client-side walker until the server-side implementation lands.
54
+ */
55
+ tableOfContents?: TableOfContents;
56
+ /** Absolute filesystem path. */
57
+ filePath: string;
58
+ }
59
+ interface CreateMdxCollectionOptions<TFrontmatter> {
60
+ /**
61
+ * Filesystem path to the content root. Resolved against `process.cwd()`
62
+ * if relative.
63
+ */
64
+ root: string;
65
+ /**
66
+ * How many directory segments between `root` and each `.mdx` file are
67
+ * treated as the namespace.
68
+ *
69
+ * - 0 → flat directory (`content/blog/{slug}.mdx`)
70
+ * - 1 → one nested dir (`content/games/{namespace}/{slug}.mdx`)
71
+ * - 2 → two nested dirs (`content/docs/{section}/{subsection}/{slug}.mdx`)
72
+ *
73
+ * Files whose depth doesn't match throw a walker error — there is no
74
+ * silent coercion.
75
+ *
76
+ * Defaults to `0`.
77
+ */
78
+ namespaceDepth?: number;
79
+ /**
80
+ * Optional schema to validate frontmatter against. Accepts anything with
81
+ * a `parse(input: unknown)` method, so Zod/Valibot/Yup/hand-rolled all
82
+ * work without adding a dependency.
83
+ *
84
+ * Note: `getAll()` does NOT throw on schema violations — it casts and
85
+ * returns the raw frontmatter. Call `validate()` explicitly (typically
86
+ * from `next.config`) to fail the build on drift.
87
+ */
88
+ frontmatterSchema?: FrontmatterSchema<TFrontmatter>;
89
+ /**
90
+ * Override the frontmatter parser. Receives the full file contents,
91
+ * returns a plain object. Default is `defaultParseFrontmatter`.
92
+ */
93
+ parseFrontmatter?: (raw: string) => Record<string, unknown>;
94
+ /** Words-per-minute override for read-time calculation. Defaults to 238. */
95
+ wordsPerMinute?: number;
96
+ }
97
+ interface MdxCollectionGetArgs {
98
+ /** Required when `namespaceDepth > 0`. Omit for depth-0 collections. */
99
+ namespace?: string;
100
+ slug: string;
101
+ }
102
+ interface MdxCollection<TFrontmatter> {
103
+ /**
104
+ * All entries in the collection. Entries with a parseable `publishedAt`
105
+ * are sorted descending first; the rest are appended in walk order.
106
+ */
107
+ getAll(): Promise<MdxEntry<TFrontmatter>[]>;
108
+ /** Entries filtered to a single namespace. */
109
+ getAllByNamespace(namespace: string): Promise<MdxEntry<TFrontmatter>[]>;
110
+ /**
111
+ * Fetch a single entry by namespace + slug (or just slug for depth-0
112
+ * collections). Returns `null` if not found.
113
+ */
114
+ get(args: MdxCollectionGetArgs): Promise<MdxEntry<TFrontmatter> | null>;
115
+ /**
116
+ * Resolve a list of related slugs within a namespace to full entries.
117
+ * Unknown slugs are skipped with a `console.warn`.
118
+ *
119
+ * Note: cross-namespace related content is a foreseeable future need
120
+ * and will require a signature change. Flagged intentionally.
121
+ */
122
+ resolveRelated(namespace: string, slugs: string[]): Promise<MdxEntry<TFrontmatter>[]>;
123
+ /**
124
+ * Validate every file against `frontmatterSchema` and throw an
125
+ * aggregated `MdxValidationError` if any fail. No-op when no schema
126
+ * is configured.
127
+ */
128
+ validate(): Promise<void>;
129
+ /** Drop the in-process cache so the next call re-reads the filesystem. */
130
+ invalidate(): void;
131
+ }
132
+ interface MdxValidationIssue {
133
+ /** Absolute path of the offending file. */
134
+ filePath: string;
135
+ /** Dotted path into the frontmatter object where validation failed. */
136
+ path: string;
137
+ /** Human-readable explanation. */
138
+ message: string;
139
+ }
140
+ interface CollectRedirectsOptions {
141
+ root: string;
142
+ namespaceDepth?: number;
143
+ /**
144
+ * URL template. Supported tokens: `{namespace}`, `{slug}`.
145
+ * Example: `"/games/{namespace}/guides/{slug}"`
146
+ */
147
+ basePath: string;
148
+ /** Defaults to `true` (301). Set `false` for 302. */
149
+ permanent?: boolean;
150
+ /** Frontmatter field to read aliases from. Defaults to `"aliases"`. */
151
+ aliasField?: string;
152
+ }
153
+ interface NextRedirect {
154
+ source: string;
155
+ destination: string;
156
+ permanent: boolean;
157
+ }
158
+
159
+ /**
160
+ * Strip MDX/markdown syntax from content, returning plain text.
161
+ * Removes export blocks, import statements, JSX tags, and markdown
162
+ * formatting characters. Useful for generating excerpts, search
163
+ * indexes, or OpenGraph descriptions from raw MDX.
164
+ */
165
+ declare function stripMdxSyntax(content: string): string;
166
+ /**
167
+ * Calculate estimated read time for MDX/markdown content.
168
+ * Strips export blocks, import statements, JSX tags, and markdown syntax
169
+ * before counting words.
170
+ */
171
+ declare function calculateReadTime(content: string, options?: ReadTimeOptions): ReadTimeResult;
172
+ /**
173
+ * Sort items by date in descending order (newest first).
174
+ * Returns a new array without mutating the original.
175
+ */
176
+ declare function sortByDateDescending<T>(items: T[], getDate: (item: T) => string): T[];
177
+
178
+ export { type CreateMdxCollectionOptions as C, type FrontmatterSchema as F, type GetContentSlugsOptions as G, type MdxCollection as M, type NextRedirect as N, type ReadTimeOptions as R, type TableOfContents as T, type CollectRedirectsOptions as a, type MdxValidationIssue as b, type MdxCollectionGetArgs as c, type MdxEntry as d, type ReadTimeResult as e, type TableOfContentsEntry as f, calculateReadTime as g, stripMdxSyntax as h, sortByDateDescending as s };