@nifrajs/content 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +18 -0
- package/package.json +62 -0
- package/src/fs.ts +140 -0
- package/src/index.ts +142 -0
- package/src/mdx.ts +75 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nifra Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# @nifrajs/content
|
|
2
|
+
|
|
3
|
+
Typed, schema-validated content collections for nifra — Markdown + frontmatter, framework-agnostic.
|
|
4
|
+
|
|
5
|
+
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework — one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
bun add @nifrajs/content
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Docs
|
|
14
|
+
|
|
15
|
+
- Reference: <https://nifra.dev/docs>
|
|
16
|
+
- AI-readable: <https://nifra.dev/llms.txt>
|
|
17
|
+
|
|
18
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nifrajs/content",
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "Typed, schema-validated content collections for nifra — Markdown + frontmatter, framework-agnostic.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bun",
|
|
7
|
+
"typescript",
|
|
8
|
+
"nifra",
|
|
9
|
+
"web-framework",
|
|
10
|
+
"full-stack",
|
|
11
|
+
"edge",
|
|
12
|
+
"ssr"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"types": "./src/index.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"bun": "./src/index.ts",
|
|
19
|
+
"types": "./src/index.ts",
|
|
20
|
+
"default": "./src/index.ts"
|
|
21
|
+
},
|
|
22
|
+
"./fs": {
|
|
23
|
+
"bun": "./src/fs.ts",
|
|
24
|
+
"types": "./src/fs.ts",
|
|
25
|
+
"default": "./src/fs.ts"
|
|
26
|
+
},
|
|
27
|
+
"./mdx": {
|
|
28
|
+
"bun": "./src/mdx.ts",
|
|
29
|
+
"types": "./src/mdx.ts",
|
|
30
|
+
"default": "./src/mdx.ts"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"src"
|
|
35
|
+
],
|
|
36
|
+
"sideEffects": false,
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"marked": "^15.0.0",
|
|
43
|
+
"yaml": "^2.6.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@mdx-js/mdx": "^3"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"@mdx-js/mdx": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "git+https://github.com/nifrajs/nifra.git",
|
|
56
|
+
"directory": "packages/content"
|
|
57
|
+
},
|
|
58
|
+
"bugs": {
|
|
59
|
+
"url": "https://github.com/nifrajs/nifra/issues"
|
|
60
|
+
},
|
|
61
|
+
"homepage": "https://github.com/nifrajs/nifra#readme"
|
|
62
|
+
}
|
package/src/fs.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem-backed content collections — `defineCollection` reads a directory of Markdown files and
|
|
3
|
+
* gives you a typed, validated API over them. Build/server-time (Bun/Node/Deno); for the edge, bake the
|
|
4
|
+
* entries at build (the manifest approach `@nifrajs/web/fs` uses for routes) — tracked as a follow-up.
|
|
5
|
+
*/
|
|
6
|
+
import { readdirSync, readFileSync, statSync } from "node:fs"
|
|
7
|
+
import { resolve, sep } from "node:path"
|
|
8
|
+
import { type Entry, type InferSchema, parseEntry, type StandardSchemaV1 } from "./index.ts"
|
|
9
|
+
|
|
10
|
+
export interface CollectionConfig<S extends StandardSchemaV1> {
|
|
11
|
+
/** Directory of content files, relative to the process cwd (or absolute). */
|
|
12
|
+
readonly dir: string
|
|
13
|
+
/** Standard Schema validating each file's frontmatter. */
|
|
14
|
+
readonly schema: S
|
|
15
|
+
/** File extensions to include (default `[".md", ".markdown"]`). */
|
|
16
|
+
readonly extensions?: readonly string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A typed collection over a content directory. */
|
|
20
|
+
export interface Collection<Frontmatter> {
|
|
21
|
+
/** Every entry in the directory (unordered — sort/filter in your loader). Throws if any file's
|
|
22
|
+
* frontmatter is invalid. */
|
|
23
|
+
all(): Promise<Array<Entry<Frontmatter>>>
|
|
24
|
+
/** One entry by slug (filename without extension), or `null` if not found. */
|
|
25
|
+
get(slug: string): Promise<Entry<Frontmatter> | null>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_EXTENSIONS = [".md", ".markdown"] as const
|
|
29
|
+
|
|
30
|
+
const slugOf = (file: string): string => file.replace(/\.[^.]+$/, "")
|
|
31
|
+
|
|
32
|
+
const isSafeSlug = (slug: string): boolean =>
|
|
33
|
+
slug !== "." &&
|
|
34
|
+
slug !== ".." &&
|
|
35
|
+
!slug.includes("/") &&
|
|
36
|
+
!slug.includes("\\") &&
|
|
37
|
+
!slug.includes("\0")
|
|
38
|
+
|
|
39
|
+
const assertSafeExtensions = (extensions: readonly string[]): void => {
|
|
40
|
+
for (const ext of extensions) {
|
|
41
|
+
if (!ext.startsWith(".") || ext.includes("/") || ext.includes("\\") || ext.includes("\0")) {
|
|
42
|
+
throw new Error(`@nifrajs/content: invalid extension "${ext}"`)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const insideRoot = (root: string, file: string): boolean =>
|
|
48
|
+
file !== root && file.startsWith(`${root}${sep}`)
|
|
49
|
+
|
|
50
|
+
interface FileRecord {
|
|
51
|
+
readonly file: string
|
|
52
|
+
readonly path: string
|
|
53
|
+
readonly stamp: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface CachedEntry<Frontmatter> {
|
|
57
|
+
readonly stamp: string
|
|
58
|
+
readonly entry: Entry<Frontmatter>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface CollectionSnapshot<Frontmatter> {
|
|
62
|
+
readonly signature: string
|
|
63
|
+
readonly entries: Array<Entry<Frontmatter>>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Define a content collection backed by a directory. `all()` discovers + parses every matching file;
|
|
68
|
+
* `get(slug)` loads one. Frontmatter is validated against `schema`, so entries are fully typed and a
|
|
69
|
+
* malformed file fails loudly. Reads the filesystem — use it at build time (SSG/prerender) or on a
|
|
70
|
+
* long-lived server (Bun/Node/Deno).
|
|
71
|
+
*/
|
|
72
|
+
export function defineCollection<S extends StandardSchemaV1>(
|
|
73
|
+
config: CollectionConfig<S>,
|
|
74
|
+
): Collection<InferSchema<S>> {
|
|
75
|
+
const extensions = config.extensions ?? DEFAULT_EXTENSIONS
|
|
76
|
+
assertSafeExtensions(extensions)
|
|
77
|
+
const root = resolve(config.dir)
|
|
78
|
+
const matches = (file: string): boolean => extensions.some((ext) => file.endsWith(ext))
|
|
79
|
+
const fileCache = new Map<string, CachedEntry<InferSchema<S>>>()
|
|
80
|
+
let collectionCache: CollectionSnapshot<InferSchema<S>> | undefined
|
|
81
|
+
|
|
82
|
+
const readEntry = async (
|
|
83
|
+
path: string,
|
|
84
|
+
slug: string,
|
|
85
|
+
stamp: string,
|
|
86
|
+
): Promise<Entry<InferSchema<S>>> => {
|
|
87
|
+
const cached = fileCache.get(path)
|
|
88
|
+
if (cached?.stamp === stamp) return cached.entry
|
|
89
|
+
const entry = await parseEntry({
|
|
90
|
+
raw: readFileSync(path, "utf8"),
|
|
91
|
+
slug,
|
|
92
|
+
schema: config.schema,
|
|
93
|
+
})
|
|
94
|
+
fileCache.set(path, { stamp, entry })
|
|
95
|
+
return entry
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const listFiles = (): FileRecord[] =>
|
|
99
|
+
readdirSync(root)
|
|
100
|
+
.filter(matches)
|
|
101
|
+
.sort()
|
|
102
|
+
.map((file) => {
|
|
103
|
+
const path = resolve(root, file)
|
|
104
|
+
const stat = statSync(path)
|
|
105
|
+
return { file, path, stamp: `${stat.mtimeMs}:${stat.size}` }
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
async all() {
|
|
110
|
+
const files = listFiles()
|
|
111
|
+
const signature = files.map((file) => `${file.file}\0${file.stamp}`).join("\0")
|
|
112
|
+
if (collectionCache?.signature === signature) return [...collectionCache.entries]
|
|
113
|
+
|
|
114
|
+
const entries = await Promise.all(
|
|
115
|
+
files.map((file) => readEntry(file.path, slugOf(file.file), file.stamp)),
|
|
116
|
+
)
|
|
117
|
+
collectionCache = {
|
|
118
|
+
signature,
|
|
119
|
+
entries,
|
|
120
|
+
}
|
|
121
|
+
return [...entries]
|
|
122
|
+
},
|
|
123
|
+
async get(slug) {
|
|
124
|
+
if (!isSafeSlug(slug)) return null
|
|
125
|
+
|
|
126
|
+
for (const ext of extensions) {
|
|
127
|
+
const file = resolve(root, `${slug}${ext}`)
|
|
128
|
+
if (!insideRoot(root, file)) return null
|
|
129
|
+
try {
|
|
130
|
+
const stat = statSync(file)
|
|
131
|
+
if (!stat.isFile()) continue
|
|
132
|
+
return await readEntry(file, slug, `${stat.mtimeMs}:${stat.size}`)
|
|
133
|
+
} catch {
|
|
134
|
+
// Not this extension or unreadable; try the next configured extension.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return null
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed content collections for nifra — the **framework-agnostic core**. Parse a Markdown file's
|
|
3
|
+
* frontmatter + body, validate the frontmatter against a Standard Schema (`@nifrajs/schema`'s `t`, zod,
|
|
4
|
+
* valibot), and render the body to HTML. Pure (no filesystem, no DOM) so it runs anywhere — pair it
|
|
5
|
+
* with `@nifrajs/content/fs`'s `defineCollection` for fs-backed discovery on Bun/Node/Deno + at build.
|
|
6
|
+
*
|
|
7
|
+
* import { t } from "@nifrajs/schema"
|
|
8
|
+
* import { defineCollection } from "@nifrajs/content/fs"
|
|
9
|
+
*
|
|
10
|
+
* const blog = defineCollection({
|
|
11
|
+
* dir: "content/blog",
|
|
12
|
+
* schema: t.object({ title: t.string(), date: t.string(), draft: t.boolean() }),
|
|
13
|
+
* })
|
|
14
|
+
*
|
|
15
|
+
* // in a loader — typed + validated entries, no manual fs/frontmatter parsing:
|
|
16
|
+
* const posts = (await blog.all()).filter((p) => !p.frontmatter.draft)
|
|
17
|
+
* // posts[0].frontmatter is { title: string; date: string; draft: boolean }
|
|
18
|
+
* // posts[0].html is the rendered Markdown
|
|
19
|
+
*/
|
|
20
|
+
import { marked } from "marked"
|
|
21
|
+
import { parse as parseYaml } from "yaml"
|
|
22
|
+
|
|
23
|
+
/** A Standard Schema issue (the subset we surface). */
|
|
24
|
+
interface StandardIssue {
|
|
25
|
+
readonly message: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Minimal [Standard Schema](https://standardschema.dev) shape — lets frontmatter validate against any
|
|
30
|
+
* compliant validator (`@nifrajs/schema`'s `t`, zod, valibot, …) without coupling `@nifrajs/content` to one.
|
|
31
|
+
*/
|
|
32
|
+
export interface StandardSchemaV1<Output = unknown> {
|
|
33
|
+
readonly "~standard": {
|
|
34
|
+
readonly validate: (
|
|
35
|
+
value: unknown,
|
|
36
|
+
) =>
|
|
37
|
+
| { readonly value: Output; readonly issues?: undefined }
|
|
38
|
+
| { readonly issues: ReadonlyArray<StandardIssue> }
|
|
39
|
+
| Promise<
|
|
40
|
+
| { readonly value: Output; readonly issues?: undefined }
|
|
41
|
+
| { readonly issues: ReadonlyArray<StandardIssue> }
|
|
42
|
+
>
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The validated output type of a schema. */
|
|
47
|
+
export type InferSchema<S> = S extends StandardSchemaV1<infer Output> ? Output : never
|
|
48
|
+
|
|
49
|
+
/** A parsed content entry: its slug, validated frontmatter, rendered HTML, and the raw Markdown body. */
|
|
50
|
+
export interface Entry<Frontmatter> {
|
|
51
|
+
/** Identifier (e.g. the filename without extension). */
|
|
52
|
+
readonly slug: string
|
|
53
|
+
/** Frontmatter, validated + typed by the collection's schema. */
|
|
54
|
+
readonly frontmatter: Frontmatter
|
|
55
|
+
/** The body rendered to HTML (Markdown → HTML). */
|
|
56
|
+
readonly html: string
|
|
57
|
+
/** The raw Markdown body, with the frontmatter block stripped. */
|
|
58
|
+
readonly body: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A leading `---` … `---` YAML frontmatter block. Tolerates CRLF and an optional trailing newline.
|
|
62
|
+
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/
|
|
63
|
+
|
|
64
|
+
/** Split a raw content string into its (unvalidated) frontmatter data + the body. No frontmatter block
|
|
65
|
+
* ⇒ `data` is `{}` and `body` is the whole input. */
|
|
66
|
+
export function parseFrontmatter(raw: string): { data: unknown; body: string } {
|
|
67
|
+
const match = FRONTMATTER.exec(raw)
|
|
68
|
+
if (match === null) return { data: {}, body: raw }
|
|
69
|
+
const data = (parseYaml(match[1] ?? "") as unknown) ?? {}
|
|
70
|
+
return { data, body: raw.slice(match[0].length) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ParseEntryOptions<S extends StandardSchemaV1> {
|
|
74
|
+
readonly raw: string
|
|
75
|
+
readonly slug: string
|
|
76
|
+
/** Standard Schema validating the frontmatter — a typo'd/missing field throws (a build-time failure,
|
|
77
|
+
* never a broken page). */
|
|
78
|
+
readonly schema: S
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parse one content file: split + validate its frontmatter against `schema`, render its Markdown body
|
|
83
|
+
* to HTML. Throws a descriptive error (naming the slug + the issues) when the frontmatter is invalid —
|
|
84
|
+
* surface it at build/load time rather than shipping a malformed entry. Pure + edge-safe.
|
|
85
|
+
*/
|
|
86
|
+
export async function parseEntry<S extends StandardSchemaV1>(
|
|
87
|
+
options: ParseEntryOptions<S>,
|
|
88
|
+
): Promise<Entry<InferSchema<S>>> {
|
|
89
|
+
const { data, body } = parseFrontmatter(options.raw)
|
|
90
|
+
const result = await options.schema["~standard"].validate(data)
|
|
91
|
+
if (result.issues !== undefined) {
|
|
92
|
+
const detail = result.issues.map((issue) => issue.message).join("; ")
|
|
93
|
+
throw new Error(`@nifrajs/content: invalid frontmatter in "${options.slug}": ${detail}`)
|
|
94
|
+
}
|
|
95
|
+
// `marked.parse` is sync by default but may be async with extensions — await covers both. Content is
|
|
96
|
+
// author-controlled (your own files), so raw HTML in Markdown is passed through (standard SSG
|
|
97
|
+
// behavior); sanitize yourself if a collection ever holds untrusted input.
|
|
98
|
+
const html = await marked.parse(body)
|
|
99
|
+
return { slug: options.slug, frontmatter: result.value as InferSchema<S>, html, body }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A content collection baked to plain data — fs-free, so it works at the **edge** (Workers
|
|
104
|
+
* request-time) where `defineCollection`'s `node:fs` reader can't run. Produce one at build/server time
|
|
105
|
+
* with `bakeCollection`, JSON-serialize + ship it in the bundle, then rehydrate with `fromBaked`.
|
|
106
|
+
*/
|
|
107
|
+
export interface BakedCollection<Frontmatter> {
|
|
108
|
+
readonly entries: ReadonlyArray<Entry<Frontmatter>>
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Read-only collection surface (`all()`/`get()`) — structurally compatible with `defineCollection`'s
|
|
112
|
+
* `Collection`, but with no filesystem access. */
|
|
113
|
+
export interface StaticCollection<Frontmatter> {
|
|
114
|
+
all(): Promise<ReadonlyArray<Entry<Frontmatter>>>
|
|
115
|
+
get(slug: string): Promise<Entry<Frontmatter> | null>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Bake a collection's entries to serializable data at build/server time. The collection does the
|
|
120
|
+
* filesystem read + validation (via `all()`); this just collects the already-parsed result so it can be
|
|
121
|
+
* JSON-serialized into the edge bundle. Pure — safe to import anywhere.
|
|
122
|
+
*/
|
|
123
|
+
export async function bakeCollection<Frontmatter>(collection: {
|
|
124
|
+
all(): Promise<ReadonlyArray<Entry<Frontmatter>>>
|
|
125
|
+
}): Promise<BakedCollection<Frontmatter>> {
|
|
126
|
+
return { entries: await collection.all() }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Rehydrate a baked collection into a read-only `all()`/`get()` collection — fs-free, edge-safe. The
|
|
131
|
+
* entries were validated when baked (build output, trusted), so they're served as-is. `get` is O(1).
|
|
132
|
+
*/
|
|
133
|
+
export function fromBaked<Frontmatter>(
|
|
134
|
+
baked: BakedCollection<Frontmatter>,
|
|
135
|
+
): StaticCollection<Frontmatter> {
|
|
136
|
+
const entries = baked.entries
|
|
137
|
+
const bySlug = new Map(entries.map((entry) => [entry.slug, entry]))
|
|
138
|
+
return {
|
|
139
|
+
all: () => Promise.resolve(entries),
|
|
140
|
+
get: (slug) => Promise.resolve(bySlug.get(slug) ?? null),
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/mdx.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mdxBunPlugin` — a `Bun.build` plugin that compiles `.mdx` files (Markdown + JSX components) into
|
|
3
|
+
* components for your UI framework, so an `.mdx` file can be a nifra route or be imported like a module.
|
|
4
|
+
* Pass it to `buildClient`/`buildServer`'s `plugins`, and add `.mdx` to your route discovery.
|
|
5
|
+
*
|
|
6
|
+
* import { buildClient } from "@nifrajs/web/build"
|
|
7
|
+
* import { mdxBunPlugin } from "@nifrajs/content/mdx"
|
|
8
|
+
*
|
|
9
|
+
* await buildClient({
|
|
10
|
+
* routesDir: "./routes",
|
|
11
|
+
* clientModule: "@nifrajs/web-react/client",
|
|
12
|
+
* plugins: [mdxBunPlugin({ jsxImportSource: "react" })],
|
|
13
|
+
* })
|
|
14
|
+
*
|
|
15
|
+
* MDX is JSX-oriented, so this targets the JSX family (React/Preact/Solid) via `jsxImportSource`. Needs
|
|
16
|
+
* `@mdx-js/mdx` installed (an optional peer — lazy-loaded at build time only).
|
|
17
|
+
*/
|
|
18
|
+
import type { BunPlugin } from "bun"
|
|
19
|
+
|
|
20
|
+
export interface MdxPluginOptions {
|
|
21
|
+
/** JSX runtime source the compiled MDX imports from — `"react"` (default), `"preact"`, `"solid-js"`. */
|
|
22
|
+
readonly jsxImportSource?: string
|
|
23
|
+
/** Emit development JSX (line numbers, `jsxDEV`). Default `false`. */
|
|
24
|
+
readonly development?: boolean
|
|
25
|
+
/** Extra `@mdx-js/mdx` `compile` options — e.g. `remarkPlugins`/`rehypePlugins` (GFM, slugs, …). */
|
|
26
|
+
readonly compileOptions?: Record<string, unknown>
|
|
27
|
+
/** Advanced: override the compiler module specifier (default `"@mdx-js/mdx"`) — e.g. to use a fork. */
|
|
28
|
+
readonly moduleName?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Non-literal specifier so tsc/consumers don't need `@mdx-js/mdx`'s types to typecheck this file — it's
|
|
32
|
+
// an optional peer, imported only when the plugin actually runs (mirrors `@nifrajs/node`'s lazy `ws`).
|
|
33
|
+
const MDX_MODULE = "@mdx-js/mdx"
|
|
34
|
+
|
|
35
|
+
interface MdxCompiler {
|
|
36
|
+
compile(source: string, options?: Record<string, unknown>): Promise<{ toString(): string }>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build a `Bun.build` plugin that loads `.mdx` files as compiled components. The compiled module's
|
|
41
|
+
* default export is the MDX content component; `.mdx` files may `import` and use components inline and
|
|
42
|
+
* `export const meta = …` like any route module.
|
|
43
|
+
*/
|
|
44
|
+
export function mdxBunPlugin(options: MdxPluginOptions = {}): BunPlugin {
|
|
45
|
+
const jsxImportSource = options.jsxImportSource ?? "react"
|
|
46
|
+
const development = options.development ?? false
|
|
47
|
+
return {
|
|
48
|
+
name: "nifra-mdx",
|
|
49
|
+
async setup(build) {
|
|
50
|
+
let compile: MdxCompiler["compile"]
|
|
51
|
+
try {
|
|
52
|
+
const mod = (await import(options.moduleName ?? MDX_MODULE)) as MdxCompiler
|
|
53
|
+
compile = mod.compile
|
|
54
|
+
} catch {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"@nifrajs/content/mdx: compiling `.mdx` needs the `@mdx-js/mdx` package — install it (`bun add @mdx-js/mdx`).",
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
// Tolerate a `?query` suffix — `nifra dev` (Vite) appends an import-cache-busting query so edited
|
|
60
|
+
// `.mdx` re-SSR; strip it before reading off disk.
|
|
61
|
+
build.onLoad({ filter: /\.mdx(\?|$)/ }, async (args) => {
|
|
62
|
+
const path = args.path.split("?")[0] ?? args.path
|
|
63
|
+
const source = await Bun.file(path).text()
|
|
64
|
+
const compiled = await compile(source, {
|
|
65
|
+
jsxImportSource,
|
|
66
|
+
development,
|
|
67
|
+
...options.compileOptions,
|
|
68
|
+
})
|
|
69
|
+
// `loader: "jsx"` — the compiled output still contains JSX; Bun applies the JSX transform with
|
|
70
|
+
// the active `jsxImportSource`. Markup is hydration-equivalent to a hand-written component.
|
|
71
|
+
return { contents: String(compiled), loader: "jsx" }
|
|
72
|
+
})
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|