@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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=content.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content.js","sourceRoot":"","sources":["../../src/types/content.ts"],"names":[],"mappings":""}
@@ -145,9 +145,9 @@ component('page-posts', () => {
145
145
  const { data: posts, pending, error } = useFetch<Post[]>('/api/posts')
146
146
 
147
147
  return html`
148
- ${pending.value ? html`<p>Loading…</p>` : ''}
149
- ${error.value ? html`<p>Error: ${error.value.message}</p>` : ''}
150
- <ul>${posts.value?.map(p => html`<li>${p.title}</li>`)}</ul>
148
+ ${when(pending.value, () => html`<p>Loading…</p>`)}
149
+ ${when(!!error.value, () => html`<p>Error: ${error.value!.message}</p>`)}
150
+ <ul>${each(posts.value ?? [], p => html`<li>${p.title}</li>`)}</ul>
151
151
  `
152
152
  })
153
153
  ```
@@ -165,10 +165,10 @@ component('page-nav', () => {
165
165
  const { user, loggedIn, login, logout } = useAuth()
166
166
 
167
167
  return html`
168
- ${loggedIn
169
- ? html`<span>${user?.name}</span><button @click="${logout}">Log out</button>`
170
- : html`<button @click="${() => login('github')}">Log in</button>`
171
- }
168
+ ${match()
169
+ .when(loggedIn, () => html`<span>${user?.name}</span><button @click="${logout}">Log out</button>`)
170
+ .otherwise(() => html`<button @click="${() => login('github')}">Log in</button>`)
171
+ .done()}
172
172
  `
173
173
  })
174
174
  ```
