@jasonshimmy/vite-plugin-cer-app 0.19.2 → 0.20.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.
Files changed (115) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/commits.txt +2 -1
  3. package/dist/cli/commands/preview.d.ts.map +1 -1
  4. package/dist/cli/commands/preview.js +2 -0
  5. package/dist/cli/commands/preview.js.map +1 -1
  6. package/dist/cli/create/templates/spa/package.json.tpl +2 -2
  7. package/dist/cli/create/templates/ssg/package.json.tpl +2 -2
  8. package/dist/cli/create/templates/ssr/package.json.tpl +2 -2
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/plugin/build-ssg.d.ts.map +1 -1
  12. package/dist/plugin/build-ssg.js +11 -0
  13. package/dist/plugin/build-ssg.js.map +1 -1
  14. package/dist/plugin/content/emitter.d.ts +19 -0
  15. package/dist/plugin/content/emitter.d.ts.map +1 -0
  16. package/dist/plugin/content/emitter.js +42 -0
  17. package/dist/plugin/content/emitter.js.map +1 -0
  18. package/dist/plugin/content/index.d.ts +32 -0
  19. package/dist/plugin/content/index.d.ts.map +1 -0
  20. package/dist/plugin/content/index.js +199 -0
  21. package/dist/plugin/content/index.js.map +1 -0
  22. package/dist/plugin/content/parser.d.ts +18 -0
  23. package/dist/plugin/content/parser.d.ts.map +1 -0
  24. package/dist/plugin/content/parser.js +158 -0
  25. package/dist/plugin/content/parser.js.map +1 -0
  26. package/dist/plugin/content/path-utils.d.ts +19 -0
  27. package/dist/plugin/content/path-utils.d.ts.map +1 -0
  28. package/dist/plugin/content/path-utils.js +40 -0
  29. package/dist/plugin/content/path-utils.js.map +1 -0
  30. package/dist/plugin/content/scanner.d.ts +12 -0
  31. package/dist/plugin/content/scanner.d.ts.map +1 -0
  32. package/dist/plugin/content/scanner.js +18 -0
  33. package/dist/plugin/content/scanner.js.map +1 -0
  34. package/dist/plugin/content/search.d.ts +9 -0
  35. package/dist/plugin/content/search.d.ts.map +1 -0
  36. package/dist/plugin/content/search.js +24 -0
  37. package/dist/plugin/content/search.js.map +1 -0
  38. package/dist/plugin/dts-generator.d.ts.map +1 -1
  39. package/dist/plugin/dts-generator.js +10 -1
  40. package/dist/plugin/dts-generator.js.map +1 -1
  41. package/dist/plugin/index.d.ts.map +1 -1
  42. package/dist/plugin/index.js +4 -1
  43. package/dist/plugin/index.js.map +1 -1
  44. package/dist/plugin/transforms/auto-import.d.ts.map +1 -1
  45. package/dist/plugin/transforms/auto-import.js +2 -0
  46. package/dist/plugin/transforms/auto-import.js.map +1 -1
  47. package/dist/runtime/composables/index.d.ts +3 -0
  48. package/dist/runtime/composables/index.d.ts.map +1 -1
  49. package/dist/runtime/composables/index.js +2 -0
  50. package/dist/runtime/composables/index.js.map +1 -1
  51. package/dist/runtime/composables/use-content-search.d.ts +49 -0
  52. package/dist/runtime/composables/use-content-search.d.ts.map +1 -0
  53. package/dist/runtime/composables/use-content-search.js +101 -0
  54. package/dist/runtime/composables/use-content-search.js.map +1 -0
  55. package/dist/runtime/composables/use-content.d.ts +51 -0
  56. package/dist/runtime/composables/use-content.d.ts.map +1 -0
  57. package/dist/runtime/composables/use-content.js +127 -0
  58. package/dist/runtime/composables/use-content.js.map +1 -0
  59. package/dist/runtime/content/client.d.ts +20 -0
  60. package/dist/runtime/content/client.d.ts.map +1 -0
  61. package/dist/runtime/content/client.js +163 -0
  62. package/dist/runtime/content/client.js.map +1 -0
  63. package/dist/types/config.d.ts +2 -0
  64. package/dist/types/config.d.ts.map +1 -1
  65. package/dist/types/config.js.map +1 -1
  66. package/dist/types/content.d.ts +63 -0
  67. package/dist/types/content.d.ts.map +1 -0
  68. package/dist/types/content.js +2 -0
  69. package/dist/types/content.js.map +1 -0
  70. package/docs/composables.md +115 -10
  71. package/docs/configuration.md +33 -0
  72. package/docs/content.md +436 -0
  73. package/e2e/cypress/e2e/content.cy.ts +228 -0
  74. package/e2e/kitchen-sink/app/pages/content-blog.ts +37 -0
  75. package/e2e/kitchen-sink/app/pages/content-doc.ts +42 -0
  76. package/e2e/kitchen-sink/app/pages/content-index.ts +39 -0
  77. package/e2e/kitchen-sink/app/pages/content-search.ts +35 -0
  78. package/e2e/kitchen-sink/cer.config.ts +1 -0
  79. package/e2e/kitchen-sink/content/blog/2026-04-01-hello.md +26 -0
  80. package/e2e/kitchen-sink/content/blog/2026-04-02-draft.md +10 -0
  81. package/e2e/kitchen-sink/content/blog/index.md +8 -0
  82. package/e2e/kitchen-sink/content/docs/getting-started.md +46 -0
  83. package/e2e/kitchen-sink/content/index.md +16 -0
  84. package/package.json +10 -7
  85. package/src/__tests__/plugin/build-ssg.test.ts +2 -1
  86. package/src/__tests__/plugin/content/emitter.test.ts +117 -0
  87. package/src/__tests__/plugin/content/loader.test.ts +162 -0
  88. package/src/__tests__/plugin/content/parser.test.ts +239 -0
  89. package/src/__tests__/plugin/content/path-utils.test.ts +53 -0
  90. package/src/__tests__/plugin/content/search.test.ts +119 -0
  91. package/src/__tests__/plugin/dts-generator.test.ts +39 -0
  92. package/src/__tests__/plugin/transforms/auto-import.test.ts +14 -0
  93. package/src/__tests__/runtime/use-content-search.test.ts +139 -0
  94. package/src/__tests__/runtime/use-content.test.ts +226 -0
  95. package/src/cli/commands/preview.ts +2 -0
  96. package/src/cli/create/templates/spa/package.json.tpl +2 -2
  97. package/src/cli/create/templates/ssg/package.json.tpl +2 -2
  98. package/src/cli/create/templates/ssr/package.json.tpl +2 -2
  99. package/src/index.ts +3 -0
  100. package/src/plugin/build-ssg.ts +12 -0
  101. package/src/plugin/content/emitter.ts +50 -0
  102. package/src/plugin/content/index.ts +236 -0
  103. package/src/plugin/content/parser.ts +192 -0
  104. package/src/plugin/content/path-utils.ts +47 -0
  105. package/src/plugin/content/scanner.ts +26 -0
  106. package/src/plugin/content/search.ts +28 -0
  107. package/src/plugin/dts-generator.ts +10 -1
  108. package/src/plugin/index.ts +6 -1
  109. package/src/plugin/transforms/auto-import.ts +2 -0
  110. package/src/runtime/composables/index.ts +3 -0
  111. package/src/runtime/composables/use-content-search.ts +121 -0
  112. package/src/runtime/composables/use-content.ts +146 -0
  113. package/src/runtime/content/client.ts +168 -0
  114. package/src/types/config.ts +2 -0
  115. package/src/types/content.ts +66 -0
