@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,121 @@
1
+ import {
2
+ createComposable,
3
+ ref,
4
+ useOnConnected,
5
+ watch,
6
+ } from '@jasonshimmy/custom-elements-runtime'
7
+ import type { ReactiveState } from '@jasonshimmy/custom-elements-runtime'
8
+ import type { ContentSearchResult } from '../../types/content.js'
9
+ import { contentSearchIndexUrl } from '../content/client.js'
10
+
11
+ // ─── Module-level singleton ───────────────────────────────────────────────────
12
+
13
+ // The MiniSearch instance is built lazily on first search from the manifest.
14
+ // The same promise is reused across all mounted instances so we only build once.
15
+ let _indexPromise: Promise<unknown> | null = null
16
+
17
+ /**
18
+ * Lazily loads the MiniSearch index from `/_content/search-index.json`.
19
+ * Returns the same Promise on repeated calls — the index is built at most once
20
+ * per session regardless of how many search components are mounted.
21
+ *
22
+ * @internal Exported for unit testing only.
23
+ */
24
+ export async function loadIndex(): Promise<unknown> {
25
+ if (_indexPromise) return _indexPromise
26
+ _indexPromise = (async () => {
27
+ const [{ default: MiniSearch }, raw] = await Promise.all([
28
+ import('minisearch'),
29
+ fetch(contentSearchIndexUrl()).then((r) => {
30
+ if (!r.ok) throw new Error(`Failed to fetch search index: ${r.status}`)
31
+ return r.text()
32
+ }),
33
+ ])
34
+ return MiniSearch.loadJSON(raw, {
35
+ fields: ['title', 'description'],
36
+ storeFields: ['_path', 'title', 'description'],
37
+ idField: '_path',
38
+ })
39
+ })()
40
+ return _indexPromise
41
+ }
42
+
43
+ /** Resets the module-level singleton. Used in tests only. @internal */
44
+ export function resetIndexSingleton(): void {
45
+ _indexPromise = null
46
+ }
47
+
48
+ // ─── Composable ───────────────────────────────────────────────────────────────
49
+
50
+ export interface UseContentSearchReturn {
51
+ query: ReactiveState<string>
52
+ results: ReactiveState<ContentSearchResult[]>
53
+ }
54
+
55
+ const _factory = createComposable((): UseContentSearchReturn => {
56
+ const query = ref('')
57
+ const results = ref<ContentSearchResult[]>([])
58
+
59
+ // Pre-warm index on mount
60
+ useOnConnected(() => {
61
+ loadIndex().catch(() => {/* silently ignore pre-warm errors */})
62
+ })
63
+
64
+ // Monotonic counter to discard stale async results
65
+ let _seq = 0
66
+
67
+ watch(query, async (q: string) => {
68
+ const seq = ++_seq
69
+
70
+ if (!q || q.length < 2) {
71
+ results.value = []
72
+ return
73
+ }
74
+
75
+ try {
76
+ const index = await loadIndex() as { search(q: string, opts?: { prefix?: boolean }): ContentSearchResult[] }
77
+ if (seq !== _seq) return // stale — a newer query is in flight
78
+ results.value = index.search(q, { prefix: true }) as ContentSearchResult[]
79
+ } catch {
80
+ if (seq !== _seq) return
81
+ results.value = []
82
+ }
83
+ })
84
+
85
+ return { query, results }
86
+ })
87
+
88
+ /**
89
+ * Full-text content search composable.
90
+ *
91
+ * Loads a pre-built MiniSearch index lazily on first use by fetching
92
+ * `/_content/search-index.json`. Both MiniSearch and the index are loaded via
93
+ * dynamic import — neither is in the app bundle.
94
+ *
95
+ * Searches `title` and `description` fields. Results are empty until at least
96
+ * 2 characters are entered.
97
+ *
98
+ * **SSR note**: search is always client-side. In SSR mode the component renders
99
+ * with empty results and hydrates on mount.
100
+ *
101
+ * @example
102
+ * ```ts
103
+ * component('site-search', () => {
104
+ * const { query, results } = useContentSearch()
105
+ *
106
+ * return html`
107
+ * <input type="search" :model="${query}" placeholder="Search…" />
108
+ * ${when(results.value.length > 0, () => html`
109
+ * <ul>
110
+ * ${each(results.value, r => html`
111
+ * <li><a :href="${r._path}">${r.title}</a></li>
112
+ * `)}
113
+ * </ul>
114
+ * `)}
115
+ * `
116
+ * })
117
+ * ```
118
+ */
119
+ export function useContentSearch(): UseContentSearchReturn {
120
+ return _factory()
121
+ }
@@ -0,0 +1,146 @@
1
+ import type { ContentItem, ContentMeta } from '../../types/content.js'
2
+ import { ContentClient } from '../content/client.js'
3
+
4
+ // ─── QueryBuilder ─────────────────────────────────────────────────────────────
5
+
6
+ export class QueryBuilder {
7
+ private _prefix: string | undefined
8
+ private _predicates: Array<(doc: ContentMeta) => boolean> = []
9
+ private _sortField: string | undefined
10
+ private _sortDir: 'asc' | 'desc' = 'asc'
11
+ private _limit: number | undefined
12
+ private _skip: number | undefined
13
+
14
+ constructor(prefix?: string) {
15
+ this._prefix = prefix
16
+ }
17
+
18
+ /** Filter results by a predicate. Receives a fully-typed `ContentMeta`. */
19
+ where(predicate: (doc: ContentMeta) => boolean): this {
20
+ this._predicates.push(predicate)
21
+ return this
22
+ }
23
+
24
+ /** Sort by a frontmatter field. Defaults to ascending order. */
25
+ sortBy(field: string, dir: 'asc' | 'desc' = 'asc'): this {
26
+ this._sortField = field
27
+ this._sortDir = dir
28
+ return this
29
+ }
30
+
31
+ /** Cap the result count. */
32
+ limit(n: number): this {
33
+ this._limit = n
34
+ return this
35
+ }
36
+
37
+ /** Skip the first `n` results (for pagination). */
38
+ skip(n: number): this {
39
+ this._skip = n
40
+ return this
41
+ }
42
+
43
+ /** Returns all matching `ContentMeta` items (no body loaded). */
44
+ async find(): Promise<ContentMeta[]> {
45
+ const manifest = await ContentClient.getManifest()
46
+ return this._applyFilters(manifest)
47
+ }
48
+
49
+ /** Returns the count of matching documents (no body loaded). */
50
+ async count(): Promise<number> {
51
+ const manifest = await ContentClient.getManifest()
52
+ return this._applyFilters(manifest).length
53
+ }
54
+
55
+ /**
56
+ * Returns the full `ContentItem` (body + toc) for the first matching document.
57
+ * When a `_path` prefix is provided and no predicates/sort/pagination are set,
58
+ * this fetches the single document directly by path.
59
+ */
60
+ async first(): Promise<ContentItem | null> {
61
+ // Fast path: no filters, no sort, no pagination — fetch directly by path
62
+ if (
63
+ this._prefix !== undefined &&
64
+ this._predicates.length === 0 &&
65
+ this._sortField === undefined &&
66
+ this._limit === undefined &&
67
+ this._skip === undefined
68
+ ) {
69
+ return ContentClient.getItem(this._prefix)
70
+ }
71
+
72
+ // Slow path: apply filters on the manifest, then fetch the first match
73
+ const manifest = await ContentClient.getManifest()
74
+ const filtered = this._applyFilters(manifest)
75
+ if (filtered.length === 0) return null
76
+ return ContentClient.getItem(filtered[0]._path)
77
+ }
78
+
79
+ private _applyFilters(items: ContentMeta[]): ContentMeta[] {
80
+ let result = items
81
+
82
+ // Prefix filter
83
+ if (this._prefix !== undefined) {
84
+ const p = this._prefix
85
+ result = result.filter(
86
+ (doc) => doc._path === p || doc._path.startsWith(p + '/'),
87
+ )
88
+ }
89
+
90
+ // Where predicates
91
+ for (const pred of this._predicates) {
92
+ result = result.filter(pred)
93
+ }
94
+
95
+ // Sort
96
+ if (this._sortField !== undefined) {
97
+ const field = this._sortField
98
+ const dir = this._sortDir
99
+ result = [...result].sort((a, b) => {
100
+ const av = a[field] as string | number | undefined
101
+ const bv = b[field] as string | number | undefined
102
+ if (av === undefined && bv === undefined) return 0
103
+ if (av === undefined) return 1
104
+ if (bv === undefined) return -1
105
+ const cmp = av < bv ? -1 : av > bv ? 1 : 0
106
+ return dir === 'asc' ? cmp : -cmp
107
+ })
108
+ }
109
+
110
+ // Skip
111
+ if (this._skip !== undefined) {
112
+ result = result.slice(this._skip)
113
+ }
114
+
115
+ // Limit
116
+ if (this._limit !== undefined) {
117
+ result = result.slice(0, this._limit)
118
+ }
119
+
120
+ return result
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Content query composable.
126
+ *
127
+ * Returns a `QueryBuilder` scoped to the given `_path` prefix. Chain filter,
128
+ * sort, and pagination methods before calling a terminal method
129
+ * (`.find()`, `.first()`, or `.count()`).
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * // Single document
134
+ * const doc = await queryContent('/blog/hello').first()
135
+ *
136
+ * // Listing — no body loaded
137
+ * const posts = await queryContent('/blog')
138
+ * .where(doc => !doc.draft)
139
+ * .sortBy('date', 'desc')
140
+ * .limit(10)
141
+ * .find()
142
+ * ```
143
+ */
144
+ export function queryContent(path?: string): QueryBuilder {
145
+ return new QueryBuilder(path)
146
+ }
@@ -0,0 +1,168 @@
1
+ import type { ContentItem, ContentMeta } from '../../types/content.js'
2
+
3
+ // ─── Types ─────────────────────────────────────────────────────────────────────
4
+
5
+ /** Reads `router.base` from `virtual:cer-app-config` at module initialisation. */
6
+ let _base: string = ''
7
+
8
+ try {
9
+ // Dynamic import is not viable for a module-level side effect, so we read from
10
+ // globalThis where the virtual module writes it during app bootstrap.
11
+ const g = globalThis as Record<string, unknown>
12
+ const appConfig = g['__CER_APP_CONFIG__'] as { router?: { base?: string } } | undefined
13
+ _base = appConfig?.router?.base ?? ''
14
+ // Normalise: strip trailing slash, keep empty string for no base
15
+ if (_base === '/') _base = ''
16
+ } catch {
17
+ _base = ''
18
+ }
19
+
20
+ // ─── Path helpers ─────────────────────────────────────────────────────────────
21
+
22
+ function contentPathToJsonFile(path: string): string {
23
+ if (path === '/') return 'index.json'
24
+ return path.slice(1) + '.json'
25
+ }
26
+
27
+ /** Returns the full URL for the search index (includes router.base prefix). */
28
+ export function contentSearchIndexUrl(): string {
29
+ return `${_base}/_content/search-index.json`
30
+ }
31
+
32
+ // ─── Lazy manifest cache ──────────────────────────────────────────────────────
33
+
34
+ let _manifestPromise: Promise<ContentMeta[]> | null = null
35
+
36
+ function fetchManifest(): Promise<ContentMeta[]> {
37
+ if (_manifestPromise) return _manifestPromise
38
+ _manifestPromise = fetch(`${_base}/_content/manifest.json`).then((r) => {
39
+ if (!r.ok) throw new Error(`Failed to fetch content manifest: ${r.status}`)
40
+ return r.json() as Promise<ContentMeta[]>
41
+ })
42
+ return _manifestPromise
43
+ }
44
+
45
+ // ─── Server-side production caches ───────────────────────────────────────────
46
+ // In production SSR the Vite build process is not running, so __CER_CONTENT_STORE__
47
+ // is absent. We cache the manifest and per-document reads here to avoid repeated
48
+ // disk I/O on every request — critical at 10k+ pages where manifest.json is ~2MB.
49
+
50
+ let _ssrManifest: ContentMeta[] | null = null
51
+ const _ssrItemCache = new Map<string, ContentItem | null>()
52
+
53
+ // ─── ContentClient ────────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Low-level content data access layer. Used by `queryContent()`.
57
+ *
58
+ * Resolution strategy:
59
+ * - **Server (dev + SSG build-time)**: reads from `globalThis.__CER_CONTENT_STORE__`
60
+ * populated by the `cerContent()` Vite plugin's `buildStart` hook.
61
+ * - **Server (production SSR runtime)**: `__CER_CONTENT_STORE__` is absent (no
62
+ * Vite build process at runtime), so falls back to `node:fs` reads from
63
+ * `dist/_content/`.
64
+ * - **Client (SPA / browser navigation)**: lazy-fetches `/_content/manifest.json`
65
+ * once (cached) for listing; fetches `/_content/[path].json` per `.first()`.
66
+ */
67
+ export const ContentClient = {
68
+ async getManifest(): Promise<ContentMeta[]> {
69
+ const g = globalThis as Record<string, unknown>
70
+
71
+ // Server: in-memory store (dev + SSG build-time rendering)
72
+ const store = g['__CER_CONTENT_STORE__'] as ContentItem[] | undefined
73
+ if (store) {
74
+ return store.map((item) => {
75
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
76
+ const { _file, body, toc, excerpt, ...meta } = item
77
+ return meta as ContentMeta
78
+ })
79
+ }
80
+
81
+ // Server: production SSR runtime — resolve content files from the app root.
82
+ // The preview CLI sets process.env.__CER_APP_ROOT__ to the project root before
83
+ // loading the server bundle. Content files are written to dist/server/_content/
84
+ // and dist/client/_content/ by cerContent's closeBundle hook.
85
+ // _ssrManifest is a module-level cache — populated once and reused for the
86
+ // lifetime of the process so manifest.json is only read and parsed once at scale.
87
+ if (typeof window === 'undefined' && typeof process !== 'undefined') {
88
+ if (_ssrManifest) return _ssrManifest
89
+ try {
90
+ const { readFileSync, existsSync } = await import('node:fs')
91
+ const { join } = await import('node:path')
92
+ const appRoot = process.env.__CER_APP_ROOT__ ?? process.cwd()
93
+ const candidates = [
94
+ join(appRoot, 'dist', 'server', '_content', 'manifest.json'),
95
+ join(appRoot, 'dist', 'client', '_content', 'manifest.json'),
96
+ join(appRoot, 'dist', '_content', 'manifest.json'),
97
+ ]
98
+ for (const p of candidates) {
99
+ if (!existsSync(p)) continue
100
+ try {
101
+ const raw = readFileSync(p, 'utf-8')
102
+ _ssrManifest = JSON.parse(raw) as ContentMeta[]
103
+ return _ssrManifest
104
+ } catch { /* try next */ }
105
+ }
106
+ _ssrManifest = []
107
+ return _ssrManifest
108
+ } catch {
109
+ return []
110
+ }
111
+ }
112
+
113
+ // Client: fetch manifest (cached)
114
+ return fetchManifest()
115
+ },
116
+
117
+ async getItem(path: string): Promise<ContentItem | null> {
118
+ const g = globalThis as Record<string, unknown>
119
+
120
+ // Server: in-memory store
121
+ const store = g['__CER_CONTENT_STORE__'] as ContentItem[] | undefined
122
+ if (store) {
123
+ return store.find((item) => item._path === path) ?? null
124
+ }
125
+
126
+ // Server: production SSR runtime — see getManifest() for path resolution strategy.
127
+ // _ssrItemCache is a module-level Map so each document is read and parsed at
128
+ // most once per process lifetime, regardless of how many concurrent requests
129
+ // ask for the same path.
130
+ if (typeof window === 'undefined' && typeof process !== 'undefined') {
131
+ if (_ssrItemCache.has(path)) return _ssrItemCache.get(path) ?? null
132
+ try {
133
+ const { readFileSync, existsSync } = await import('node:fs')
134
+ const { join } = await import('node:path')
135
+ const appRoot = process.env.__CER_APP_ROOT__ ?? process.cwd()
136
+ const jsonFile = contentPathToJsonFile(path)
137
+ const candidates = [
138
+ join(appRoot, 'dist', 'server', '_content', jsonFile),
139
+ join(appRoot, 'dist', 'client', '_content', jsonFile),
140
+ join(appRoot, 'dist', '_content', jsonFile),
141
+ ]
142
+ for (const p of candidates) {
143
+ if (!existsSync(p)) continue
144
+ try {
145
+ const raw = readFileSync(p, 'utf-8')
146
+ const item = JSON.parse(raw) as ContentItem
147
+ _ssrItemCache.set(path, item)
148
+ return item
149
+ } catch { /* try next */ }
150
+ }
151
+ _ssrItemCache.set(path, null)
152
+ return null
153
+ } catch {
154
+ return null
155
+ }
156
+ }
157
+
158
+ // Client: fetch individual document
159
+ const jsonFile = contentPathToJsonFile(path)
160
+ try {
161
+ const res = await fetch(`${_base}/_content/${jsonFile}`)
162
+ if (!res.ok) return null
163
+ return (await res.json()) as ContentItem
164
+ } catch {
165
+ return null
166
+ }
167
+ },
168
+ }
@@ -222,6 +222,8 @@ export interface RuntimeConfig {
222
222
  export interface CerAppConfig {
223
223
  mode?: 'spa' | 'ssr' | 'ssg'
224
224
  srcDir?: string // defaults to 'app'
225
+ /** File-based content layer configuration. Reads from `content/` at the project root by default. */
226
+ content?: import('./content.js').CerContentConfig
225
227
  /**
226
228
  * Internationalisation (i18n) routing configuration.
227
229
  * When set, the framework generates locale-aware URL routes and enables `useLocale()`.
@@ -0,0 +1,66 @@
1
+ /** Heading extracted from Markdown during parsing. The `id` is slugified from the heading text and added as an HTML `id` attribute. */
2
+ export interface ContentHeading {
3
+ depth: 1 | 2 | 3 | 4 | 5 | 6
4
+ /** Slugified heading text — matches the `id` attribute in the rendered body HTML. */
5
+ id: string
6
+ /** Plain text of the heading. */
7
+ text: string
8
+ }
9
+
10
+ /**
11
+ * Lean per-document metadata — returned by `.find()` and `.count()`.
12
+ * Kept small deliberately: no `body`, no `toc`, no `excerpt`.
13
+ * Use `description` for listing previews; set it in frontmatter.
14
+ */
15
+ export interface ContentMeta {
16
+ /** URL path (e.g. `"/blog/hello"`). */
17
+ _path: string
18
+ /** Source file type. */
19
+ _type: 'markdown' | 'json'
20
+ title?: string
21
+ /** Use for listing previews — included in search index. */
22
+ description?: string
23
+ date?: string
24
+ draft?: boolean
25
+ /** Any other frontmatter key. */
26
+ [key: string]: unknown
27
+ }
28
+
29
+ /**
30
+ * Full document — returned by `.first()`.
31
+ * Superset of `ContentMeta`; includes `body`, `toc`, `_file`, and optional `excerpt`.
32
+ */
33
+ export interface ContentItem extends ContentMeta {
34
+ /** Relative path from `content/` at the project root (e.g. `"blog/hello.md"`). */
35
+ _file: string
36
+ /** Rendered HTML (Markdown) or the raw file contents (JSON files). */
37
+ body: string
38
+ /** Extracted headings. Empty array for JSON files. */
39
+ toc: ContentHeading[]
40
+ /** HTML content before `<!-- more -->`. Absent when the marker is not present. */
41
+ excerpt?: string
42
+ }
43
+
44
+ /**
45
+ * Search result item returned by `useContentSearch()`.
46
+ * Contains only the MiniSearch stored fields: `_path`, `title`, `description`.
47
+ */
48
+ export interface ContentSearchResult {
49
+ _path: string
50
+ title: string
51
+ description?: string
52
+ }
53
+
54
+ /** Content layer configuration. Controls the directory and draft behaviour. */
55
+ export interface CerContentConfig {
56
+ /**
57
+ * Content directory relative to the project root. Defaults to `'content'`,
58
+ * which resolves to `{root}/content/` — at the same level as `app/`, `server/`, and `public/`.
59
+ */
60
+ dir?: string
61
+ /**
62
+ * When `true`, draft items (`draft: true` in frontmatter) are included in
63
+ * production builds. Defaults to `false`.
64
+ */
65
+ drafts?: boolean
66
+ }