@aaronellington/vite-plugin-inkwell 0.0.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 Aaron Ellington
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,48 @@
1
+ # @aaronellington/vite-plugin-inkwell
2
+
3
+ [![CI](https://github.com/aaronellington/vite-plugin-inkwell/actions/workflows/ci.yml/badge.svg)](https://github.com/aaronellington/vite-plugin-inkwell/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/@aaronellington/vite-plugin-inkwell)](https://www.npmjs.com/package/@aaronellington/vite-plugin-inkwell)
5
+ [![license](https://img.shields.io/npm/l/@aaronellington/vite-plugin-inkwell)](./LICENSE)
6
+
7
+ A Vite plugin that transforms directories of markdown files into typed, lazy-loaded content collections with frontmatter parsing, asset hashing, and HMR.
8
+
9
+ ## Setup
10
+
11
+ Install the plugin:
12
+
13
+ ```bash
14
+ npm install @aaronellington/vite-plugin-inkwell
15
+ ```
16
+
17
+ Register it in `vite.config.ts`:
18
+
19
+ ```typescript
20
+ import { inkwell } from "@aaronellington/vite-plugin-inkwell";
21
+
22
+ export default defineConfig({
23
+ plugins: [inkwell()],
24
+ });
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ Import a content directory using the `inkwell:` prefix. The path resolves relative to the importing file:
30
+
31
+ ```typescript
32
+ import collection from "inkwell:./content";
33
+ ```
34
+
35
+ Multiple collections are supported:
36
+
37
+ ```typescript
38
+ import blog from "inkwell:./blog";
39
+ import tutorials from "inkwell:./tutorials";
40
+ ```
41
+
42
+ ## Draft Mode
43
+
44
+ Posts with `draft: true` are excluded from production builds but included during development. Override with the `includeDrafts` option.
45
+
46
+ ## HMR
47
+
48
+ Editing or adding markdown files triggers a full page reload in development. The plugin watches all imported content directories automatically.
package/content.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { ContentItem } from "./src/types.ts"
2
+
3
+ declare module "inkwell:*" {
4
+ const collection: ContentItem[]
5
+ export default collection
6
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@aaronellington/vite-plugin-inkwell",
3
+ "version": "0.0.1",
4
+ "description": "A Vite plugin that transforms directories of markdown files into typed, lazy-loaded content collections with frontmatter parsing, asset hashing, and HMR.",
5
+ "license": "MIT",
6
+ "author": "Aaron Ellington",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/aaronellington/vite-plugin-inkwell.git"
10
+ },
11
+ "homepage": "https://github.com/aaronellington/vite-plugin-inkwell#readme",
12
+ "bugs": "https://github.com/aaronellington/vite-plugin-inkwell/issues",
13
+ "keywords": [
14
+ "vite",
15
+ "vite-plugin",
16
+ "markdown",
17
+ "content",
18
+ "frontmatter",
19
+ "blog"
20
+ ],
21
+ "type": "module",
22
+ "exports": {
23
+ ".": "./src/index.ts"
24
+ },
25
+ "files": [
26
+ "src/*.ts",
27
+ "content.d.ts"
28
+ ],
29
+ "engines": {
30
+ "node": ">=22"
31
+ },
32
+ "dependencies": {
33
+ "gray-matter": "^4.0.3",
34
+ "marked": "^17.0.0",
35
+ "smol-toml": "^1.3.0"
36
+ },
37
+ "devDependencies": {
38
+ "@biomejs/biome": "^2.4.5",
39
+ "@playwright/test": "^1.52.0",
40
+ "@types/node": "^25.3.3",
41
+ "prettier": "^3.8.1",
42
+ "sirv": "^3.0.2",
43
+ "typescript": "^5.9.3",
44
+ "vite": "^7.1.2"
45
+ },
46
+ "peerDependencies": {
47
+ "vite": "^7.0.0"
48
+ },
49
+ "scripts": {
50
+ "lint": "tsc -b && biome check . && prettier --check .",
51
+ "fix": "biome check --write . && prettier --write .",
52
+ "test": "playwright test",
53
+ "test:install": "playwright install chromium"
54
+ }
55
+ }
package/src/assets.ts ADDED
@@ -0,0 +1,84 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import type { AssetReference } from "./types.ts"
4
+
5
+ const ASSET_REGEX =
6
+ /(?:src|href|poster)=["'](?!(?:https?:|data:|#|\/\/|\/))([^"']+)["']/g
7
+
8
+ export function extractAssetReferences(
9
+ html: string,
10
+ mdFilePath: string,
11
+ ): AssetReference[] {
12
+ const mdDir = path.dirname(mdFilePath)
13
+ const assets: AssetReference[] = []
14
+ const seen = new Set<string>()
15
+
16
+ ASSET_REGEX.lastIndex = 0
17
+ for (
18
+ let match = ASSET_REGEX.exec(html);
19
+ match !== null;
20
+ match = ASSET_REGEX.exec(html)
21
+ ) {
22
+ const originalPath = match[1]
23
+ if (seen.has(originalPath)) continue
24
+ seen.add(originalPath)
25
+
26
+ const absolutePath = path.resolve(mdDir, originalPath)
27
+
28
+ if (!fs.existsSync(absolutePath)) {
29
+ throw new Error(
30
+ `Missing asset referenced in ${mdFilePath}: "${originalPath}"\n` +
31
+ `Resolved to: ${absolutePath}`,
32
+ )
33
+ }
34
+
35
+ const placeholderToken = `__CONTENT_ASSET_${assets.length}__`
36
+ assets.push({ absolutePath, originalPath, placeholderToken })
37
+ }
38
+
39
+ return assets
40
+ }
41
+
42
+ export function replaceAssetsWithPlaceholders(
43
+ html: string,
44
+ assets: AssetReference[],
45
+ ): string {
46
+ let result = html
47
+ for (const asset of assets) {
48
+ result = result.split(asset.originalPath).join(asset.placeholderToken)
49
+ }
50
+ return result
51
+ }
52
+
53
+ export function generateSlugModuleCode(
54
+ html: string,
55
+ assets: AssetReference[],
56
+ ): string {
57
+ if (assets.length === 0) {
58
+ return `export default ${JSON.stringify(html)};`
59
+ }
60
+
61
+ const lines: string[] = []
62
+
63
+ for (let i = 0; i < assets.length; i++) {
64
+ const asset = assets[i]
65
+ lines.push(
66
+ `import __asset_${i}__ from ${JSON.stringify(asset.absolutePath)};`,
67
+ )
68
+ }
69
+
70
+ lines.push("")
71
+ lines.push(`let html = ${JSON.stringify(html)};`)
72
+
73
+ for (let i = 0; i < assets.length; i++) {
74
+ const asset = assets[i]
75
+ lines.push(
76
+ `html = html.replaceAll(${JSON.stringify(asset.placeholderToken)}, __asset_${i}__);`,
77
+ )
78
+ }
79
+
80
+ lines.push("")
81
+ lines.push("export default html;")
82
+
83
+ return lines.join("\n")
84
+ }
package/src/content.ts ADDED
@@ -0,0 +1,143 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import matter from "gray-matter"
4
+ import type { MarkedExtension } from "marked"
5
+ import { Marked } from "marked"
6
+ import { parse as parseToml } from "smol-toml"
7
+ import type { ContentFrontmatter, ParsedContentItem } from "./types.ts"
8
+
9
+ const matterOptions: matter.GrayMatterOption<string, any> = {
10
+ engines: {
11
+ toml: {
12
+ parse: parseToml as unknown as (input: string) => Record<string, unknown>,
13
+ stringify: () => {
14
+ throw new Error("TOML stringify not supported")
15
+ },
16
+ },
17
+ },
18
+ }
19
+
20
+ export function scanDirectory(dir: string, recursive: boolean): string[] {
21
+ const results: string[] = []
22
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
23
+
24
+ for (const entry of entries) {
25
+ const fullPath = path.join(dir, entry.name)
26
+ if (entry.isDirectory() && recursive) {
27
+ results.push(...scanDirectory(fullPath, recursive))
28
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
29
+ results.push(fullPath)
30
+ }
31
+ }
32
+
33
+ return results
34
+ }
35
+
36
+ export function parseFrontmatter(
37
+ fileContent: string,
38
+ filePath: string,
39
+ ): { data: Record<string, unknown>; content: string } {
40
+ try {
41
+ const result = matter(fileContent, matterOptions)
42
+ return { content: result.content, data: result.data }
43
+ } catch (error) {
44
+ const message = error instanceof Error ? error.message : String(error)
45
+ throw new Error(`Invalid frontmatter in ${filePath}: ${message}`)
46
+ }
47
+ }
48
+
49
+ export function computeSlug(
50
+ frontmatter: Record<string, unknown>,
51
+ filePath: string,
52
+ ): string {
53
+ if (
54
+ typeof frontmatter.slug === "string" &&
55
+ frontmatter.slug.trim().length > 0
56
+ ) {
57
+ return frontmatter.slug.trim()
58
+ }
59
+ return path.basename(filePath, ".md")
60
+ }
61
+
62
+ export function checkDuplicateSlugs(items: ParsedContentItem[]): void {
63
+ const seen = new Map<string, string>()
64
+ for (const item of items) {
65
+ const existing = seen.get(item.frontmatter.slug)
66
+ if (existing) {
67
+ throw new Error(
68
+ `Duplicate slug "${item.frontmatter.slug}" found in:\n` +
69
+ ` - ${existing}\n` +
70
+ ` - ${item.filePath}\n` +
71
+ `Provide an explicit "slug" in frontmatter to resolve.`,
72
+ )
73
+ }
74
+ seen.set(item.frontmatter.slug, item.filePath)
75
+ }
76
+ }
77
+
78
+ const FILE_EXT_REGEX = /\.\w{1,10}$/
79
+
80
+ const assetLinkExtension: MarkedExtension = {
81
+ renderer: {
82
+ link({ href, text }) {
83
+ if (href && FILE_EXT_REGEX.test(href)) {
84
+ return `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`
85
+ }
86
+ return false
87
+ },
88
+ },
89
+ }
90
+
91
+ export function createRenderer(extensions: MarkedExtension[]): Marked {
92
+ const marked = new Marked()
93
+ marked.use(assetLinkExtension)
94
+ if (extensions.length > 0) {
95
+ marked.use(...extensions)
96
+ }
97
+ return marked
98
+ }
99
+
100
+ export function parseContentFile(
101
+ filePath: string,
102
+ baseDir: string,
103
+ renderer: Marked,
104
+ validate?: (frontmatter: Record<string, unknown>, filePath: string) => void,
105
+ ): ParsedContentItem {
106
+ const fileContent = fs.readFileSync(filePath, "utf-8")
107
+ const { data, content } = parseFrontmatter(fileContent, filePath)
108
+
109
+ if (validate) {
110
+ validate(data, filePath)
111
+ }
112
+
113
+ const slug = computeSlug(data, filePath)
114
+ const directoryPath = path.relative(baseDir, path.dirname(filePath))
115
+
116
+ const html = renderer.parse(content)
117
+ if (typeof html !== "string") {
118
+ throw new Error(
119
+ `Async marked extensions are not supported. File: ${filePath}`,
120
+ )
121
+ }
122
+
123
+ const frontmatter: ContentFrontmatter = {
124
+ ...data,
125
+ date:
126
+ typeof data.date === "string"
127
+ ? data.date
128
+ : data.date instanceof Date
129
+ ? data.date.toISOString()
130
+ : "",
131
+ draft: data.draft === true,
132
+ slug,
133
+ title: typeof data.title === "string" ? data.title : "",
134
+ }
135
+
136
+ return {
137
+ assets: [],
138
+ directoryPath,
139
+ filePath,
140
+ frontmatter,
141
+ html,
142
+ }
143
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { inkwell } from "./plugin.ts"
2
+ export type {
3
+ ContentFrontmatter,
4
+ ContentItem,
5
+ ContentPluginOptions,
6
+ ParsedContentItem,
7
+ } from "./types.ts"
package/src/plugin.ts ADDED
@@ -0,0 +1,247 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import type { Plugin, ResolvedConfig, ViteDevServer } from "vite"
4
+ import {
5
+ extractAssetReferences,
6
+ generateSlugModuleCode,
7
+ replaceAssetsWithPlaceholders,
8
+ } from "./assets.ts"
9
+ import {
10
+ checkDuplicateSlugs,
11
+ createRenderer,
12
+ parseContentFile,
13
+ scanDirectory,
14
+ } from "./content.ts"
15
+ import type { ContentPluginOptions, ParsedContentItem } from "./types.ts"
16
+
17
+ const CONTENT_PREFIX = "inkwell:"
18
+ const RESOLVED_PREFIX = "\0inkwell:"
19
+ const SLUG_SEPARATOR = "/"
20
+
21
+ export function inkwell(options?: ContentPluginOptions): Plugin {
22
+ const opts = options ?? {}
23
+ let config: ResolvedConfig
24
+ let server: ViteDevServer | undefined
25
+ let isProduction = false
26
+
27
+ const renderer = createRenderer(opts.markedExtensions ?? [])
28
+
29
+ // Map from absolute directory path to its parsed content items
30
+ const collections = new Map<string, ParsedContentItem[]>()
31
+ // Track which directories are in use for HMR
32
+ const watchedDirs = new Set<string>()
33
+
34
+ function buildCollection(absoluteDir: string): ParsedContentItem[] {
35
+ if (!fs.existsSync(absoluteDir)) {
36
+ throw new Error(`Content directory does not exist: ${absoluteDir}`)
37
+ }
38
+
39
+ const recursive = opts.recursive !== false
40
+ const allFiles = scanDirectory(absoluteDir, recursive)
41
+ const items: ParsedContentItem[] = []
42
+
43
+ for (const filePath of allFiles) {
44
+ const item = parseContentFile(
45
+ filePath,
46
+ absoluteDir,
47
+ renderer,
48
+ opts.validate,
49
+ )
50
+
51
+ const assets = extractAssetReferences(item.html, filePath)
52
+ item.assets = assets
53
+ item.html = replaceAssetsWithPlaceholders(item.html, assets)
54
+
55
+ items.push(item)
56
+ }
57
+
58
+ checkDuplicateSlugs(items)
59
+ return items
60
+ }
61
+
62
+ function getVisibleItems(items: ParsedContentItem[]): ParsedContentItem[] {
63
+ if (isProduction && !opts.includeDrafts) {
64
+ return items.filter((item) => !item.frontmatter.draft)
65
+ }
66
+ return items
67
+ }
68
+
69
+ function generateCollectionModule(absoluteDir: string): string {
70
+ const allItems = collections.get(absoluteDir)
71
+ if (!allItems) {
72
+ throw new Error(`No content collection for directory: ${absoluteDir}`)
73
+ }
74
+
75
+ const items = getVisibleItems(allItems)
76
+ const slugPrefix = CONTENT_PREFIX + absoluteDir + SLUG_SEPARATOR
77
+
78
+ const entries = items.map((item) => {
79
+ const meta: Record<string, unknown> = { ...item.frontmatter }
80
+ delete meta.title
81
+ delete meta.slug
82
+ delete meta.date
83
+ delete meta.draft
84
+
85
+ return [
86
+ " {",
87
+ ` title: ${JSON.stringify(item.frontmatter.title)},`,
88
+ ` slug: ${JSON.stringify(item.frontmatter.slug)},`,
89
+ ` date: ${JSON.stringify(item.frontmatter.date)},`,
90
+ ` draft: ${JSON.stringify(item.frontmatter.draft)},`,
91
+ ` directory: ${JSON.stringify(item.directoryPath)},`,
92
+ ` meta: ${JSON.stringify(meta)},`,
93
+ ` getHtml: () => import(${JSON.stringify(slugPrefix + item.frontmatter.slug)}).then(m => m.default),`,
94
+ " }",
95
+ ].join("\n")
96
+ })
97
+
98
+ return `export default [\n${entries.join(",\n")}\n];\n`
99
+ }
100
+
101
+ function findItemBySlug(
102
+ absoluteDir: string,
103
+ slug: string,
104
+ ): ParsedContentItem | undefined {
105
+ const items = collections.get(absoluteDir)
106
+ return items?.find((i) => i.frontmatter.slug === slug)
107
+ }
108
+
109
+ return {
110
+ configResolved(resolvedConfig) {
111
+ config = resolvedConfig
112
+ isProduction = resolvedConfig.command === "build"
113
+ },
114
+
115
+ configureServer(devServer) {
116
+ server = devServer
117
+ },
118
+ enforce: "pre",
119
+
120
+ handleHotUpdate(ctx) {
121
+ const { file, server: hmrServer } = ctx
122
+ if (!file.endsWith(".md")) return
123
+
124
+ // Find which watched directory this file belongs to
125
+ let matchedDir: string | undefined
126
+ for (const dir of watchedDirs) {
127
+ if (file.startsWith(dir + path.sep) || file.startsWith(`${dir}/`)) {
128
+ matchedDir = dir
129
+ break
130
+ }
131
+ }
132
+
133
+ if (!matchedDir) return
134
+
135
+ try {
136
+ const items = buildCollection(matchedDir)
137
+ collections.set(matchedDir, items)
138
+ } catch (error) {
139
+ const message = error instanceof Error ? error.message : String(error)
140
+ hmrServer.config.logger.error(message)
141
+ return []
142
+ }
143
+
144
+ // Invalidate the collection module
145
+ const collectionId = RESOLVED_PREFIX + matchedDir
146
+ const collectionModule = hmrServer.moduleGraph.getModuleById(collectionId)
147
+ if (collectionModule) {
148
+ hmrServer.moduleGraph.invalidateModule(collectionModule)
149
+ }
150
+
151
+ // Invalidate the changed file's slug module
152
+ const items = collections.get(matchedDir)
153
+ const changedItem = items?.find((item) => item.filePath === file)
154
+ if (changedItem) {
155
+ const slugId =
156
+ RESOLVED_PREFIX +
157
+ matchedDir +
158
+ SLUG_SEPARATOR +
159
+ changedItem.frontmatter.slug
160
+ const slugModule = hmrServer.moduleGraph.getModuleById(slugId)
161
+ if (slugModule) {
162
+ hmrServer.moduleGraph.invalidateModule(slugModule)
163
+ }
164
+ }
165
+
166
+ hmrServer.hot.send({ type: "full-reload" })
167
+ return []
168
+ },
169
+
170
+ load(id) {
171
+ if (!id.startsWith(RESOLVED_PREFIX)) return null
172
+
173
+ const rest = id.slice(RESOLVED_PREFIX.length)
174
+
175
+ // Check if this is a slug module (contains a slug after the directory path)
176
+ // Slug modules: \0content:/abs/path/to/dir/my-slug
177
+ // Collection modules: \0content:/abs/path/to/dir
178
+ for (const [absoluteDir, items] of collections) {
179
+ const dirPrefix = absoluteDir + SLUG_SEPARATOR
180
+ if (rest.startsWith(dirPrefix) && rest.length > dirPrefix.length) {
181
+ const slug = rest.slice(dirPrefix.length)
182
+ const item = items.find((i) => i.frontmatter.slug === slug)
183
+ if (!item) {
184
+ throw new Error(`Content item with slug "${slug}" not found`)
185
+ }
186
+ return generateSlugModuleCode(item.html, item.assets)
187
+ }
188
+
189
+ if (rest === absoluteDir) {
190
+ return generateCollectionModule(absoluteDir)
191
+ }
192
+ }
193
+
194
+ // If we get here, the collection hasn't been built yet
195
+ // This happens on first load — build it now
196
+ if (rest.includes(SLUG_SEPARATOR)) {
197
+ // Try to find the directory portion
198
+ // Walk backward from the end to find a valid directory
199
+ const lastSlash = rest.lastIndexOf(SLUG_SEPARATOR)
200
+ const possibleDir = rest.slice(0, lastSlash)
201
+ const slug = rest.slice(lastSlash + 1)
202
+
203
+ if (collections.has(possibleDir)) {
204
+ const item = findItemBySlug(possibleDir, slug)
205
+ if (!item) {
206
+ throw new Error(`Content item with slug "${slug}" not found`)
207
+ }
208
+ return generateSlugModuleCode(item.html, item.assets)
209
+ }
210
+ }
211
+
212
+ return null
213
+ },
214
+ name: "inkwell",
215
+
216
+ resolveId(source, importer) {
217
+ if (!source.startsWith(CONTENT_PREFIX)) return null
218
+
219
+ const rawPath = source.slice(CONTENT_PREFIX.length)
220
+
221
+ // If the path is already absolute (resolved from a slug module import), use it directly
222
+ if (path.isAbsolute(rawPath)) {
223
+ return RESOLVED_PREFIX + rawPath
224
+ }
225
+
226
+ // Resolve relative to the importer's directory
227
+ const importerDir = importer ? path.dirname(importer) : config.root
228
+ // Strip the \0 prefix from importer if it's a virtual module
229
+ const cleanImporterDir = importerDir.replace(/^\0/, "")
230
+ const absoluteDir = path.resolve(cleanImporterDir, rawPath)
231
+
232
+ // Build the collection if we haven't yet
233
+ if (!collections.has(absoluteDir)) {
234
+ const items = buildCollection(absoluteDir)
235
+ collections.set(absoluteDir, items)
236
+
237
+ // Watch directory for HMR
238
+ if (server) {
239
+ server.watcher.add(absoluteDir)
240
+ }
241
+ watchedDirs.add(absoluteDir)
242
+ }
243
+
244
+ return RESOLVED_PREFIX + absoluteDir
245
+ },
246
+ }
247
+ }
package/src/types.ts ADDED
@@ -0,0 +1,53 @@
1
+ import type { MarkedExtension } from "marked"
2
+
3
+ export interface ContentPluginOptions {
4
+ /** Whether to recursively scan subdirectories (default: true) */
5
+ recursive?: boolean
6
+ /** Custom frontmatter validation function. Throw to fail build. */
7
+ validate?: (frontmatter: Record<string, unknown>, filePath: string) => void
8
+ /** Marked extensions for custom markdown rendering */
9
+ markedExtensions?: MarkedExtension[]
10
+ /** Whether to include draft posts in production (default: false) */
11
+ includeDrafts?: boolean
12
+ }
13
+
14
+ export interface ContentFrontmatter {
15
+ title: string
16
+ slug: string
17
+ date: string
18
+ draft: boolean
19
+ [key: string]: unknown
20
+ }
21
+
22
+ export interface AssetReference {
23
+ /** Original relative path as written in the markdown */
24
+ originalPath: string
25
+ /** Absolute filesystem path */
26
+ absolutePath: string
27
+ /** Placeholder token used in the HTML template string */
28
+ placeholderToken: string
29
+ }
30
+
31
+ export interface ParsedContentItem {
32
+ frontmatter: ContentFrontmatter
33
+ filePath: string
34
+ /** Directory path relative to the configured content directory */
35
+ directoryPath: string
36
+ /** Rendered HTML with placeholder tokens for assets */
37
+ html: string
38
+ /** Asset references discovered in this content */
39
+ assets: AssetReference[]
40
+ }
41
+
42
+ export interface ContentItem {
43
+ title: string
44
+ slug: string
45
+ date: string
46
+ draft: boolean
47
+ /** Relative directory path within the content source */
48
+ directory: string
49
+ /** All frontmatter key-value pairs (excluding title, slug, date, draft) */
50
+ meta: Record<string, unknown>
51
+ /** Lazy-load the rendered HTML for this content item */
52
+ getHtml: () => Promise<string>
53
+ }