@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.
- package/CHANGELOG.md +9 -0
- package/commits.txt +2 -1
- package/dist/cli/commands/preview.d.ts.map +1 -1
- package/dist/cli/commands/preview.js +2 -0
- package/dist/cli/commands/preview.js.map +1 -1
- package/dist/cli/create/templates/spa/package.json.tpl +2 -2
- package/dist/cli/create/templates/ssg/package.json.tpl +2 -2
- package/dist/cli/create/templates/ssr/package.json.tpl +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/plugin/build-ssg.d.ts.map +1 -1
- package/dist/plugin/build-ssg.js +11 -0
- package/dist/plugin/build-ssg.js.map +1 -1
- package/dist/plugin/content/emitter.d.ts +19 -0
- package/dist/plugin/content/emitter.d.ts.map +1 -0
- package/dist/plugin/content/emitter.js +42 -0
- package/dist/plugin/content/emitter.js.map +1 -0
- package/dist/plugin/content/index.d.ts +32 -0
- package/dist/plugin/content/index.d.ts.map +1 -0
- package/dist/plugin/content/index.js +199 -0
- package/dist/plugin/content/index.js.map +1 -0
- package/dist/plugin/content/parser.d.ts +18 -0
- package/dist/plugin/content/parser.d.ts.map +1 -0
- package/dist/plugin/content/parser.js +158 -0
- package/dist/plugin/content/parser.js.map +1 -0
- package/dist/plugin/content/path-utils.d.ts +19 -0
- package/dist/plugin/content/path-utils.d.ts.map +1 -0
- package/dist/plugin/content/path-utils.js +40 -0
- package/dist/plugin/content/path-utils.js.map +1 -0
- package/dist/plugin/content/scanner.d.ts +12 -0
- package/dist/plugin/content/scanner.d.ts.map +1 -0
- package/dist/plugin/content/scanner.js +18 -0
- package/dist/plugin/content/scanner.js.map +1 -0
- package/dist/plugin/content/search.d.ts +9 -0
- package/dist/plugin/content/search.d.ts.map +1 -0
- package/dist/plugin/content/search.js +24 -0
- package/dist/plugin/content/search.js.map +1 -0
- package/dist/plugin/dts-generator.d.ts.map +1 -1
- package/dist/plugin/dts-generator.js +10 -1
- package/dist/plugin/dts-generator.js.map +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +4 -1
- package/dist/plugin/index.js.map +1 -1
- package/dist/plugin/transforms/auto-import.d.ts.map +1 -1
- package/dist/plugin/transforms/auto-import.js +2 -0
- package/dist/plugin/transforms/auto-import.js.map +1 -1
- package/dist/runtime/composables/index.d.ts +3 -0
- package/dist/runtime/composables/index.d.ts.map +1 -1
- package/dist/runtime/composables/index.js +2 -0
- package/dist/runtime/composables/index.js.map +1 -1
- package/dist/runtime/composables/use-content-search.d.ts +49 -0
- package/dist/runtime/composables/use-content-search.d.ts.map +1 -0
- package/dist/runtime/composables/use-content-search.js +101 -0
- package/dist/runtime/composables/use-content-search.js.map +1 -0
- package/dist/runtime/composables/use-content.d.ts +51 -0
- package/dist/runtime/composables/use-content.d.ts.map +1 -0
- package/dist/runtime/composables/use-content.js +127 -0
- package/dist/runtime/composables/use-content.js.map +1 -0
- package/dist/runtime/content/client.d.ts +20 -0
- package/dist/runtime/content/client.d.ts.map +1 -0
- package/dist/runtime/content/client.js +163 -0
- package/dist/runtime/content/client.js.map +1 -0
- package/dist/types/config.d.ts +2 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js.map +1 -1
- package/dist/types/content.d.ts +63 -0
- package/dist/types/content.d.ts.map +1 -0
- package/dist/types/content.js +2 -0
- package/dist/types/content.js.map +1 -0
- package/docs/composables.md +115 -10
- package/docs/configuration.md +33 -0
- package/docs/content.md +436 -0
- package/e2e/cypress/e2e/content.cy.ts +228 -0
- package/e2e/kitchen-sink/app/pages/content-blog.ts +37 -0
- package/e2e/kitchen-sink/app/pages/content-doc.ts +42 -0
- package/e2e/kitchen-sink/app/pages/content-index.ts +39 -0
- package/e2e/kitchen-sink/app/pages/content-search.ts +35 -0
- package/e2e/kitchen-sink/cer.config.ts +1 -0
- package/e2e/kitchen-sink/content/blog/2026-04-01-hello.md +26 -0
- package/e2e/kitchen-sink/content/blog/2026-04-02-draft.md +10 -0
- package/e2e/kitchen-sink/content/blog/index.md +8 -0
- package/e2e/kitchen-sink/content/docs/getting-started.md +46 -0
- package/e2e/kitchen-sink/content/index.md +16 -0
- package/package.json +10 -7
- package/src/__tests__/plugin/build-ssg.test.ts +2 -1
- package/src/__tests__/plugin/content/emitter.test.ts +117 -0
- package/src/__tests__/plugin/content/loader.test.ts +162 -0
- package/src/__tests__/plugin/content/parser.test.ts +239 -0
- package/src/__tests__/plugin/content/path-utils.test.ts +53 -0
- package/src/__tests__/plugin/content/search.test.ts +119 -0
- package/src/__tests__/plugin/dts-generator.test.ts +39 -0
- package/src/__tests__/plugin/transforms/auto-import.test.ts +14 -0
- package/src/__tests__/runtime/use-content-search.test.ts +139 -0
- package/src/__tests__/runtime/use-content.test.ts +226 -0
- package/src/cli/commands/preview.ts +2 -0
- package/src/cli/create/templates/spa/package.json.tpl +2 -2
- package/src/cli/create/templates/ssg/package.json.tpl +2 -2
- package/src/cli/create/templates/ssr/package.json.tpl +2 -2
- package/src/index.ts +3 -0
- package/src/plugin/build-ssg.ts +12 -0
- package/src/plugin/content/emitter.ts +50 -0
- package/src/plugin/content/index.ts +236 -0
- package/src/plugin/content/parser.ts +192 -0
- package/src/plugin/content/path-utils.ts +47 -0
- package/src/plugin/content/scanner.ts +26 -0
- package/src/plugin/content/search.ts +28 -0
- package/src/plugin/dts-generator.ts +10 -1
- package/src/plugin/index.ts +6 -1
- package/src/plugin/transforms/auto-import.ts +2 -0
- package/src/runtime/composables/index.ts +3 -0
- package/src/runtime/composables/use-content-search.ts +121 -0
- package/src/runtime/composables/use-content.ts +146 -0
- package/src/runtime/content/client.ts +168 -0
- package/src/types/config.ts +2 -0
- 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
|
|
package/src/plugin/index.ts
CHANGED
|
@@ -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'
|