@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 @@
|
|
|
1
|
+
{"version":3,"file":"content.js","sourceRoot":"","sources":["../../src/types/content.ts"],"names":[],"mappings":""}
|
package/docs/composables.md
CHANGED
|
@@ -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
|
|
149
|
-
${error.value
|
|
150
|
-
<ul>${posts.value
|
|
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
|
-
${
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
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
|
+
```
|
package/docs/configuration.md
CHANGED
|
@@ -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.
|
package/docs/content.md
ADDED
|
@@ -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.
|