@jasonshimmy/vite-plugin-cer-app 0.19.3 → 0.20.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/CHANGELOG.md +9 -0
- package/commits.txt +1 -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/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 +221 -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 +453 -0
- package/e2e/cypress/e2e/content.cy.ts +291 -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-fallback.ts +36 -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/blog/no-frontmatter.md +7 -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 +381 -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/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 +259 -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
package/docs/content.md
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
# Content Layer
|
|
2
|
+
|
|
3
|
+
CER Content is a file-based content layer built into `vite-plugin-cer-app`. It parses Markdown and JSON files from `content/` at the project root, injects them into the global store, generates a static search index, and exposes them to your pages via `queryContent()` and `useContentSearch()`. No separate server or database is required.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
- **Zero config** — drop files into `content/` at the project root and they are available immediately.
|
|
10
|
+
- **Markdown + JSON** — Markdown files are parsed with frontmatter, rendered to HTML, and have their headings extracted into a table of contents. JSON files are stored as raw string bodies.
|
|
11
|
+
- **Draft support** — items with `draft: true` in frontmatter are excluded from production builds by default.
|
|
12
|
+
- **Excerpt extraction** — place `<!-- more -->` in a Markdown file to set the excerpt boundary.
|
|
13
|
+
- **Full-text search** — a MiniSearch index is emitted at build time and loaded lazily on the client via `useContentSearch()`.
|
|
14
|
+
- **Works in all modes** — SPA (client fetch), SSR (Node.js filesystem), and SSG (pre-rendered).
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
### 1. Enable the content layer
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// cer.config.ts
|
|
24
|
+
import { defineConfig } from '@jasonshimmy/vite-plugin-cer-app'
|
|
25
|
+
|
|
26
|
+
export default defineConfig({
|
|
27
|
+
content: {},
|
|
28
|
+
})
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 2. Add content files
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
content/
|
|
35
|
+
index.md
|
|
36
|
+
blog/
|
|
37
|
+
2026-04-01-hello.md
|
|
38
|
+
docs/
|
|
39
|
+
getting-started.md
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### 3. Query content in a page
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// app/pages/blog.ts
|
|
46
|
+
component('page-blog', () => {
|
|
47
|
+
useHead({ title: 'Blog' })
|
|
48
|
+
|
|
49
|
+
const ssrData = usePageData<{ posts: ContentMeta[] }>()
|
|
50
|
+
const posts = ref<ContentMeta[]>(ssrData?.posts ?? [])
|
|
51
|
+
|
|
52
|
+
useOnConnected(async () => {
|
|
53
|
+
if (ssrData) return
|
|
54
|
+
posts.value = await queryContent('/blog').find()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
return html`
|
|
58
|
+
<ul>
|
|
59
|
+
${each(posts.value, p => html`<li><a :href="${p._path}">${p.title}</a></li>`)}
|
|
60
|
+
</ul>
|
|
61
|
+
`
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
export const loader = async () => {
|
|
65
|
+
const posts = await queryContent('/blog').find()
|
|
66
|
+
return { posts }
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
All options are optional.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
// cer.config.ts
|
|
78
|
+
export default defineConfig({
|
|
79
|
+
content: {
|
|
80
|
+
dir: 'content', // default
|
|
81
|
+
drafts: false, // default
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### `content.dir`
|
|
87
|
+
|
|
88
|
+
**Type:** `string`
|
|
89
|
+
**Default:** `'content'`
|
|
90
|
+
|
|
91
|
+
Content directory relative to the **project root** — at the same level as `app/`, `server/`, and `public/`. The default resolves to `{root}/content/`.
|
|
92
|
+
|
|
93
|
+
### `content.drafts`
|
|
94
|
+
|
|
95
|
+
**Type:** `boolean`
|
|
96
|
+
**Default:** `false`
|
|
97
|
+
|
|
98
|
+
When `false`, any file with `draft: true` in its frontmatter is excluded from the content store and search index. Set to `true` to include drafts (useful for preview environments).
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## File format
|
|
103
|
+
|
|
104
|
+
### Markdown files
|
|
105
|
+
|
|
106
|
+
Markdown files use [gray-matter](https://github.com/jonschlinkert/gray-matter) for YAML frontmatter. All frontmatter keys are stored in the content item. The body is rendered to HTML using [marked](https://marked.js.org). Heading elements receive an `id` attribute derived from their slug.
|
|
107
|
+
|
|
108
|
+
```md
|
|
109
|
+
---
|
|
110
|
+
title: Hello World
|
|
111
|
+
description: My first post.
|
|
112
|
+
date: 2026-04-01
|
|
113
|
+
draft: false
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
# Hello World
|
|
117
|
+
|
|
118
|
+
<!-- more -->
|
|
119
|
+
|
|
120
|
+
Everything below the excerpt boundary is in `body` but not in `excerpt`.
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Recognized frontmatter keys:
|
|
124
|
+
|
|
125
|
+
| Key | Type | Description |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| `title` | `string` | Document title. |
|
|
128
|
+
| `description` | `string` | Short description for listings and search. |
|
|
129
|
+
| `date` | `string` | ISO date string (e.g. `2026-04-01`). |
|
|
130
|
+
| `draft` | `boolean` | When `true`, excluded from production builds unless `drafts: true`. |
|
|
131
|
+
|
|
132
|
+
Any additional frontmatter keys are stored verbatim in the `ContentMeta` / `ContentItem` object.
|
|
133
|
+
|
|
134
|
+
### Automatic title and description
|
|
135
|
+
|
|
136
|
+
When `title` or `description` are absent from frontmatter, the parser derives them from the body:
|
|
137
|
+
|
|
138
|
+
- **`title`** — plain text of the first depth-1 heading (`# …`). Only `h1` is considered; `h2`–`h6` are ignored.
|
|
139
|
+
- **`description`** — plain text of the first paragraph, truncated to 160 characters (with `…` appended). Inline formatting is stripped.
|
|
140
|
+
|
|
141
|
+
Frontmatter values always win — these fallbacks only fill the gaps. JSON files do not receive fallbacks (they have no Markdown body to parse from).
|
|
142
|
+
|
|
143
|
+
```md
|
|
144
|
+
# Hello World
|
|
145
|
+
|
|
146
|
+
This becomes the description because no description key is in frontmatter.
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Results in `title: 'Hello World'` and `description: 'This becomes the description because no description key is in frontmatter.'`.
|
|
150
|
+
|
|
151
|
+
### Date-prefixed filenames
|
|
152
|
+
|
|
153
|
+
Filenames starting with `YYYY-MM-DD-` have the date prefix stripped when computing the content path:
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
content/blog/2026-04-01-hello.md → _path: '/blog/hello'
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Index files
|
|
160
|
+
|
|
161
|
+
Files named `index.md` have `/index` stripped from their path:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
content/blog/index.md → _path: '/blog'
|
|
165
|
+
content/index.md → _path: '/'
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### JSON files
|
|
169
|
+
|
|
170
|
+
JSON files are read as-is. The `body` is the raw file content string — valid JSON, preserving the original formatting.
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
content/data/features.json → _path: '/data/features', _type: 'json'
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Excerpt
|
|
177
|
+
|
|
178
|
+
Place `<!-- more -->` anywhere in a Markdown file to set the excerpt boundary. Everything before the marker is stored in `item.excerpt` as rendered HTML. The full rendered body (including content after the marker, minus the marker itself) is in `item.body`.
|
|
179
|
+
|
|
180
|
+
```md
|
|
181
|
+
This paragraph is the excerpt.
|
|
182
|
+
|
|
183
|
+
<!-- more -->
|
|
184
|
+
|
|
185
|
+
This paragraph is only in the body.
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## TypeScript types
|
|
191
|
+
|
|
192
|
+
All types are exported from `@jasonshimmy/vite-plugin-cer-app` and are automatically available as globals inside pages, layouts, and components.
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
import type {
|
|
196
|
+
ContentMeta,
|
|
197
|
+
ContentItem,
|
|
198
|
+
ContentHeading,
|
|
199
|
+
ContentSearchResult,
|
|
200
|
+
CerContentConfig,
|
|
201
|
+
} from '@jasonshimmy/vite-plugin-cer-app'
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### `ContentMeta`
|
|
205
|
+
|
|
206
|
+
Lean per-document object returned by `.find()` and `.count()`. Does not include `body`, `toc`, or `excerpt`.
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
interface ContentMeta {
|
|
210
|
+
_path: string // URL path, e.g. '/blog/hello'
|
|
211
|
+
_type: 'markdown' | 'json'
|
|
212
|
+
title?: string
|
|
213
|
+
description?: string
|
|
214
|
+
date?: string
|
|
215
|
+
draft?: boolean
|
|
216
|
+
[key: string]: unknown // any additional frontmatter key
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### `ContentItem`
|
|
221
|
+
|
|
222
|
+
Full document returned by `.first()`. Extends `ContentMeta` with rendered body, TOC, and excerpt.
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
interface ContentItem extends ContentMeta {
|
|
226
|
+
_file: string // relative source path, e.g. 'blog/hello.md'
|
|
227
|
+
body: string // rendered HTML (Markdown) or raw file content (JSON)
|
|
228
|
+
toc: ContentHeading[] // extracted headings
|
|
229
|
+
excerpt?: string // HTML before <!-- more --> (if marker present)
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### `ContentHeading`
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
interface ContentHeading {
|
|
237
|
+
depth: 1 | 2 | 3 | 4 | 5 | 6
|
|
238
|
+
id: string // slugified heading text, matches id= in body HTML
|
|
239
|
+
text: string // plain heading text
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### `ContentSearchResult`
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
interface ContentSearchResult {
|
|
247
|
+
_path: string
|
|
248
|
+
title: string
|
|
249
|
+
description?: string
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## `queryContent(path?)`
|
|
256
|
+
|
|
257
|
+
**Auto-imported** in pages, layouts, and components.
|
|
258
|
+
|
|
259
|
+
Returns a `QueryBuilder` scoped to the given path prefix. If `path` is omitted, queries all content.
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
queryContent() // all items
|
|
263
|
+
queryContent('/blog') // items where _path starts with '/blog'
|
|
264
|
+
queryContent('/blog/hello') // items where _path starts with '/blog/hello'
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### `QueryBuilder` methods
|
|
268
|
+
|
|
269
|
+
All terminal methods return a `Promise`.
|
|
270
|
+
|
|
271
|
+
#### `.where(predicate)`
|
|
272
|
+
|
|
273
|
+
Filters results. `predicate` is a function that receives a `ContentMeta` and returns `true` to include the item.
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
await queryContent('/blog').where(doc => !doc.draft).find()
|
|
277
|
+
await queryContent().where(doc => /^\/docs/.test(doc._path)).find()
|
|
278
|
+
await queryContent().where(doc => Array.isArray(doc.tags) && (doc.tags as string[]).includes('web')).find()
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
#### `.sortBy(field, order?)`
|
|
282
|
+
|
|
283
|
+
Sorts results by a field. `order` defaults to `'asc'`.
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
await queryContent('/blog').sortBy('date', 'desc').find()
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
#### `.limit(n)`
|
|
290
|
+
|
|
291
|
+
Returns at most `n` items.
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
await queryContent('/blog').limit(5).find()
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
#### `.skip(n)`
|
|
298
|
+
|
|
299
|
+
Skips the first `n` items (pagination).
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
await queryContent('/blog').skip(10).limit(10).find()
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
#### `.find()`
|
|
306
|
+
|
|
307
|
+
Executes the query and returns `Promise<ContentMeta[]>`.
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
const posts = await queryContent('/blog').sortBy('date', 'desc').find()
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
#### `.first()`
|
|
314
|
+
|
|
315
|
+
Returns `Promise<ContentItem | null>` — the first matching full document (includes `body`, `toc`, `excerpt`).
|
|
316
|
+
|
|
317
|
+
When a path is set and no other filters or sort are active, `first()` short-circuits to a direct item lookup for efficiency.
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
const doc = await queryContent('/docs/getting-started').first()
|
|
321
|
+
if (doc) {
|
|
322
|
+
// doc.body, doc.toc, doc.excerpt
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
#### `.count()`
|
|
327
|
+
|
|
328
|
+
Returns `Promise<number>` — the number of matching items (no body loaded).
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
const total = await queryContent().count()
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Using with a page loader (SSR/SSG)
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
component('page-blog', () => {
|
|
338
|
+
const ssrData = usePageData<{ posts: ContentMeta[] }>()
|
|
339
|
+
const posts = ref<ContentMeta[]>(ssrData?.posts ?? [])
|
|
340
|
+
|
|
341
|
+
useOnConnected(async () => {
|
|
342
|
+
if (ssrData) return // already hydrated from loader
|
|
343
|
+
posts.value = await queryContent('/blog').sortBy('date', 'desc').find()
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
return html`...`
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
export const loader = async () => {
|
|
350
|
+
const posts = await queryContent('/blog').sortBy('date', 'desc').find()
|
|
351
|
+
return { posts }
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
## `useContentSearch()`
|
|
358
|
+
|
|
359
|
+
**Auto-imported** in pages, layouts, and components.
|
|
360
|
+
|
|
361
|
+
Returns reactive `query` and `results` refs. The MiniSearch index is loaded once on the client (lazily, the first time the composable is used). Search is debounce-free — results update synchronously on each keypress, but the initial index fetch is async.
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
const { query, results } = useContentSearch()
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### Return value
|
|
368
|
+
|
|
369
|
+
```ts
|
|
370
|
+
interface UseContentSearchReturn {
|
|
371
|
+
query: Ref<string> // bind to an <input> value
|
|
372
|
+
results: Ref<ContentSearchResult[]> // reactive search results
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### Usage
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
component('page-search', () => {
|
|
380
|
+
const { query, results } = useContentSearch()
|
|
381
|
+
|
|
382
|
+
return html`
|
|
383
|
+
<input type="search" :model="${query}" placeholder="Search…" />
|
|
384
|
+
<ul>
|
|
385
|
+
${each(results.value, r => html`
|
|
386
|
+
<li><a :href="${r._path}">${r.title}</a></li>
|
|
387
|
+
`)}
|
|
388
|
+
</ul>
|
|
389
|
+
`
|
|
390
|
+
})
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Search activates when `query.value.length >= 2`. Empty or single-character queries return an empty array.
|
|
394
|
+
|
|
395
|
+
### Searched fields
|
|
396
|
+
|
|
397
|
+
The MiniSearch index is built over `title` and `description`. The stored fields (`_path`, `title`, `description`) are returned in each result.
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
## Rendering modes
|
|
402
|
+
|
|
403
|
+
### SPA mode
|
|
404
|
+
|
|
405
|
+
On the client, `queryContent()` lazily fetches `/_content/manifest.json` (all `ContentMeta` items) and caches it. Individual full documents (`ContentItem`) are fetched from `/_content/[path].json` on demand (once each, cached).
|
|
406
|
+
|
|
407
|
+
### SSR mode
|
|
408
|
+
|
|
409
|
+
In dev mode, `queryContent()` reads synchronously from the in-memory `globalThis.__CER_CONTENT_STORE__` array populated by the Vite plugin's `buildStart` hook. No filesystem or network access is needed per request.
|
|
410
|
+
|
|
411
|
+
At production runtime, `__CER_CONTENT_STORE__` is absent — `buildStart` is a build-time hook that does not run at production server startup. The `ContentClient` always falls back to reading `dist/_content/` files via `node:fs`. The manifest and individual documents are cached as module-level singletons, so each file is read and parsed at most once per process lifetime.
|
|
412
|
+
|
|
413
|
+
### SSG mode
|
|
414
|
+
|
|
415
|
+
During pre-rendering (`cer-app build --mode ssg`), `queryContent()` reads from `globalThis.__CER_CONTENT_STORE__` just like SSR. After all pages are rendered, the `closeBundle` hook writes the content manifest, individual document JSON files, and search index to `dist/_content/`.
|
|
416
|
+
|
|
417
|
+
---
|
|
418
|
+
|
|
419
|
+
## Search index
|
|
420
|
+
|
|
421
|
+
At build time, a `dist/_content/search-index.json` file is written. It is the serialized MiniSearch index for all non-draft content items. The client fetches this file the first time `useContentSearch()` activates a search.
|
|
422
|
+
|
|
423
|
+
In dev mode, `/_content/search-index.json` is served from the in-memory store by the dev middleware — no file is written to disk.
|
|
424
|
+
|
|
425
|
+
---
|
|
426
|
+
|
|
427
|
+
## Dev server
|
|
428
|
+
|
|
429
|
+
In dev mode, the Vite dev server intercepts all `/_content/*` requests:
|
|
430
|
+
|
|
431
|
+
| URL pattern | Response |
|
|
432
|
+
|---|---|
|
|
433
|
+
| `/_content/manifest.json` | JSON array of all `ContentMeta` items |
|
|
434
|
+
| `/_content/search-index.json` | Serialized MiniSearch index |
|
|
435
|
+
| `/_content/[path].json` | Full `ContentItem` for `_path === path` |
|
|
436
|
+
|
|
437
|
+
Content files are watched for changes. When a Markdown or JSON file in `content/` changes, the store is re-populated and a full-reload HMR event is dispatched to the client.
|
|
438
|
+
|
|
439
|
+
---
|
|
440
|
+
|
|
441
|
+
## Limitations
|
|
442
|
+
|
|
443
|
+
- **No aggregation** — `.count()` is the only aggregation terminal. Use `.find()` + `Array.prototype.length` for anything more complex.
|
|
444
|
+
- **Search fields only** — MiniSearch is configured to index `title` and `description`. Full-body search is not supported.
|
|
445
|
+
- **Content directory is fixed at build time** — changing `content.dir` at runtime has no effect.
|
|
446
|
+
|
|
447
|
+
---
|
|
448
|
+
|
|
449
|
+
## Known edge cases
|
|
450
|
+
|
|
451
|
+
- Filenames with multiple `YYYY-MM-DD-` date prefixes have only the leading prefix stripped.
|
|
452
|
+
- Markdown files with no headings have an empty `toc` array.
|
|
453
|
+
- JSON files with invalid JSON are skipped and a warning is logged to the console. The build continues without that file.
|