mdx-blog-core 0.1.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 ADDED
@@ -0,0 +1,340 @@
1
+ # mdx-blog-core
2
+
3
+ Headless utilities for **MDX- or Markdown-based blogs** in **Next.js** (App Router) and other React apps.
4
+
5
+ **This package does not render UI or compile MDX.** You keep your own MDX pipeline (`next-mdx-remote`, `next-mdx-rsc`, custom loaders, etc.) and your own React components. `mdx-blog-core` focuses on **loading posts from disk**, **TOC extraction**, **RSS XML**, **neighbour navigation**, **search filtering**, and **LLM-oriented markdown shells**.
6
+
7
+ Optional companion: `**mdx-blog-ui`** (copy-paste blog UI via CLI from the same workspace)—separate package, not required for `mdx-blog-core`.
8
+
9
+ ---
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install mdx-blog-core
15
+ # or
16
+ pnpm add mdx-blog-core
17
+ # or
18
+ yarn add mdx-blog-core
19
+ ```
20
+
21
+ ### Peer dependencies
22
+
23
+
24
+ | Package | Required | Notes |
25
+ | --------- | ----------------- | ------------------------------------------------------------------------ |
26
+ | **react** | Yes (`>=18`) | Used by `React.cache` in the Node loader and by client entry points. |
27
+ | **next** | Optional (`>=14`) | Only needed if you import `mdx-blog-core/next` (uses `next/navigation`). |
28
+
29
+
30
+ Runtime **Node.js** is required for `mdx-blog-core/node` (filesystem access).
31
+
32
+ ### Transitive dependencies
33
+
34
+ The package depends on `**gray-matter`** (frontmatter) and `**fumadocs-core**` (table of contents). You do not need to install those separately for normal use.
35
+
36
+ ---
37
+
38
+ ## Package entry points
39
+
40
+ Use the subpath that matches **where your code runs** (Node server vs browser).
41
+
42
+
43
+ | Import | Environment | Purpose |
44
+ | --------------------- | -------------------- | --------------------------------------------------------------------------- |
45
+ | `mdx-blog-core` | Server / shared | Types, post helpers, TOC, RSS, LLM markdown helpers, `escapeXml` |
46
+ | `mdx-blog-core/node` | **Node only** | `createBlogSource` — read `.md`/`.mdx` from disk, cached with `React.cache` |
47
+ | `mdx-blog-core/react` | **Client** | `useFilteredPosts` — memoized filter by title/description query |
48
+ | `mdx-blog-core/next` | **Client** (Next.js) | `PostKeyboardNavigation`, `usePostKeyboardNavigation` — arrow-key prev/next |
49
+
50
+
51
+ **Important:** Import `mdx-blog-core/node` only from code that runs in the **Node.js** runtime (e.g. Next.js Server Components, `getStaticProps`-style data loaders), not from Edge bundles if your toolchain cannot resolve `node:fs`.
52
+
53
+ ---
54
+
55
+ ## Types (`mdx-blog-core`)
56
+
57
+ These shapes match what `createBlogSource` expects by default; you can extend metadata with `parseMetadata` + generics.
58
+
59
+ ### `PostMetadata`
60
+
61
+
62
+ | Field | Type | Description |
63
+ | ------------- | ---------- | ------------------------------------------------------------- |
64
+ | `title` | `string` | Post title |
65
+ | `description` | `string` | Short summary |
66
+ | `image` | `string?` | Cover / OG image URL |
67
+ | `category` | `string?` | Used with `getPostsByCategory` / `filterPostsByCategory` |
68
+ | `new` | `boolean?` | Optional flag for UI |
69
+ | `pinned` | `boolean?` | Pinned posts sort first (see `sortPostsByPinnedAndCreatedAt`) |
70
+ | `createdAt` | `string` | ISO date string (sorting) |
71
+ | `updatedAt` | `string` | ISO date string |
72
+
73
+
74
+ ### `Post`
75
+
76
+ ```ts
77
+ type Post = {
78
+ metadata: PostMetadata
79
+ slug: string
80
+ content: string // body without frontmatter (MDX/Markdown)
81
+ }
82
+ ```
83
+
84
+ ### `FsPost` (`mdx-blog-core/node`)
85
+
86
+ Same as a `Post` for `metadata`, `slug`, and `content`, plus:
87
+
88
+ - `filePath: string` — absolute path to the source file.
89
+
90
+ ### `PostPreview`
91
+
92
+ Minimal shape: `{ slug, title, category? }` for list UIs that should not ship full `content`.
93
+
94
+ ### `TOCItemType`
95
+
96
+ Re-exported from `fumadocs-core/toc`. Returned items from `getTableOfContents` include `title`, `url`, `depth`, etc.—use them in your own TOC component.
97
+
98
+ ---
99
+
100
+ ## Loading content: `createBlogSource` (`mdx-blog-core/node`)
101
+
102
+ ```ts
103
+ import { createBlogSource } from "mdx-blog-core/node"
104
+
105
+ const blog = createBlogSource({
106
+ contentDir: "content/blog",
107
+ extensions: [".mdx", ".md"],
108
+ recursive: true,
109
+ })
110
+
111
+ const all = blog.getAllPosts()
112
+ const one = blog.getPostBySlug("my-post")
113
+ const guides = blog.getPostsByCategory("guides")
114
+ ```
115
+
116
+ ### Options
117
+
118
+
119
+ | Option | Type | Default | Description |
120
+ | ---------------------- | ---------- | ------------------------- | ----------------------------------------------------------------------------------------------------- |
121
+ | `contentDir` | `string` | (required) | Absolute path, or relative to `process.cwd()` |
122
+ | `extensions` | `string[]` | `[".mdx"]` | Allowed file extensions (case-insensitive) |
123
+ | `recursive` | `boolean` | `false` | If `true`, walk subfolders; slug becomes path relative to `contentDir` with `/` (e.g. `guides/hello`) |
124
+ | `parseMetadata` | function | identity cast | `(data, { filePath, slug }) => Meta` — use **Zod** (or similar) here |
125
+ | `sort` | function | pinned + `createdAt` desc | Custom sort; affects neighbour order |
126
+ | `throwOnDuplicateSlug` | `boolean` | `true` | Throw if two files resolve to the same slug |
127
+
128
+
129
+ ### Slug rules
130
+
131
+ - **Flat directory:** slug = filename without extension (`hello.mdx` → `hello`).
132
+ - **Recursive:** slug = relative path without extension, POSIX-style (`guides/hello.mdx` → `guides/hello`).
133
+
134
+ ### Caching
135
+
136
+ `getAllPosts` is wrapped in `**React.cache`**, so within a single request the file system is read once. Ensure this module is only used in contexts where React’s cache is valid (RSC / Next data layer).
137
+
138
+ ### Example frontmatter
139
+
140
+ ```yaml
141
+ ---
142
+ title: "Hello world"
143
+ description: "A short summary for lists and RSS."
144
+ createdAt: "2026-04-01"
145
+ updatedAt: "2026-04-05"
146
+ category: "guides"
147
+ image: "/og/hello.png"
148
+ pinned: false
149
+ new: true
150
+ ---
151
+
152
+ Your MDX body starts here.
153
+ ```
154
+
155
+ ### Validating frontmatter with Zod
156
+
157
+ ```ts
158
+ import { z } from "zod"
159
+ import { createBlogSource } from "mdx-blog-core/node"
160
+ import type { PostMetadata } from "mdx-blog-core"
161
+
162
+ const Schema = z.object({
163
+ title: z.string(),
164
+ description: z.string(),
165
+ createdAt: z.string(),
166
+ updatedAt: z.string(),
167
+ category: z.string().optional(),
168
+ image: z.string().optional(),
169
+ pinned: z.boolean().optional(),
170
+ new: z.boolean().optional(),
171
+ }) satisfies z.ZodType<PostMetadata>
172
+
173
+ const blog = createBlogSource({
174
+ contentDir: "content/blog",
175
+ parseMetadata: (data, _ctx) => Schema.parse(data),
176
+ })
177
+ ```
178
+
179
+ ---
180
+
181
+ ## Table of contents (`mdx-blog-core`)
182
+
183
+ ```ts
184
+ import { getTableOfContents, type TOCItemType } from "mdx-blog-core"
185
+
186
+ const items = getTableOfContents(post.content)
187
+ ```
188
+
189
+ Uses **fumadocs-core**’s TOC extractor on the **raw** MDX/Markdown string. Heading IDs in the rendered page should match `item.url` fragments (e.g. `#section-id`)—configure your MDX pipeline (e.g. `rehype-slug`) accordingly.
190
+
191
+ ---
192
+
193
+ ## Previous / next post (`mdx-blog-core`)
194
+
195
+ ```ts
196
+ import { findNeighbour } from "mdx-blog-core"
197
+
198
+ const { previous, next } = findNeighbour(allPosts, currentSlug)
199
+ ```
200
+
201
+ Order matches the **array order** of `allPosts` (usually your sorted list from `getAllPosts()`). If the slug is missing, both neighbours are `null`.
202
+
203
+ ---
204
+
205
+ ## Sorting and filters (`mdx-blog-core`)
206
+
207
+ ```ts
208
+ import {
209
+ sortPostsByPinnedAndCreatedAt,
210
+ filterPostsByCategory,
211
+ filterPostsByQuery,
212
+ } from "mdx-blog-core"
213
+
214
+ posts.sort(sortPostsByPinnedAndCreatedAt)
215
+ const guides = filterPostsByCategory(posts, "guides")
216
+ const hits = filterPostsByQuery(posts, searchQuery)
217
+ ```
218
+
219
+ `filterPostsByQuery` normalizes spaces and case; it matches **title** and **description** substrings.
220
+
221
+ ---
222
+
223
+ ## RSS (`mdx-blog-core`)
224
+
225
+ ```ts
226
+ import { buildRssFeedXml, type RssChannelInfo, type RssFeedItem } from "mdx-blog-core"
227
+
228
+ const channel: RssChannelInfo = {
229
+ title: "My blog",
230
+ link: "https://example.com",
231
+ description: "Latest posts",
232
+ }
233
+
234
+ const items: RssFeedItem[] = posts.map((p) => ({
235
+ title: p.metadata.title,
236
+ link: `${siteUrl}/blog/${p.slug}`,
237
+ description: p.metadata.description,
238
+ pubDate: p.metadata.createdAt,
239
+ }))
240
+
241
+ const xml = buildRssFeedXml(channel, items)
242
+ ```
243
+
244
+ `escapeXml` is used internally; export it if you build custom XML.
245
+
246
+ **Next.js App Router example** (`app/blog/rss.xml/route.ts` or similar):
247
+
248
+ ```ts
249
+ import { buildRssFeedXml } from "mdx-blog-core"
250
+
251
+ export function GET() {
252
+ const xml = buildRssFeedXml(channel, items)
253
+ return new Response(xml, {
254
+ headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
255
+ })
256
+ }
257
+ ```
258
+
259
+ ---
260
+
261
+ ## LLM / plain markdown export (`mdx-blog-core`)
262
+
263
+ For “view as markdown” or RAG-friendly text, process MDX with **your own** `remark`/`unified` pipeline, then wrap:
264
+
265
+ ```ts
266
+ import { buildLlmMarkdownDocument } from "mdx-blog-core"
267
+
268
+ const markdown = buildLlmMarkdownDocument({
269
+ title: post.metadata.title,
270
+ description: post.metadata.description,
271
+ bodyMarkdown: processedBody,
272
+ updatedAtLabel: "April 5, 2026",
273
+ })
274
+ ```
275
+
276
+ Or use `**buildLlmMarkdownFromPost**` when you want the package to assemble title, description, and “Last updated” around an async body converter:
277
+
278
+ ```ts
279
+ import { buildLlmMarkdownFromPost } from "mdx-blog-core"
280
+
281
+ const markdown = await buildLlmMarkdownFromPost({
282
+ post,
283
+ toBodyMarkdown: async ({ content }) => {
284
+ // run remark/rehype here; return plain markdown string
285
+ return content
286
+ },
287
+ getUpdatedAtLabel: ({ updatedAt }) => updatedAt,
288
+ })
289
+ ```
290
+
291
+ ---
292
+
293
+ ## Client: search list (`mdx-blog-core/react`)
294
+
295
+ ```tsx
296
+ "use client"
297
+
298
+ import { useFilteredPosts } from "mdx-blog-core/react"
299
+
300
+ const visible = useFilteredPosts(posts, query)
301
+ ```
302
+
303
+ Pass `query` from URL state (`nuqs`, `useSearchParams`, etc.). `null`/`undefined`/empty string returns all posts.
304
+
305
+ ---
306
+
307
+ ## Client: keyboard navigation (`mdx-blog-core/next`)
308
+
309
+ Requires **Next.js** (`next/navigation`).
310
+
311
+ ```tsx
312
+ "use client"
313
+
314
+ import { PostKeyboardNavigation } from "mdx-blog-core/next"
315
+
316
+ export function BlogPostChrome({
317
+ basePath,
318
+ previous,
319
+ next,
320
+ }: {
321
+ basePath: string
322
+ previous: { slug: string } | null
323
+ next: { slug: string } | null
324
+ }) {
325
+ return (
326
+ <PostKeyboardNavigation
327
+ basePath={basePath.replace(/\/$/, "")}
328
+ previous={previous}
329
+ next={next}
330
+ />
331
+ )
332
+ }
333
+ ```
334
+
335
+ **ArrowLeft** / **ArrowRight** navigate when `defaultPrevented` is false on the key event. For custom routing, use `**usePostKeyboardNavigation`** with your own `push` function.
336
+
337
+
338
+ ## License
339
+
340
+ MIT
package/dist/core.d.ts ADDED
@@ -0,0 +1,84 @@
1
+ import { getTableOfContents } from "fumadocs-core/content/toc";
2
+ export type { TOCItemType } from "fumadocs-core/toc";
3
+ export type PostMetadata = {
4
+ title: string;
5
+ description: string;
6
+ image?: string;
7
+ category?: string;
8
+ new?: boolean;
9
+ pinned?: boolean;
10
+ createdAt: string;
11
+ updatedAt: string;
12
+ };
13
+ export type Post = {
14
+ metadata: PostMetadata;
15
+ slug: string;
16
+ content: string;
17
+ };
18
+ export type PostPreview = {
19
+ slug: string;
20
+ title: string;
21
+ category?: string;
22
+ };
23
+ export declare function sortPostsByPinnedAndCreatedAt(a: Post, b: Post): number;
24
+ export declare function filterPostsByCategory(posts: Post[], category: string): Post[];
25
+ export declare function findNeighbour<T extends {
26
+ slug: string;
27
+ }>(posts: T[], slug: string): {
28
+ previous: T | null;
29
+ next: T | null;
30
+ };
31
+ export declare function filterPostsByQuery<T extends {
32
+ metadata: {
33
+ title: string;
34
+ description: string;
35
+ };
36
+ }>(posts: T[], query: string | null): T[];
37
+ export declare function escapeXml(text: string): string;
38
+ export type RssChannelInfo = {
39
+ title: string;
40
+ link: string;
41
+ description: string;
42
+ };
43
+ export type RssFeedItem = {
44
+ title: string;
45
+ link: string;
46
+ description: string;
47
+ pubDate: Date | string;
48
+ };
49
+ export declare function buildRssFeedXml(channel: RssChannelInfo, items: RssFeedItem[]): string;
50
+ export declare function buildLlmMarkdownDocument(parts: {
51
+ title: string;
52
+ description: string;
53
+ bodyMarkdown: string;
54
+ updatedAtLabel: string;
55
+ }): string;
56
+ export type LlmPostLike = {
57
+ metadata: {
58
+ title: string;
59
+ description: string;
60
+ updatedAt: string;
61
+ };
62
+ content: string;
63
+ };
64
+ export declare function buildLlmMarkdownFromPost<TPost extends LlmPostLike>(params: {
65
+ post: TPost;
66
+ /**
67
+ * Convert raw MDX/Markdown into a plain-markdown body suitable for LLM consumption.
68
+ * The host app fully owns the processor (remark/unified/CMS/etc).
69
+ */
70
+ toBodyMarkdown: (input: {
71
+ content: string;
72
+ post: TPost;
73
+ }) => Promise<string>;
74
+ /**
75
+ * Provide the human-readable updated-at label. This keeps the package unopinionated
76
+ * about date formatting libraries and locales.
77
+ */
78
+ getUpdatedAtLabel: (input: {
79
+ updatedAt: string;
80
+ post: TPost;
81
+ }) => string;
82
+ }): Promise<string>;
83
+ export { getTableOfContents };
84
+ //# sourceMappingURL=core.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"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"}
package/dist/core.js ADDED
@@ -0,0 +1,90 @@
1
+ import { getTableOfContents } from "fumadocs-core/content/toc";
2
+ export function sortPostsByPinnedAndCreatedAt(a, b) {
3
+ if (a.metadata.pinned && !b.metadata.pinned)
4
+ return -1;
5
+ if (!a.metadata.pinned && b.metadata.pinned)
6
+ return 1;
7
+ return (new Date(b.metadata.createdAt).getTime() -
8
+ new Date(a.metadata.createdAt).getTime());
9
+ }
10
+ export function filterPostsByCategory(posts, category) {
11
+ return posts.filter((post) => post.metadata.category === category);
12
+ }
13
+ export function findNeighbour(posts, slug) {
14
+ const len = posts.length;
15
+ for (let i = 0; i < len; ++i) {
16
+ if (posts[i].slug === slug) {
17
+ return {
18
+ previous: i > 0 ? posts[i - 1] : null,
19
+ next: i < len - 1 ? posts[i + 1] : null,
20
+ };
21
+ }
22
+ }
23
+ return { previous: null, next: null };
24
+ }
25
+ const normalize = (text) => text.toLowerCase().replaceAll(" ", "");
26
+ function matchesQuery(post, normalizedQuery) {
27
+ const normalizedTitle = normalize(post.metadata.title);
28
+ const normalizedDescription = normalize(post.metadata.description);
29
+ return (normalizedTitle.includes(normalizedQuery) ||
30
+ normalizedDescription.includes(normalizedQuery));
31
+ }
32
+ export function filterPostsByQuery(posts, query) {
33
+ if (!query)
34
+ return posts;
35
+ const normalizedQuery = normalize(query);
36
+ return posts.filter((post) => matchesQuery(post, normalizedQuery));
37
+ }
38
+ export function escapeXml(text) {
39
+ return text
40
+ .replaceAll("&", "&amp;")
41
+ .replaceAll("<", "&lt;")
42
+ .replaceAll(">", "&gt;")
43
+ .replaceAll('"', "&quot;")
44
+ .replaceAll("'", "&apos;");
45
+ }
46
+ export function buildRssFeedXml(channel, items) {
47
+ const itemsXml = items
48
+ .map((post) => `<item>
49
+ <title>${escapeXml(post.title)}</title>
50
+ <link>${escapeXml(post.link)}</link>
51
+ <description>${escapeXml(post.description)}</description>
52
+ <pubDate>${new Date(post.pubDate).toUTCString()}</pubDate>
53
+ </item>`)
54
+ .join("\n");
55
+ return `<?xml version="1.0" encoding="UTF-8" ?>
56
+ <rss version="2.0">
57
+ <channel>
58
+ <title>${escapeXml(channel.title)}</title>
59
+ <link>${escapeXml(channel.link)}</link>
60
+ <description>${escapeXml(channel.description)}</description>
61
+ ${itemsXml}
62
+ </channel>
63
+ </rss>`;
64
+ }
65
+ export function buildLlmMarkdownDocument(parts) {
66
+ return `# ${parts.title}
67
+
68
+ ${parts.description}
69
+
70
+ ${parts.bodyMarkdown}
71
+
72
+ Last updated on ${parts.updatedAtLabel}`;
73
+ }
74
+ export async function buildLlmMarkdownFromPost(params) {
75
+ const bodyMarkdown = await params.toBodyMarkdown({
76
+ content: params.post.content,
77
+ post: params.post,
78
+ });
79
+ const updatedAtLabel = params.getUpdatedAtLabel({
80
+ updatedAt: params.post.metadata.updatedAt,
81
+ post: params.post,
82
+ });
83
+ return buildLlmMarkdownDocument({
84
+ title: params.post.metadata.title,
85
+ description: params.post.metadata.description,
86
+ bodyMarkdown,
87
+ updatedAtLabel,
88
+ });
89
+ }
90
+ export { getTableOfContents };
@@ -0,0 +1,2 @@
1
+ export { buildLlmMarkdownDocument, buildLlmMarkdownFromPost, buildRssFeedXml, escapeXml, filterPostsByCategory, filterPostsByQuery, findNeighbour, getTableOfContents, type LlmPostLike, type Post, type PostMetadata, type PostPreview, type RssChannelInfo, type RssFeedItem, sortPostsByPinnedAndCreatedAt, type TOCItemType, } from "./core.js";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,wBAAwB,EACxB,wBAAwB,EACxB,eAAe,EACf,SAAS,EACT,qBAAqB,EACrB,kBAAkB,EAClB,aAAa,EACb,kBAAkB,EAClB,KAAK,WAAW,EAChB,KAAK,IAAI,EACT,KAAK,YAAY,EACjB,KAAK,WAAW,EAChB,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,6BAA6B,EAC7B,KAAK,WAAW,GACjB,MAAM,WAAW,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { buildLlmMarkdownDocument, buildLlmMarkdownFromPost, buildRssFeedXml, escapeXml, filterPostsByCategory, filterPostsByQuery, findNeighbour, getTableOfContents, sortPostsByPinnedAndCreatedAt, } from "./core.js";
package/dist/next.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ export type PostNavTarget = {
2
+ slug: string;
3
+ };
4
+ export declare function usePostKeyboardNavigation(options: {
5
+ basePath: string;
6
+ previous: PostNavTarget | null;
7
+ next: PostNavTarget | null;
8
+ push: (href: string) => void;
9
+ }): void;
10
+ export declare function PostKeyboardNavigation({ basePath, previous, next, }: {
11
+ basePath: string;
12
+ previous: PostNavTarget | null;
13
+ next: PostNavTarget | null;
14
+ }): null;
15
+ //# sourceMappingURL=next.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"next.d.ts","sourceRoot":"","sources":["../src/next.tsx"],"names":[],"mappings":"AAKA,MAAM,MAAM,aAAa,GAAG;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAA;AAE5C,wBAAgB,yBAAyB,CAAC,OAAO,EAAE;IACjD,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,aAAa,GAAG,IAAI,CAAA;IAC9B,IAAI,EAAE,aAAa,GAAG,IAAI,CAAA;IAC1B,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;CAC7B,GAAG,IAAI,CAmBP;AAED,wBAAgB,sBAAsB,CAAC,EACrC,QAAQ,EACR,QAAQ,EACR,IAAI,GACL,EAAE;IACD,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,aAAa,GAAG,IAAI,CAAA;IAC9B,IAAI,EAAE,aAAa,GAAG,IAAI,CAAA;CAC3B,QAaA"}
package/dist/next.js ADDED
@@ -0,0 +1,33 @@
1
+ "use client";
2
+ import { useRouter } from "next/navigation";
3
+ import { useEffect } from "react";
4
+ export function usePostKeyboardNavigation(options) {
5
+ const { basePath, previous, next, push } = options;
6
+ useEffect(() => {
7
+ const onKeyDown = (event) => {
8
+ if (event.defaultPrevented) {
9
+ return;
10
+ }
11
+ if (event.key === "ArrowRight" && next) {
12
+ push(`${basePath}/${next.slug}`);
13
+ }
14
+ else if (event.key === "ArrowLeft" && previous) {
15
+ push(`${basePath}/${previous.slug}`);
16
+ }
17
+ };
18
+ window.addEventListener("keydown", onKeyDown);
19
+ return () => window.removeEventListener("keydown", onKeyDown);
20
+ }, [basePath, next, previous, push]);
21
+ }
22
+ export function PostKeyboardNavigation({ basePath, previous, next, }) {
23
+ const router = useRouter();
24
+ usePostKeyboardNavigation({
25
+ basePath,
26
+ previous,
27
+ next,
28
+ push: (href) => {
29
+ router.push(href);
30
+ },
31
+ });
32
+ return null;
33
+ }
package/dist/node.d.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { PostMetadata } from "./core.js";
2
+ export type FsPost<Meta extends Record<string, unknown> = PostMetadata> = {
3
+ metadata: Meta;
4
+ /**
5
+ * Slug derived from the filename.
6
+ *
7
+ * - When `recursive: false`: `hello-world`
8
+ * - When `recursive: true`: `guides/hello-world`
9
+ */
10
+ slug: string;
11
+ /** Raw markdown/MDX body without frontmatter. */
12
+ content: string;
13
+ /** Absolute file path to the source. */
14
+ filePath: string;
15
+ };
16
+ type ParseFrontmatterContext = {
17
+ filePath: string;
18
+ slug: string;
19
+ };
20
+ export type FrontmatterParser<Meta extends Record<string, unknown> = PostMetadata> = (data: unknown, ctx: ParseFrontmatterContext) => Meta;
21
+ export type BlogSourceOptions<Meta extends Record<string, unknown> = PostMetadata> = {
22
+ /**
23
+ * Directory containing markdown/MDX files.
24
+ *
25
+ * Accepts an absolute path or a path relative to `process.cwd()`.
26
+ */
27
+ contentDir: string;
28
+ /**
29
+ * File extensions to include (case-insensitive).
30
+ *
31
+ * @defaultValue [".mdx"]
32
+ */
33
+ extensions?: string[];
34
+ /**
35
+ * Recursively discover files under `contentDir`.
36
+ *
37
+ * @defaultValue false
38
+ */
39
+ recursive?: boolean;
40
+ /**
41
+ * Parse/validate frontmatter. Use this for Zod validation, default values, etc.
42
+ *
43
+ * @defaultValue (data) => data as PostMetadata
44
+ */
45
+ parseMetadata?: FrontmatterParser<Meta>;
46
+ /**
47
+ * Override default sort (pinned first, then `createdAt` descending).
48
+ *
49
+ * Note: sorting affects neighbour navigation ordering in the host app.
50
+ */
51
+ sort?: (a: FsPost<Meta>, b: FsPost<Meta>) => number;
52
+ /**
53
+ * If true, throw on duplicate slugs. This catches collisions that can happen
54
+ * with case-insensitive filesystems or nested files.
55
+ *
56
+ * @defaultValue true
57
+ */
58
+ throwOnDuplicateSlug?: boolean;
59
+ };
60
+ export type BlogSource<Meta extends Record<string, unknown> = PostMetadata> = {
61
+ getAllPosts: () => FsPost<Meta>[];
62
+ getPostBySlug: (slug: string) => FsPost<Meta> | undefined;
63
+ getPostsByCategory: (category: string) => FsPost<Meta>[];
64
+ };
65
+ export declare function createBlogSource<Meta extends Record<string, unknown> = PostMetadata>(options: BlogSourceOptions<Meta>): BlogSource<Meta>;
66
+ export {};
67
+ //# sourceMappingURL=node.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../src/node.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAQ,YAAY,EAAE,MAAM,WAAW,CAAA;AAGnD,MAAM,MAAM,MAAM,CAAC,IAAI,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,YAAY,IAAI;IACxE,QAAQ,EAAE,IAAI,CAAA;IACd;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAA;IACZ,iDAAiD;IACjD,OAAO,EAAE,MAAM,CAAA;IACf,wCAAwC;IACxC,QAAQ,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,KAAK,uBAAuB,GAAG;IAC7B,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;CACb,CAAA;AAED,MAAM,MAAM,iBAAiB,CAC3B,IAAI,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,YAAY,IACjD,CAAC,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,uBAAuB,KAAK,IAAI,CAAA;AAezD,MAAM,MAAM,iBAAiB,CAC3B,IAAI,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,YAAY,IACjD;IACF;;;;OAIG;IACH,UAAU,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;IACrB;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB;;;;OAIG;IACH,aAAa,CAAC,EAAE,iBAAiB,CAAC,IAAI,CAAC,CAAA;IACvC;;;;OAIG;IACH,IAAI,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC,IAAI,CAAC,KAAK,MAAM,CAAA;IACnD;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAA;CAC/B,CAAA;AAED,MAAM,MAAM,UAAU,CACpB,IAAI,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,YAAY,IACjD;IACF,WAAW,EAAE,MAAM,MAAM,CAAC,IAAI,CAAC,EAAE,CAAA;IACjC,aAAa,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC,IAAI,CAAC,GAAG,SAAS,CAAA;IACzD,kBAAkB,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAC,IAAI,CAAC,EAAE,CAAA;CACzD,CAAA;AAgDD,wBAAgB,gBAAgB,CAC9B,IAAI,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,YAAY,EACnD,OAAO,EAAE,iBAAiB,CAAC,IAAI,CAAC,GAAG,UAAU,CAAC,IAAI,CAAC,CA6EpD"}
package/dist/node.js ADDED
@@ -0,0 +1,101 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import matter from "gray-matter";
4
+ import { cache } from "react";
5
+ import { sortPostsByPinnedAndCreatedAt } from "./core.js";
6
+ function parseFrontmatter(fileContent, ctx, parseMeta) {
7
+ const file = matter(fileContent);
8
+ return {
9
+ metadata: parseMeta(file.data, ctx),
10
+ content: file.content,
11
+ };
12
+ }
13
+ function normalizeExtList(exts) {
14
+ return new Set(exts.map((e) => e.toLowerCase()));
15
+ }
16
+ function listFilesRecursive(dir) {
17
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
18
+ const out = [];
19
+ for (const entry of entries) {
20
+ const full = path.join(dir, entry.name);
21
+ if (entry.isDirectory()) {
22
+ out.push(...listFilesRecursive(full));
23
+ }
24
+ else if (entry.isFile()) {
25
+ out.push(full);
26
+ }
27
+ }
28
+ return out;
29
+ }
30
+ function listFilesFlat(dir) {
31
+ return fs
32
+ .readdirSync(dir, { withFileTypes: true })
33
+ .filter((e) => e.isFile())
34
+ .map((e) => path.join(dir, e.name));
35
+ }
36
+ function toPosixPath(p) {
37
+ return p.replaceAll(path.sep, "/");
38
+ }
39
+ function getSlugFromFilePath(params) {
40
+ if (!params.recursive) {
41
+ const base = path.basename(params.filePath);
42
+ return path.basename(base, path.extname(base));
43
+ }
44
+ const rel = path.relative(params.contentDir, params.filePath);
45
+ const withoutExt = rel.slice(0, rel.length - path.extname(rel).length);
46
+ return toPosixPath(withoutExt);
47
+ }
48
+ export function createBlogSource(options) {
49
+ const contentDir = path.isAbsolute(options.contentDir)
50
+ ? options.contentDir
51
+ : path.join(process.cwd(), options.contentDir);
52
+ const recursive = options.recursive ?? false;
53
+ const extensions = normalizeExtList(options.extensions ?? [".mdx"]);
54
+ const throwOnDuplicateSlug = options.throwOnDuplicateSlug ?? true;
55
+ const parseMetadata = options.parseMetadata ?? ((data) => data);
56
+ const sort = options.sort ??
57
+ ((a, b) => sortPostsByPinnedAndCreatedAt(a, b));
58
+ function readAllPosts() {
59
+ const files = recursive
60
+ ? listFilesRecursive(contentDir)
61
+ : listFilesFlat(contentDir);
62
+ const posts = files
63
+ .filter((filePath) => extensions.has(path.extname(filePath).toLowerCase()))
64
+ .map((filePath) => {
65
+ const slug = getSlugFromFilePath({
66
+ contentDir,
67
+ filePath,
68
+ recursive,
69
+ });
70
+ const rawContent = fs.readFileSync(filePath, "utf-8");
71
+ const { metadata, content } = parseFrontmatter(rawContent, { filePath, slug }, parseMetadata);
72
+ return {
73
+ metadata,
74
+ slug,
75
+ content,
76
+ filePath,
77
+ };
78
+ });
79
+ if (throwOnDuplicateSlug) {
80
+ const seen = new Map();
81
+ for (const post of posts) {
82
+ const prior = seen.get(post.slug);
83
+ if (prior != null) {
84
+ throw new Error(`Duplicate slug \"${post.slug}\" found in:\n- ${prior}\n- ${post.filePath}`);
85
+ }
86
+ seen.set(post.slug, post.filePath);
87
+ }
88
+ }
89
+ return [...posts].sort(sort);
90
+ }
91
+ const getAllPosts = cache(readAllPosts);
92
+ return {
93
+ getAllPosts,
94
+ getPostBySlug(slug) {
95
+ return getAllPosts().find((post) => post.slug === slug);
96
+ },
97
+ getPostsByCategory(category) {
98
+ return getAllPosts().filter((post) => post.metadata.category === category);
99
+ },
100
+ };
101
+ }
@@ -0,0 +1,7 @@
1
+ export declare function useFilteredPosts<T extends {
2
+ metadata: {
3
+ title: string;
4
+ description: string;
5
+ };
6
+ }>(posts: T[], query: string | null | undefined): T[];
7
+ //# sourceMappingURL=react.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.tsx"],"names":[],"mappings":"AAMA,wBAAgB,gBAAgB,CAC9B,CAAC,SAAS;IAAE,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,EAC9D,KAAK,EAAE,CAAC,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,CAAC,EAAE,CAKnD"}
package/dist/react.js ADDED
@@ -0,0 +1,6 @@
1
+ "use client";
2
+ import { useMemo } from "react";
3
+ import { filterPostsByQuery } from "./core.js";
4
+ export function useFilteredPosts(posts, query) {
5
+ return useMemo(() => filterPostsByQuery(posts, query ?? null), [posts, query]);
6
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "mdx-blog-core",
3
+ "version": "0.1.0",
4
+ "description": "Headless MDX blog utilities for Next.js: filesystem loading, TOC, RSS, neighbours, search filter, LLM markdown shell.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./node": {
14
+ "types": "./dist/node.d.ts",
15
+ "import": "./dist/node.js"
16
+ },
17
+ "./react": {
18
+ "types": "./dist/react.d.ts",
19
+ "import": "./dist/react.js"
20
+ },
21
+ "./next": {
22
+ "types": "./dist/next.d.ts",
23
+ "import": "./dist/next.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "scripts": {
30
+ "build": "tsc -p tsconfig.build.json",
31
+ "check-types": "tsc -p tsconfig.build.json --noEmit"
32
+ },
33
+ "dependencies": {
34
+ "fumadocs-core": "^16.6.8",
35
+ "gray-matter": "^4.0.3"
36
+ },
37
+ "peerDependencies": {
38
+ "next": ">=14",
39
+ "react": ">=18"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "next": {
43
+ "optional": true
44
+ }
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^20.14.12",
48
+ "@types/react": "19.2.10",
49
+ "next": "16.1.6",
50
+ "react": "19.2.4",
51
+ "typescript": "5.8.3"
52
+ }
53
+ }