@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.
Files changed (111) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/commits.txt +1 -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/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/plugin/build-ssg.d.ts.map +1 -1
  9. package/dist/plugin/build-ssg.js +11 -0
  10. package/dist/plugin/build-ssg.js.map +1 -1
  11. package/dist/plugin/content/emitter.d.ts +19 -0
  12. package/dist/plugin/content/emitter.d.ts.map +1 -0
  13. package/dist/plugin/content/emitter.js +42 -0
  14. package/dist/plugin/content/emitter.js.map +1 -0
  15. package/dist/plugin/content/index.d.ts +32 -0
  16. package/dist/plugin/content/index.d.ts.map +1 -0
  17. package/dist/plugin/content/index.js +199 -0
  18. package/dist/plugin/content/index.js.map +1 -0
  19. package/dist/plugin/content/parser.d.ts +18 -0
  20. package/dist/plugin/content/parser.d.ts.map +1 -0
  21. package/dist/plugin/content/parser.js +221 -0
  22. package/dist/plugin/content/parser.js.map +1 -0
  23. package/dist/plugin/content/path-utils.d.ts +19 -0
  24. package/dist/plugin/content/path-utils.d.ts.map +1 -0
  25. package/dist/plugin/content/path-utils.js +40 -0
  26. package/dist/plugin/content/path-utils.js.map +1 -0
  27. package/dist/plugin/content/scanner.d.ts +12 -0
  28. package/dist/plugin/content/scanner.d.ts.map +1 -0
  29. package/dist/plugin/content/scanner.js +18 -0
  30. package/dist/plugin/content/scanner.js.map +1 -0
  31. package/dist/plugin/content/search.d.ts +9 -0
  32. package/dist/plugin/content/search.d.ts.map +1 -0
  33. package/dist/plugin/content/search.js +24 -0
  34. package/dist/plugin/content/search.js.map +1 -0
  35. package/dist/plugin/dts-generator.d.ts.map +1 -1
  36. package/dist/plugin/dts-generator.js +10 -1
  37. package/dist/plugin/dts-generator.js.map +1 -1
  38. package/dist/plugin/index.d.ts.map +1 -1
  39. package/dist/plugin/index.js +4 -1
  40. package/dist/plugin/index.js.map +1 -1
  41. package/dist/plugin/transforms/auto-import.d.ts.map +1 -1
  42. package/dist/plugin/transforms/auto-import.js +2 -0
  43. package/dist/plugin/transforms/auto-import.js.map +1 -1
  44. package/dist/runtime/composables/index.d.ts +3 -0
  45. package/dist/runtime/composables/index.d.ts.map +1 -1
  46. package/dist/runtime/composables/index.js +2 -0
  47. package/dist/runtime/composables/index.js.map +1 -1
  48. package/dist/runtime/composables/use-content-search.d.ts +49 -0
  49. package/dist/runtime/composables/use-content-search.d.ts.map +1 -0
  50. package/dist/runtime/composables/use-content-search.js +101 -0
  51. package/dist/runtime/composables/use-content-search.js.map +1 -0
  52. package/dist/runtime/composables/use-content.d.ts +51 -0
  53. package/dist/runtime/composables/use-content.d.ts.map +1 -0
  54. package/dist/runtime/composables/use-content.js +127 -0
  55. package/dist/runtime/composables/use-content.js.map +1 -0
  56. package/dist/runtime/content/client.d.ts +20 -0
  57. package/dist/runtime/content/client.d.ts.map +1 -0
  58. package/dist/runtime/content/client.js +163 -0
  59. package/dist/runtime/content/client.js.map +1 -0
  60. package/dist/types/config.d.ts +2 -0
  61. package/dist/types/config.d.ts.map +1 -1
  62. package/dist/types/config.js.map +1 -1
  63. package/dist/types/content.d.ts +63 -0
  64. package/dist/types/content.d.ts.map +1 -0
  65. package/dist/types/content.js +2 -0
  66. package/dist/types/content.js.map +1 -0
  67. package/docs/composables.md +115 -10
  68. package/docs/configuration.md +33 -0
  69. package/docs/content.md +453 -0
  70. package/e2e/cypress/e2e/content.cy.ts +291 -0
  71. package/e2e/kitchen-sink/app/pages/content-blog.ts +37 -0
  72. package/e2e/kitchen-sink/app/pages/content-doc.ts +42 -0
  73. package/e2e/kitchen-sink/app/pages/content-fallback.ts +36 -0
  74. package/e2e/kitchen-sink/app/pages/content-index.ts +39 -0
  75. package/e2e/kitchen-sink/app/pages/content-search.ts +35 -0
  76. package/e2e/kitchen-sink/cer.config.ts +1 -0
  77. package/e2e/kitchen-sink/content/blog/2026-04-01-hello.md +26 -0
  78. package/e2e/kitchen-sink/content/blog/2026-04-02-draft.md +10 -0
  79. package/e2e/kitchen-sink/content/blog/index.md +8 -0
  80. package/e2e/kitchen-sink/content/blog/no-frontmatter.md +7 -0
  81. package/e2e/kitchen-sink/content/docs/getting-started.md +46 -0
  82. package/e2e/kitchen-sink/content/index.md +16 -0
  83. package/package.json +10 -7
  84. package/src/__tests__/plugin/build-ssg.test.ts +2 -1
  85. package/src/__tests__/plugin/content/emitter.test.ts +117 -0
  86. package/src/__tests__/plugin/content/loader.test.ts +162 -0
  87. package/src/__tests__/plugin/content/parser.test.ts +381 -0
  88. package/src/__tests__/plugin/content/path-utils.test.ts +53 -0
  89. package/src/__tests__/plugin/content/search.test.ts +119 -0
  90. package/src/__tests__/plugin/dts-generator.test.ts +39 -0
  91. package/src/__tests__/plugin/transforms/auto-import.test.ts +14 -0
  92. package/src/__tests__/runtime/use-content-search.test.ts +139 -0
  93. package/src/__tests__/runtime/use-content.test.ts +226 -0
  94. package/src/cli/commands/preview.ts +2 -0
  95. package/src/index.ts +3 -0
  96. package/src/plugin/build-ssg.ts +12 -0
  97. package/src/plugin/content/emitter.ts +50 -0
  98. package/src/plugin/content/index.ts +236 -0
  99. package/src/plugin/content/parser.ts +259 -0
  100. package/src/plugin/content/path-utils.ts +47 -0
  101. package/src/plugin/content/scanner.ts +26 -0
  102. package/src/plugin/content/search.ts +28 -0
  103. package/src/plugin/dts-generator.ts +10 -1
  104. package/src/plugin/index.ts +6 -1
  105. package/src/plugin/transforms/auto-import.ts +2 -0
  106. package/src/runtime/composables/index.ts +3 -0
  107. package/src/runtime/composables/use-content-search.ts +121 -0
  108. package/src/runtime/composables/use-content.ts +146 -0
  109. package/src/runtime/content/client.ts +168 -0
  110. package/src/types/config.ts +2 -0
  111. package/src/types/content.ts +66 -0
@@ -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.