@@ -736,10 +736,10 @@ component('locale-switcher', () => {
736
736
 
737
737
  return html`
738
738
  <nav>
739
- ${locales.map((l) => html`
739
+ ${each(locales, (l) => html`
740
740
  <a
741
- href="${switchLocalePath(l)}"
742
- aria-current="${l === locale ? 'true' : 'false'}"
741
+ :href="${switchLocalePath(l)}"
742
+ :aria-current="${l === locale ? 'true' : 'false'}"
743
743
  >${l.toUpperCase()}</a>
744
744
  `)}
745
745
  </nav>
@@ -798,3 +798,108 @@ If you need it outside auto-imported directories:
798
798
  ```ts
799
799
  import { navigateTo } from '@jasonshimmy/vite-plugin-cer-app/composables'
800
800
  ```
801
+
802
+ ---
803
+
804
+ ### `queryContent(path?)`
805
+
806
+ Queries content items from the file-based content layer. Returns a `QueryBuilder` that can be filtered, sorted, paginated, and terminated with `.find()`, `.first()`, or `.count()`.
807
+
808
+ Requires `content: {}` in `cer.config.ts`. See [content.md](./content.md) for full configuration, type reference, and rendering-mode behavior.
809
+
810
+ ```ts
811
+ // All items
812
+ const all = await queryContent().find()
813
+
814
+ // Blog posts only (path prefix)
815
+ const posts = await queryContent('/blog').sortBy('date', 'desc').find()
816
+
817
+ // Single full document (includes body + TOC)
818
+ const doc = await queryContent('/docs/getting-started').first()
819
+
820
+ // Count
821
+ const total = await queryContent().count()
822
+ ```
823
+
824
+ **QueryBuilder methods:**
825
+
826
+ | Method | Returns | Description |
827
+ |---|---|---|
828
+ | `.where(predicate)` | `QueryBuilder` | Predicate function — `(doc: ContentMeta) => boolean`. |
829
+ | `.sortBy(field, order?)` | `QueryBuilder` | Sort ascending (`'asc'`) or descending (`'desc'`). |
830
+ | `.limit(n)` | `QueryBuilder` | Return at most `n` items. |
831
+ | `.skip(n)` | `QueryBuilder` | Skip the first `n` items (pagination). |
832
+ | `.find()` | `Promise<ContentMeta[]>` | Execute, return lean metadata array. |
833
+ | `.first()` | `Promise<ContentItem \| null>` | Execute, return first full document with body and TOC. |
834
+ | `.count()` | `Promise<number>` | Execute, return count only. |
835
+
836
+ **Usage with a page loader:**
837
+
838
+ ```ts
839
+ component('page-blog', () => {
840
+ const ssrData = usePageData<{ posts: ContentMeta[] }>()
841
+ const posts = ref<ContentMeta[]>(ssrData?.posts ?? [])
842
+
843
+ useOnConnected(async () => {
844
+ if (ssrData) return // hydrated from loader
845
+ posts.value = await queryContent('/blog').find()
846
+ })
847
+
848
+ return html`
849
+ <ul>
850
+ ${each(posts.value, p => html`<li><a :href="${p._path}">${p.title}</a></li>`)}
851
+ </ul>
852
+ `
853
+ })
854
+
855
+ export const loader = async () => {
856
+ const posts = await queryContent('/blog').find()
857
+ return { posts }
858
+ }
859
+ ```
860
+
861
+ If you need it outside auto-imported directories:
862
+
863
+ ```ts
864
+ import { queryContent } from '@jasonshimmy/vite-plugin-cer-app/composables'
865
+ ```
866
+
867
+ ---
868
+
869
+ ### `useContentSearch()`
870
+
871
+ Reactive full-text search over the content layer. Loads the MiniSearch index lazily on first use. Returns `query` and `results` refs that update reactively as the user types.
872
+
873
+ Requires `content: {}` in `cer.config.ts`. See [content.md](./content.md) for full documentation.
874
+
875
+ ```ts
876
+ component('page-search', () => {
877
+ const { query, results } = useContentSearch()
878
+
879
+ return html`
880
+ <input type="search" :model="${query}" placeholder="Search…" />
881
+ <ul>
882
+ ${each(results.value, r => html`
883
+ <li><a :href="${r._path}">${r.title}</a></li>
884
+ `)}
885
+ </ul>
886
+ `
887
+ })
888
+ ```
889
+
890
+ **Return value:**
891
+
892
+ ```ts
893
+ interface UseContentSearchReturn {
894
+ query: Ref<string> // bind with :model
895
+ results: Ref<ContentSearchResult[]> // reactive search results
896
+ }
897
+ ```
898
+
899
+ Search activates when `query.value.length >= 2`. MiniSearch is loaded once and cached for the lifetime of the page. Searched fields are `title` and `description`.
900
+
901
+ If you need it outside auto-imported directories:
902
+
903
+ ```ts
904
+ import { useContentSearch } from '@jasonshimmy/vite-plugin-cer-app/composables'
905
+ ```
@@ -220,6 +220,8 @@ import {
220
220
  useCookie,
221
221
  useSession,
222
222
  useLocale,
223
+ queryContent,
224
+ useContentSearch,
223
225
  defineMiddleware,
224
226
  defineServerMiddleware,
225
227
  navigateTo,
@@ -284,6 +286,37 @@ See [cli.md](./cli.md#cer-app-adapt) for full details.
284
286
 
285
287
  ---
286
288
 
289
+ ## `content` options
290
+
291
+ Enables the file-based content layer. Drop Markdown or JSON files into `content/` at the project root and query them with `queryContent()` or search with `useContentSearch()`.
292
+
293
+ ```ts
294
+ export default defineConfig({
295
+ content: {
296
+ dir: 'content', // default
297
+ drafts: false, // default
298
+ },
299
+ })
300
+ ```
301
+
302
+ ### `content.dir`
303
+
304
+ **Type:** `string`
305
+ **Default:** `'content'`
306
+
307
+ Content directory relative to the project root. The default resolves to `{root}/content/`.
308
+
309
+ ### `content.drafts`
310
+
311
+ **Type:** `boolean`
312
+ **Default:** `false`
313
+
314
+ When `false`, files with `draft: true` in their frontmatter are excluded from the content store and search index in production builds.
315
+
316
+ See [content.md](./content.md) for full documentation.
317
+
318
+ ---
319
+
287
320
  ## `i18n` options
288
321
 
289
322
  Enables locale-aware URL routing and the `useLocale()` composable. No external package is required.
@@ -0,0 +1,436 @@
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
+ ### Date-prefixed filenames
135
+
136
+ Filenames starting with `YYYY-MM-DD-` have the date prefix stripped when computing the content path:
137
+
138
+ ```
139
+ content/blog/2026-04-01-hello.md → _path: '/blog/hello'
140
+ ```
141
+
142
+ ### Index files
143
+
144
+ Files named `index.md` have `/index` stripped from their path:
145
+
146
+ ```
147
+ content/blog/index.md → _path: '/blog'
148
+ content/index.md → _path: '/'
149
+ ```
150
+
151
+ ### JSON files
152
+
153
+ JSON files are read as-is. The `body` is the raw file content string — valid JSON, preserving the original formatting.
154
+
155
+ ```
156
+ content/data/features.json → _path: '/data/features', _type: 'json'
157
+ ```
158
+
159
+ ### Excerpt
160
+
161
+ 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`.
162
+
163
+ ```md
164
+ This paragraph is the excerpt.
165
+
166
+ <!-- more -->
167
+
168
+ This paragraph is only in the body.
169
+ ```
170
+
171
+ ---
172
+
173
+ ## TypeScript types
174
+
175
+ All types are exported from `@jasonshimmy/vite-plugin-cer-app` and are automatically available as globals inside pages, layouts, and components.
176
+
177
+ ```ts
178
+ import type {
179
+ ContentMeta,
180
+ ContentItem,
181
+ ContentHeading,
182
+ ContentSearchResult,
183
+ CerContentConfig,
184
+ } from '@jasonshimmy/vite-plugin-cer-app'
185
+ ```
186
+
187
+ ### `ContentMeta`
188
+
189
+ Lean per-document object returned by `.find()` and `.count()`. Does not include `body`, `toc`, or `excerpt`.
190
+
191
+ ```ts
192
+ interface ContentMeta {
193
+ _path: string // URL path, e.g. '/blog/hello'
194
+ _type: 'markdown' | 'json'
195
+ title?: string
196
+ description?: string
197
+ date?: string
198
+ draft?: boolean
199
+ [key: string]: unknown // any additional frontmatter key
200
+ }
201
+ ```
202
+
203
+ ### `ContentItem`
204
+
205
+ Full document returned by `.first()`. Extends `ContentMeta` with rendered body, TOC, and excerpt.
206
+
207
+ ```ts
208
+ interface ContentItem extends ContentMeta {
209
+ _file: string // relative source path, e.g. 'blog/hello.md'
210
+ body: string // rendered HTML (Markdown) or raw file content (JSON)
211
+ toc: ContentHeading[] // extracted headings
212
+ excerpt?: string // HTML before <!-- more --> (if marker present)
213
+ }
214
+ ```
215
+
216
+ ### `ContentHeading`
217
+
218
+ ```ts
219
+ interface ContentHeading {
220
+ depth: 1 | 2 | 3 | 4 | 5 | 6
221
+ id: string // slugified heading text, matches id= in body HTML
222
+ text: string // plain heading text
223
+ }
224
+ ```
225
+
226
+ ### `ContentSearchResult`
227
+
228
+ ```ts
229
+ interface ContentSearchResult {
230
+ _path: string
231
+ title: string
232
+ description?: string
233
+ }
234
+ ```
235
+
236
+ ---
237
+
238
+ ## `queryContent(path?)`
239
+
240
+ **Auto-imported** in pages, layouts, and components.
241
+
242
+ Returns a `QueryBuilder` scoped to the given path prefix. If `path` is omitted, queries all content.
243
+
244
+ ```ts
245
+ queryContent() // all items
246
+ queryContent('/blog') // items where _path starts with '/blog'
247
+ queryContent('/blog/hello') // items where _path starts with '/blog/hello'
248
+ ```
249
+
250
+ ### `QueryBuilder` methods
251
+
252
+ All terminal methods return a `Promise`.
253
+
254
+ #### `.where(predicate)`
255
+
256
+ Filters results. `predicate` is a function that receives a `ContentMeta` and returns `true` to include the item.
257
+
258
+ ```ts
259
+ await queryContent('/blog').where(doc => !doc.draft).find()
260
+ await queryContent().where(doc => /^\/docs/.test(doc._path)).find()
261
+ await queryContent().where(doc => Array.isArray(doc.tags) && (doc.tags as string[]).includes('web')).find()
262
+ ```
263
+
264
+ #### `.sortBy(field, order?)`
265
+
266
+ Sorts results by a field. `order` defaults to `'asc'`.
267
+
268
+ ```ts
269
+ await queryContent('/blog').sortBy('date', 'desc').find()
270
+ ```
271
+
272
+ #### `.limit(n)`
273
+
274
+ Returns at most `n` items.
275
+
276
+ ```ts
277
+ await queryContent('/blog').limit(5).find()
278
+ ```
279
+
280
+ #### `.skip(n)`
281
+
282
+ Skips the first `n` items (pagination).
283
+
284
+ ```ts
285
+ await queryContent('/blog').skip(10).limit(10).find()
286
+ ```
287
+
288
+ #### `.find()`
289
+
290
+ Executes the query and returns `Promise<ContentMeta[]>`.
291
+
292
+ ```ts
293
+ const posts = await queryContent('/blog').sortBy('date', 'desc').find()
294
+ ```
295
+
296
+ #### `.first()`
297
+
298
+ Returns `Promise<ContentItem | null>` — the first matching full document (includes `body`, `toc`, `excerpt`).
299
+
300
+ When a path is set and no other filters or sort are active, `first()` short-circuits to a direct item lookup for efficiency.
301
+
302
+ ```ts
303
+ const doc = await queryContent('/docs/getting-started').first()
304
+ if (doc) {
305
+ // doc.body, doc.toc, doc.excerpt
306
+ }
307
+ ```
308
+
309
+ #### `.count()`
310
+
311
+ Returns `Promise<number>` — the number of matching items (no body loaded).
312
+
313
+ ```ts
314
+ const total = await queryContent().count()
315
+ ```
316
+
317
+ ### Using with a page loader (SSR/SSG)
318
+
319
+ ```ts
320
+ component('page-blog', () => {
321
+ const ssrData = usePageData<{ posts: ContentMeta[] }>()
322
+ const posts = ref<ContentMeta[]>(ssrData?.posts ?? [])
323
+
324
+ useOnConnected(async () => {
325
+ if (ssrData) return // already hydrated from loader
326
+ posts.value = await queryContent('/blog').sortBy('date', 'desc').find()
327
+ })
328
+
329
+ return html`...`
330
+ })
331
+
332
+ export const loader = async () => {
333
+ const posts = await queryContent('/blog').sortBy('date', 'desc').find()
334
+ return { posts }
335
+ }
336
+ ```
337
+
338
+ ---
339
+
340
+ ## `useContentSearch()`
341
+
342
+ **Auto-imported** in pages, layouts, and components.
343
+
344
+ 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.
345
+
346
+ ```ts
347
+ const { query, results } = useContentSearch()
348
+ ```
349
+
350
+ ### Return value
351
+
352
+ ```ts
353
+ interface UseContentSearchReturn {
354
+ query: Ref<string> // bind to an <input> value
355
+ results: Ref<ContentSearchResult[]> // reactive search results
356
+ }
357
+ ```
358
+
359
+ ### Usage
360
+
361
+ ```ts
362
+ component('page-search', () => {
363
+ const { query, results } = useContentSearch()
364
+
365
+ return html`
366
+ <input type="search" :model="${query}" placeholder="Search…" />
367
+ <ul>
368
+ ${each(results.value, r => html`
369
+ <li><a :href="${r._path}">${r.title}</a></li>
370
+ `)}
371
+ </ul>
372
+ `
373
+ })
374
+ ```
375
+
376
+ Search activates when `query.value.length >= 2`. Empty or single-character queries return an empty array.
377
+
378
+ ### Searched fields
379
+
380
+ The MiniSearch index is built over `title` and `description`. The stored fields (`_path`, `title`, `description`) are returned in each result.
381
+
382
+ ---
383
+
384
+ ## Rendering modes
385
+
386
+ ### SPA mode
387
+
388
+ 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).
389
+
390
+ ### SSR mode
391
+
392
+ 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.
393
+
394
+ 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.
395
+
396
+ ### SSG mode
397
+
398
+ 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/`.
399
+
400
+ ---
401
+
402
+ ## Search index
403
+
404
+ 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.
405
+
406
+ In dev mode, `/_content/search-index.json` is served from the in-memory store by the dev middleware — no file is written to disk.
407
+
408
+ ---
409
+
410
+ ## Dev server
411
+
412
+ In dev mode, the Vite dev server intercepts all `/_content/*` requests:
413
+
414
+ | URL pattern | Response |
415
+ |---|---|
416
+ | `/_content/manifest.json` | JSON array of all `ContentMeta` items |
417
+ | `/_content/search-index.json` | Serialized MiniSearch index |
418
+ | `/_content/[path].json` | Full `ContentItem` for `_path === path` |
419
+
420
+ 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.
421
+
422
+ ---
423
+
424
+ ## Limitations
425
+
426
+ - **No aggregation** — `.count()` is the only aggregation terminal. Use `.find()` + `Array.prototype.length` for anything more complex.
427
+ - **Search fields only** — MiniSearch is configured to index `title` and `description`. Full-body search is not supported.
428
+ - **Content directory is fixed at build time** — changing `content.dir` at runtime has no effect.
429
+
430
+ ---
431
+
432
+ ## Known edge cases
433
+
434
+ - Filenames with multiple `YYYY-MM-DD-` date prefixes have only the leading prefix stripped.
435
+ - Markdown files with no headings have an empty `toc` array.
436
+ - JSON files with invalid JSON are skipped and a warning is logged to the console. The build continues without that file.