@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,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
|
+
}
|
package/src/types/config.ts
CHANGED
|
@@ -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
|
+
}
|