@@ -0,0 +1,236 @@
1
+ import { join } from 'pathe'
2
+ import { existsSync } from 'node:fs'
3
+ import type { Plugin, ViteDevServer, ResolvedConfig } from 'vite'
4
+ import type { CerContentConfig } from '../../types/content.js'
5
+ import type { ContentItem } from '../../types/content.js'
6
+ import { scanContentFiles } from './scanner.js'
7
+ import { parseContentFileAsync, toContentMeta } from './parser.js'
8
+ import { emitContentFiles } from './emitter.js'
9
+ import { buildSearchIndex } from './search.js'
10
+ import type { IncomingMessage, ServerResponse } from 'node:http'
11
+
12
+ /** The globalThis key used to share the in-memory content store with ContentClient. */
13
+ export const CONTENT_STORE_KEY = '__CER_CONTENT_STORE__'
14
+
15
+ /**
16
+ * Resolves the absolute content directory from the framework config.
17
+ * `dir` is relative to the project root (not the app source directory),
18
+ * so `content/` sits alongside `app/`, `server/`, and `public/`.
19
+ */
20
+ export function resolveContentDir(root: string, contentConfig?: CerContentConfig): string {
21
+ const dir = contentConfig?.dir ?? 'content'
22
+ return join(root, dir)
23
+ }
24
+
25
+ /**
26
+ * Loads all content files from `contentDir`, parses them concurrently, and
27
+ * returns the full `ContentItem[]`. Excludes drafts in production unless
28
+ * `drafts: true`. Uses async I/O + `Promise.all` for concurrent disk reads,
29
+ * which is significantly faster than sequential `readFileSync` at 10k+ pages.
30
+ */
31
+ export async function loadContentStore(
32
+ contentDir: string,
33
+ isDraft: boolean,
34
+ isProduction: boolean,
35
+ ): Promise<ContentItem[]> {
36
+ if (!existsSync(contentDir)) return []
37
+
38
+ const files = await scanContentFiles(contentDir)
39
+
40
+ const results = await Promise.all(
41
+ files.map(async (file) => {
42
+ try {
43
+ const item = await parseContentFileAsync(file, contentDir)
44
+ // Skip drafts in production (unless drafts flag is set)
45
+ if (isProduction && !isDraft && item.draft === true) return null
46
+ return item
47
+ } catch (err) {
48
+ // Warn and skip unparseable / invalid files so one bad file does not
49
+ // abort the entire build. Invalid JSON files produce a descriptive
50
+ // error from the parser; other errors are also surfaced here.
51
+ console.warn(
52
+ `[cer-app] Skipping content file (parse error): ${file.filePath}\n ${(err as Error).message}`,
53
+ )
54
+ return null
55
+ }
56
+ }),
57
+ )
58
+
59
+ return results.filter((item): item is ContentItem => item !== null)
60
+ }
61
+
62
+ // ─── Dev server derived-data cache ───────────────────────────────────────────
63
+ // Re-computing the manifest JSON and search index on every request is O(n) at
64
+ // 10k+ pages. Cache them as serialised strings, keyed by the store array
65
+ // reference. When refreshStore() replaces the store, the reference changes
66
+ // and the cache is automatically invalidated on the next request.
67
+
68
+ let _devCacheStoreRef: ContentItem[] | null = null
69
+ let _devCacheManifestJson: string | null = null
70
+ let _devCacheSearchIndexJson: string | null = null
71
+
72
+ function invalidateDevCaches(): void {
73
+ _devCacheStoreRef = null
74
+ _devCacheManifestJson = null
75
+ _devCacheSearchIndexJson = null
76
+ }
77
+
78
+ function ensureDevCache(store: ContentItem[]): void {
79
+ if (_devCacheStoreRef === store) return
80
+ _devCacheStoreRef = store
81
+ _devCacheManifestJson = JSON.stringify(store.map(toContentMeta))
82
+ _devCacheSearchIndexJson = buildSearchIndex(store)
83
+ }
84
+
85
+ /**
86
+ * Registers the `/_content/*` dev middleware that serves content from the
87
+ * in-memory store populated by `buildStart`.
88
+ */
89
+ function registerDevMiddleware(server: ViteDevServer, _contentDir: string): void {
90
+ server.middlewares.use(async (req: IncomingMessage, res: ServerResponse, next: () => void) => {
91
+ const url = (req as { url?: string }).url ?? ''
92
+ if (!url.startsWith('/_content/')) {
93
+ next()
94
+ return
95
+ }
96
+
97
+ const g = globalThis as Record<string, unknown>
98
+ const store = g[CONTENT_STORE_KEY] as ContentItem[] | undefined
99
+
100
+ if (!store) {
101
+ res.statusCode = 503
102
+ res.end('Content store not ready')
103
+ return
104
+ }
105
+
106
+ // Rebuild derived caches if the store reference has changed (post-HMR).
107
+ ensureDevCache(store)
108
+
109
+ const suffix = url.slice('/_content/'.length).split('?')[0]
110
+
111
+ if (suffix === 'manifest.json') {
112
+ res.setHeader('Content-Type', 'application/json')
113
+ res.setHeader('Cache-Control', 'no-store')
114
+ res.end(_devCacheManifestJson!)
115
+ return
116
+ }
117
+
118
+ if (suffix === 'search-index.json') {
119
+ res.setHeader('Content-Type', 'application/json')
120
+ res.setHeader('Cache-Control', 'no-store')
121
+ res.end(_devCacheSearchIndexJson!)
122
+ return
123
+ }
124
+
125
+ // Individual document: suffix is like "blog/hello.json" or "index.json"
126
+ if (suffix.endsWith('.json')) {
127
+ const rawPath = suffix.slice(0, -'.json'.length) // strip .json
128
+ // Reverse the contentPathToFile mapping:
129
+ // "index" → "/" and "blog/hello" → "/blog/hello"
130
+ const _path = rawPath === 'index' ? '/' : '/' + rawPath
131
+ const item = store.find((i) => i._path === _path)
132
+ if (item) {
133
+ res.setHeader('Content-Type', 'application/json')
134
+ res.setHeader('Cache-Control', 'no-store')
135
+ res.end(JSON.stringify(item))
136
+ return
137
+ }
138
+ }
139
+
140
+ res.statusCode = 404
141
+ res.end('Not found')
142
+ })
143
+ }
144
+
145
+ /**
146
+ * `cerContent()` — Vite sub-plugin that provides the file-based content layer.
147
+ *
148
+ * - `buildStart`: scans and parses the content directory, populates `globalThis.__CER_CONTENT_STORE__`
149
+ * - `configureServer`: registers `/_content/*` dev middleware from the same store; watches for HMR
150
+ * - `closeBundle`: emits `_content/*.json` into the Vite output directory for this build
151
+ * (e.g. `dist/client/_content/` for SSR client, `dist/_content/` for SPA/SSG)
152
+ *
153
+ * The content directory is resolved relative to the **project root** (not the
154
+ * app source directory), so a default config produces `{root}/content/` — at
155
+ * the same level as `app/`, `server/`, and `public/`.
156
+ */
157
+ export function cerContent(
158
+ contentConfig?: CerContentConfig,
159
+ ): Plugin {
160
+ const contentDirName = contentConfig?.dir ?? 'content'
161
+ const includeDrafts = contentConfig?.drafts ?? false
162
+ // Resolved in configResolved; empty until then.
163
+ let _resolvedContentDir = ''
164
+ let _resolvedOutDir = ''
165
+ let _isSsr = false
166
+
167
+ const plugin: Plugin = {
168
+ name: '@jasonshimmy/vite-plugin-cer-app:content',
169
+
170
+ configResolved(resolvedConfig: ResolvedConfig) {
171
+ // Content lives at {root}/{dir} — parallel to app/, server/, public/.'
172
+ _resolvedContentDir = join(resolvedConfig.root, contentDirName)
173
+ _resolvedOutDir = resolvedConfig.build.outDir
174
+ _isSsr = !!resolvedConfig.build.ssr
175
+ },
176
+
177
+ async buildStart() {
178
+ const isProduction = this.meta.watchMode === false
179
+ const items = await loadContentStore(_resolvedContentDir, includeDrafts, isProduction)
180
+ const g = globalThis as Record<string, unknown>
181
+ g[CONTENT_STORE_KEY] = items
182
+ },
183
+
184
+ configureServer(server: ViteDevServer) {
185
+ registerDevMiddleware(server, _resolvedContentDir)
186
+
187
+ // HMR: re-parse on content file changes
188
+ server.watcher.add(_resolvedContentDir)
189
+
190
+ server.watcher.on('add', async (file: string) => {
191
+ if (!file.startsWith(_resolvedContentDir)) return
192
+ await refreshStore(_resolvedContentDir, includeDrafts)
193
+ server.ws.send({ type: 'full-reload' })
194
+ })
195
+
196
+ server.watcher.on('change', async (file: string) => {
197
+ if (!file.startsWith(_resolvedContentDir)) return
198
+ await refreshStore(_resolvedContentDir, includeDrafts)
199
+ server.ws.send({ type: 'full-reload' })
200
+ })
201
+
202
+ server.watcher.on('unlink', async (file: string) => {
203
+ if (!file.startsWith(_resolvedContentDir)) return
204
+ await refreshStore(_resolvedContentDir, includeDrafts)
205
+ server.ws.send({ type: 'full-reload' })
206
+ })
207
+ },
208
+
209
+ closeBundle() {
210
+ // Only emit for the client/SPA/SSG build pass, not the SSR server bundle pass.
211
+ // This prevents double scanning, parsing, and writing at build time.
212
+ // The SSR runtime reads from dist/client/_content/ (or dist/_content/) at runtime.
213
+ if (_isSsr) return
214
+ const g = globalThis as Record<string, unknown>
215
+ // Use the populated store; fall back to [] so the client never receives a 404 on
216
+ // /_content/manifest.json even if buildStart failed to populate the store.
217
+ const store = (g[CONTENT_STORE_KEY] as ContentItem[] | undefined) ?? []
218
+
219
+ const searchIndex = buildSearchIndex(store)
220
+ emitContentFiles(store, _resolvedOutDir, searchIndex)
221
+ },
222
+ }
223
+
224
+ return plugin
225
+ }
226
+
227
+ async function refreshStore(contentDir: string, includeDrafts: boolean): Promise<void> {
228
+ // HMR runs in dev (watchMode=true) — use isProduction=false so draft items
229
+ // remain visible, matching the initial buildStart behaviour in dev mode.
230
+ const items = await loadContentStore(contentDir, includeDrafts, false)
231
+ const g = globalThis as Record<string, unknown>
232
+ g[CONTENT_STORE_KEY] = items
233
+ // Invalidate the dev middleware caches so the next request rebuilds manifest
234
+ // and search-index from the updated store.
235
+ invalidateDevCaches()
236
+ }
@@ -0,0 +1,192 @@
1
+ import matter from 'gray-matter'
2
+ import { marked, type Token } from 'marked'
3
+ import { readFileSync } from 'node:fs'
4
+ import { readFile } from 'node:fs/promises'
5
+ import type { ContentHeading, ContentItem, ContentMeta } from '../../types/content.js'
6
+ import type { ContentFile } from './scanner.js'
7
+ import { fileToContentPath } from './path-utils.js'
8
+ import { relative } from 'pathe'
9
+
10
+ // ─── Heading extraction ───────────────────────────────────────────────────────
11
+
12
+ /** Slugify a plain-text heading string into a URL-safe id. */
13
+ function slugify(text: string): string {
14
+ return text
15
+ .toLowerCase()
16
+ .replace(/[^\w\s-]/g, '')
17
+ .trim()
18
+ .replace(/[\s_]+/g, '-')
19
+ .replace(/-+/g, '-')
20
+ }
21
+
22
+ /**
23
+ * Walks a marked token list and collects heading tokens.
24
+ * Mutates heading tokens in place to add `id` attributes to the HTML output.
25
+ */
26
+ function extractHeadings(tokens: Token[]): ContentHeading[] {
27
+ const headings: ContentHeading[] = []
28
+
29
+ const walk = (tokenList: Token[]) => {
30
+ for (const token of tokenList) {
31
+ if (token.type === 'heading') {
32
+ const text = token.text
33
+ const id = slugify(text)
34
+ headings.push({
35
+ depth: token.depth as ContentHeading['depth'],
36
+ id,
37
+ text,
38
+ })
39
+ }
40
+ // Walk nested tokens (e.g. list items, blockquote)
41
+ if ('tokens' in token && Array.isArray(token.tokens)) {
42
+ walk(token.tokens)
43
+ }
44
+ }
45
+ }
46
+
47
+ walk(tokens)
48
+ return headings
49
+ }
50
+
51
+ // ─── Custom renderer: add id to heading tags ─────────────────────────────────
52
+
53
+ const renderer = new marked.Renderer()
54
+
55
+ renderer.heading = function ({ tokens, depth }) {
56
+ const text = tokens.map((t) => ('text' in t ? (t.text as string) : '')).join('')
57
+ const id = slugify(text)
58
+ const level = depth as ContentHeading['depth']
59
+ const innerHtml = marked.parseInline(tokens.map((t) => ('raw' in t ? t.raw : '')).join(''))
60
+ return `<h${level} id="${id}">${innerHtml}</h${level}>\n`
61
+ }
62
+
63
+ // ─── Parser ───────────────────────────────────────────────────────────────────
64
+
65
+ /**
66
+ * Core parse logic shared by both the sync and async variants.
67
+ * Accepts pre-read `raw` content so the caller controls I/O scheduling.
68
+ *
69
+ * Date normalization: gray-matter parses bare YAML dates (e.g. `date: 2026-04-03`)
70
+ * as JavaScript `Date` objects. This causes a type mismatch — the in-memory server
71
+ * store contains `Date` objects while the client, which reads via JSON.stringify/parse,
72
+ * always gets ISO strings. All `Date` values are normalised to `YYYY-MM-DD` strings
73
+ * here so both paths are consistent.
74
+ */
75
+ function parseContentFileFromRaw(
76
+ file: ContentFile,
77
+ contentDir: string,
78
+ raw: string,
79
+ ): ContentItem {
80
+ const _path = fileToContentPath(file.filePath, contentDir)
81
+ const _file = relative(contentDir, file.filePath)
82
+
83
+ if (file.ext === 'json') {
84
+ // Validate JSON so users get a clear error at build time rather than a
85
+ // silently broken body string that only surfaces at render time.
86
+ try {
87
+ JSON.parse(raw)
88
+ } catch (err) {
89
+ throw new Error(
90
+ `Invalid JSON in content file "${file.filePath}": ${(err as Error).message}`,
91
+ )
92
+ }
93
+ return {
94
+ _path,
95
+ _file,
96
+ _type: 'json',
97
+ body: raw,
98
+ toc: [],
99
+ }
100
+ }
101
+
102
+ // ── Markdown ─────────────────────────────────────────────────────────────
103
+ const parsed = matter(raw)
104
+ const frontmatter = parsed.data as ContentMeta
105
+ const content = parsed.content
106
+
107
+ // Split on <!-- more --> for excerpt.
108
+ // The marker is stripped from bodySource so it does not appear as an HTML
109
+ // comment in the rendered body — spec says body is "all content minus the
110
+ // marker itself".
111
+ const MORE_MARKER = '<!-- more -->'
112
+ const moreIndex = content.indexOf(MORE_MARKER)
113
+ const hasMore = moreIndex !== -1
114
+ const bodySource = hasMore
115
+ ? content.slice(0, moreIndex) + content.slice(moreIndex + MORE_MARKER.length)
116
+ : content
117
+ const excerptSource = hasMore ? content.slice(0, moreIndex).trim() : null
118
+
119
+ // Tokenise once and extract headings
120
+ const lexer = new marked.Lexer()
121
+ const tokens = lexer.lex(bodySource)
122
+ const toc = extractHeadings(tokens)
123
+
124
+ // Render full body with custom renderer (adds id= to headings)
125
+ const body = marked.parser(tokens, { renderer }) as string
126
+
127
+ // Render excerpt if present
128
+ const excerpt = excerptSource !== null
129
+ ? (marked.parse(excerptSource, { renderer }) as string)
130
+ : undefined
131
+
132
+ const item: ContentItem = {
133
+ ...frontmatter,
134
+ _path,
135
+ _file,
136
+ _type: 'markdown',
137
+ body,
138
+ toc,
139
+ }
140
+
141
+ if (excerpt !== undefined) {
142
+ item.excerpt = excerpt
143
+ }
144
+
145
+ // Normalise any Date objects introduced by gray-matter's YAML parser to
146
+ // YYYY-MM-DD strings. Without this, the server in-memory store holds Date
147
+ // objects while the client (after JSON round-trip) holds strings, causing
148
+ // date comparisons in .where() predicates to silently misbehave server-side.
149
+ for (const key of Object.keys(item)) {
150
+ if ((item as Record<string, unknown>)[key] instanceof Date) {
151
+ ;(item as Record<string, unknown>)[key] = ((item as Record<string, unknown>)[key] as Date)
152
+ .toISOString()
153
+ .split('T')[0]
154
+ }
155
+ }
156
+
157
+ return item
158
+ }
159
+
160
+ /**
161
+ * Parses a single content file synchronously and returns a `ContentItem`.
162
+ * Prefer `parseContentFileAsync` in batch contexts (e.g. `loadContentStore`)
163
+ * to allow concurrent I/O.
164
+ */
165
+ export function parseContentFile(
166
+ file: ContentFile,
167
+ contentDir: string,
168
+ ): ContentItem {
169
+ const raw = readFileSync(file.filePath, 'utf-8')
170
+ return parseContentFileFromRaw(file, contentDir, raw)
171
+ }
172
+
173
+ /**
174
+ * Async variant of `parseContentFile`. Uses `fs/promises.readFile` so multiple
175
+ * calls can be awaited concurrently via `Promise.all`, overlapping disk I/O.
176
+ */
177
+ export async function parseContentFileAsync(
178
+ file: ContentFile,
179
+ contentDir: string,
180
+ ): Promise<ContentItem> {
181
+ const raw = await readFile(file.filePath, 'utf-8')
182
+ return parseContentFileFromRaw(file, contentDir, raw)
183
+ }
184
+
185
+ /**
186
+ * Strips body-only fields to produce a lean `ContentMeta` for the manifest.
187
+ */
188
+ export function toContentMeta(item: ContentItem): ContentMeta {
189
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
190
+ const { _file, body, toc, excerpt, ...meta } = item
191
+ return meta as ContentMeta
192
+ }
@@ -0,0 +1,47 @@
1
+ import { relative } from 'pathe'
2
+
3
+ /**
4
+ * Maps a content file path to a `_path` URL path.
5
+ *
6
+ * Rules:
7
+ * - Strip the content dir prefix
8
+ * - Strip the file extension
9
+ * - Strip `/index` suffix (so blog/index.md → /blog)
10
+ * - Strip `YYYY-MM-DD-` date prefix from the final slug segment
11
+ *
12
+ * Examples:
13
+ * index.md → /
14
+ * about.md → /about
15
+ * blog/index.md → /blog
16
+ * blog/2026-04-03-hello.md → /blog/hello
17
+ * docs/getting-started.md → /docs/getting-started
18
+ * data/products.json → /data/products
19
+ */
20
+ export function fileToContentPath(filePath: string, contentDir: string): string {
21
+ // Get path relative to contentDir, strip extension
22
+ let rel = relative(contentDir, filePath)
23
+ rel = rel.replace(/\.(md|json)$/, '')
24
+
25
+ // Split into segments
26
+ const segments = rel.split('/')
27
+
28
+ // Strip date prefix (YYYY-MM-DD-) from the last segment
29
+ const last = segments[segments.length - 1]
30
+ const stripped = last.replace(/^\d{4}-\d{2}-\d{2}-/, '')
31
+ segments[segments.length - 1] = stripped
32
+
33
+ // Strip trailing 'index' segment (but keep bare 'index' → '/')
34
+ if (segments.length > 1 && segments[segments.length - 1] === 'index') {
35
+ segments.pop()
36
+ }
37
+
38
+ // If the only segment was 'index', produce root path
39
+ if (segments.length === 1 && segments[0] === 'index') {
40
+ return '/'
41
+ }
42
+
43
+ const path = '/' + segments.join('/')
44
+ return path.replace(/\/+/g, '/')
45
+ }
46
+
47
+
@@ -0,0 +1,26 @@
1
+ import fg from 'fast-glob'
2
+
3
+ export interface ContentFile {
4
+ /** Absolute file path */
5
+ filePath: string
6
+ /** Extension without dot: 'md' | 'json' */
7
+ ext: 'md' | 'json'
8
+ }
9
+
10
+ /**
11
+ * Scans the content directory for all Markdown and JSON files.
12
+ * Returns absolute file paths sorted alphabetically.
13
+ */
14
+ export async function scanContentFiles(contentDir: string): Promise<ContentFile[]> {
15
+ const files = await fg('**/*.{md,json}', {
16
+ cwd: contentDir,
17
+ absolute: true,
18
+ onlyFiles: true,
19
+ ignore: ['**/node_modules/**', '**/.git/**'],
20
+ })
21
+
22
+ return files.sort().map((filePath) => ({
23
+ filePath,
24
+ ext: filePath.endsWith('.json') ? 'json' : 'md',
25
+ }))
26
+ }
@@ -0,0 +1,28 @@
1
+ import MiniSearch from 'minisearch'
2
+ import type { ContentItem } from '../../types/content.js'
3
+
4
+ /**
5
+ * Builds and serialises a MiniSearch full-text search index over `title` and `description`.
6
+ *
7
+ * Stored fields: `_path`, `title`, `description` — match `ContentSearchResult` exactly.
8
+ * Returns the serialised index as a JSON string ready to be written to `search-index.json`.
9
+ */
10
+ export function buildSearchIndex(items: ContentItem[]): string {
11
+ const index = new MiniSearch({
12
+ fields: ['title', 'description'],
13
+ storeFields: ['_path', 'title', 'description'],
14
+ idField: '_path',
15
+ })
16
+
17
+ const docs = items
18
+ .filter((item) => item.title !== undefined)
19
+ .map((item) => ({
20
+ _path: item._path,
21
+ title: (item.title as string) ?? '',
22
+ description: (item.description as string) ?? '',
23
+ }))
24
+
25
+ index.addAll(docs)
26
+
27
+ return JSON.stringify(index)
28
+ }
@@ -76,7 +76,7 @@ const RUNTIME_GLOBALS = [
76
76
 
77
77
  const DIRECTIVE_GLOBALS = ['when', 'each', 'match', 'anchorBlock']
78
78
 
79
- const FRAMEWORK_GLOBALS = ['useHead', 'usePageData', 'useInject', 'useRuntimeConfig', 'defineMiddleware', 'defineServerMiddleware', 'useSeoMeta', 'useCookie', 'useSession', 'useAuth', 'useFetch', 'useRoute', 'navigateTo', 'useState', 'useLocale']
79
+ const FRAMEWORK_GLOBALS = ['useHead', 'usePageData', 'useInject', 'useRuntimeConfig', 'defineMiddleware', 'defineServerMiddleware', 'useSeoMeta', 'useCookie', 'useSession', 'useAuth', 'useFetch', 'useRoute', 'navigateTo', 'useState', 'useLocale', 'queryContent', 'useContentSearch']
80
80
 
81
81
  /**
82
82
  * Scans a composables directory and returns a map of export name → file path.
@@ -146,6 +146,12 @@ export async function generateAutoImportDts(
146
146
  }
147
147
  }
148
148
 
149
+ lines.push('')
150
+ lines.push(' // Content layer types')
151
+ lines.push(` type ContentMeta = import('@jasonshimmy/vite-plugin-cer-app')['ContentMeta']`)
152
+ lines.push(` type ContentItem = import('@jasonshimmy/vite-plugin-cer-app')['ContentItem']`)
153
+ lines.push(` type ContentHeading = import('@jasonshimmy/vite-plugin-cer-app')['ContentHeading']`)
154
+ lines.push(` type ContentSearchResult = import('@jasonshimmy/vite-plugin-cer-app')['ContentSearchResult']`)
149
155
  lines.push('')
150
156
  lines.push(' // SSR loader data injected as window.__CER_DATA__ by the server.')
151
157
  lines.push(' // Consumed once by usePageData() during client hydration.')
@@ -155,6 +161,9 @@ export async function generateAutoImportDts(
155
161
  lines.push(' // Pre-populates the client useState() Map on first use.')
156
162
  lines.push(' // eslint-disable-next-line @typescript-eslint/no-explicit-any')
157
163
  lines.push(' var __CER_STATE_INIT__: any')
164
+ lines.push(' // App config injected by virtual:cer-app-config at app bootstrap.')
165
+ lines.push(' // eslint-disable-next-line @typescript-eslint/no-explicit-any')
166
+ lines.push(' var __CER_APP_CONFIG__: any')
158
167
  lines.push('}')
159
168
  lines.push('')
160
169
 
@@ -19,6 +19,7 @@ import { generateServerMiddlewareCode } from './virtual/server-middleware.js'
19
19
  import { generateLoadingCode } from './virtual/loading.js'
20
20
  import { generateErrorCode } from './virtual/error.js'
21
21
  import { createWatcher } from './scanner.js'
22
+ import { cerContent } from './content/index.js'
22
23
 
23
24
  // Virtual module IDs (raw)
24
25
  const VIRTUAL_IDS = {
@@ -181,7 +182,8 @@ function generateAppConfigModule(config: ResolvedCerConfig, ssr = false): string
181
182
  `export const runtimeConfig = { public: ${JSON.stringify(publicConfig, null, 2)} }\n` +
182
183
  `\n` +
183
184
  `export const i18nConfig = ${i18nValue}\n` +
184
- `;(globalThis).__CER_I18N_CONFIG__ = i18nConfig\n`
185
+ `;(globalThis).__CER_I18N_CONFIG__ = i18nConfig\n` +
186
+ `;(globalThis).__CER_APP_CONFIG__ = appConfig\n`
185
187
 
186
188
  if (ssr) {
187
189
  const privateDefaults = config.runtimeConfig.private
@@ -521,5 +523,8 @@ export function cerApp(userConfig: CerAppConfig = {}): Plugin[] {
521
523
  cerAppPlugin,
522
524
  ...jitPlugins,
523
525
  ...(userConfig.autoImports?.components !== false ? [componentImportsProxy] : []),
526
+ cerContent(
527
+ userConfig.content,
528
+ ),
524
529
  ]
525
530
  }
@@ -71,6 +71,8 @@ const FRAMEWORK_MAP: Record<string, string> = {
71
71
  navigateTo: '@jasonshimmy/vite-plugin-cer-app/composables',
72
72
  useState: '@jasonshimmy/vite-plugin-cer-app/composables',
73
73
  useLocale: '@jasonshimmy/vite-plugin-cer-app/composables',
74
+ queryContent: '@jasonshimmy/vite-plugin-cer-app/composables',
75
+ useContentSearch: '@jasonshimmy/vite-plugin-cer-app/composables',
74
76
  }
75
77
 
76
78
  // All identifier maps — processed in order. Earlier maps take precedence for
@@ -22,3 +22,6 @@ export { navigateTo } from './use-navigate.js'
22
22
  export { useState } from './use-state.js'
23
23
  export { useLocale } from './use-locale.js'
24
24
  export type { LocaleComposable } from './use-locale.js'
25
+ export { queryContent, QueryBuilder } from './use-content.js'
26
+ export { useContentSearch } from './use-content-search.js'
27
+ export type { UseContentSearchReturn } from './use-content-search.js'