@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 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
+ }