mkdnsite 0.0.1 → 1.1.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.
@@ -28,11 +28,131 @@ export interface MkdnSiteConfig {
28
28
  /** Client-side enhancement modules */
29
29
  client: ClientConfig
30
30
 
31
+ /**
32
+ * Preset that configures sensible defaults for common use cases.
33
+ * Applied before user config — any explicit user setting overrides the preset.
34
+ * - 'docs': nav + TOC + prev/next (default-like, best for documentation sites)
35
+ * - 'blog': page title + date + reading time + prev/next, no nav sidebar
36
+ */
37
+ preset?: 'docs' | 'blog'
38
+
31
39
  /** Markdown renderer engine (default: 'portable') */
32
40
  renderer: RendererEngine
33
41
 
34
42
  /** Static files directory for images, videos, etc. */
35
43
  staticDir?: string
44
+
45
+ /**
46
+ * Glob patterns to include. Only matching files will be served.
47
+ * Mutually exclusive with `exclude` — define one or the other, not both.
48
+ * e.g. ['docs', 'guides/*.md']
49
+ */
50
+ include?: string[]
51
+
52
+ /**
53
+ * Glob patterns to exclude. Matching files will not be served.
54
+ * Mutually exclusive with `include` — define one or the other, not both.
55
+ * e.g. ['private', '*.draft.md']
56
+ */
57
+ exclude?: string[]
58
+
59
+ /** GitHub repository source (alternative to local contentDir) */
60
+ github?: import('../content/types.ts').GitHubSourceConfig
61
+
62
+ /** MCP (Model Context Protocol) server configuration */
63
+ mcp: McpConfig
64
+
65
+ /** Analytics configuration (disabled by default) */
66
+ analytics?: AnalyticsConfig
67
+
68
+ /** Content Security Policy configuration */
69
+ csp?: CspConfig
70
+
71
+ /** Response caching and CDN header configuration */
72
+ cache?: CacheConfig
73
+ }
74
+
75
+ /** MCP (Model Context Protocol) server configuration */
76
+ export interface McpConfig {
77
+ /** Enable the built-in MCP server (default: true) */
78
+ enabled: boolean
79
+ /** MCP endpoint path (default: '/mcp') */
80
+ endpoint?: string
81
+ }
82
+
83
+ /** Content Security Policy configuration */
84
+ export interface CspConfig {
85
+ /** Enable the Content-Security-Policy header on HTML responses (default: true) */
86
+ enabled: boolean
87
+ /** Additional script-src sources */
88
+ extraScriptSrc?: string[]
89
+ /** Additional style-src sources */
90
+ extraStyleSrc?: string[]
91
+ /** Additional img-src sources */
92
+ extraImgSrc?: string[]
93
+ /** Additional connect-src sources */
94
+ extraConnectSrc?: string[]
95
+ /** Additional font-src sources */
96
+ extraFontSrc?: string[]
97
+ /** Report URI for CSP violation reports */
98
+ reportUri?: string
99
+ }
100
+
101
+ /** Response caching and CDN header configuration */
102
+ export interface CacheConfig {
103
+ /** Enable response caching (default: false — opt-in) */
104
+ enabled?: boolean
105
+ /** Cache-Control max-age in seconds for HTML responses (default: 300) */
106
+ maxAge?: number
107
+ /** Cache-Control max-age in seconds for markdown responses (default: 300) */
108
+ maxAgeMarkdown?: number
109
+ /** stale-while-revalidate in seconds (default: 0, meaning omitted) */
110
+ staleWhileRevalidate?: number
111
+ /** Version tag for ETag and CDN cache busting (e.g. 'v1.2.3' or git SHA) */
112
+ versionTag?: string
113
+ }
114
+
115
+ /** Analytics configuration */
116
+ export interface AnalyticsConfig {
117
+ /** Google Analytics 4 (GA4) configuration */
118
+ googleAnalytics?: {
119
+ /** GA4 measurement ID, e.g. 'G-XXXXXXXXXX' */
120
+ measurementId: string
121
+ }
122
+ /** Server-side traffic analytics (default: disabled) */
123
+ traffic?: TrafficAnalyticsConfig
124
+ }
125
+
126
+ /** Server-side traffic analytics configuration */
127
+ export interface TrafficAnalyticsConfig {
128
+ /**
129
+ * Enable server-side traffic analytics.
130
+ * When false (default) the analytics hook is skipped entirely.
131
+ */
132
+ enabled?: boolean
133
+ /**
134
+ * Log each request as a JSON line to stdout.
135
+ * Useful for development / debugging. Requires enabled: true.
136
+ */
137
+ console?: boolean
138
+ }
139
+
140
+ /** OpenGraph / social sharing meta tag configuration */
141
+ export interface OgConfig {
142
+ /** Default OG image URL (can be overridden per-page via frontmatter `og_image`) */
143
+ image?: string
144
+ /** Default OG type (default: 'website' for index, 'article' for other pages) */
145
+ type?: string
146
+ /** Twitter card type: 'summary' or 'summary_large_image' (default: 'summary') */
147
+ twitterCard?: 'summary' | 'summary_large_image'
148
+ /** Twitter @handle for the site (e.g. '@mkdnsite') */
149
+ twitterSite?: string
150
+ }
151
+
152
+ /** Favicon configuration */
153
+ export interface FaviconConfig {
154
+ /** Path or URL to the favicon file (.ico, .png, .svg) */
155
+ src: string
36
156
  }
37
157
 
38
158
  export interface SiteConfig {
@@ -40,6 +160,10 @@ export interface SiteConfig {
40
160
  description?: string
41
161
  url?: string
42
162
  lang?: string
163
+ /** OpenGraph / social sharing meta tag configuration */
164
+ og?: OgConfig
165
+ /** Favicon configuration */
166
+ favicon?: FaviconConfig
43
167
  }
44
168
 
45
169
  export interface ServerConfig {
@@ -47,6 +171,52 @@ export interface ServerConfig {
47
171
  hostname: string
48
172
  }
49
173
 
174
+ /** CSS color token overrides for the built-in theme */
175
+ export interface ColorTokens {
176
+ /** Primary accent color (links, highlights) */
177
+ accent?: string
178
+ /** Main text color */
179
+ text?: string
180
+ /** Muted text color */
181
+ textMuted?: string
182
+ /** Page background color */
183
+ bg?: string
184
+ /** Alternate background (nav, code blocks) */
185
+ bgAlt?: string
186
+ /** Border color */
187
+ border?: string
188
+ /** Link color */
189
+ link?: string
190
+ /** Link hover color */
191
+ linkHover?: string
192
+ /** Inline code background */
193
+ codeBg?: string
194
+ /** Code block (pre) background */
195
+ preBg?: string
196
+ }
197
+
198
+ /** Font stack overrides for the built-in theme */
199
+ export interface FontTokens {
200
+ /** Body/prose font stack */
201
+ body?: string
202
+ /** Monospace font stack */
203
+ mono?: string
204
+ /** Heading font stack (defaults to body font) */
205
+ heading?: string
206
+ }
207
+
208
+ /** Logo image configuration */
209
+ export interface LogoConfig {
210
+ /** Path or URL to the logo image */
211
+ src: string
212
+ /** Alt text for the logo image */
213
+ alt?: string
214
+ /** Logo display width in pixels (default: 32) */
215
+ width?: number
216
+ /** Logo display height in pixels (default: 32) */
217
+ height?: number
218
+ }
219
+
50
220
  export interface ThemeConfig {
51
221
  /**
52
222
  * Rendering mode for markdown content.
@@ -58,9 +228,48 @@ export interface ThemeConfig {
58
228
  /** Custom React components to override default element rendering */
59
229
  components?: ComponentOverrides
60
230
 
61
- /** Custom CSS file path or URL to use instead of default theme */
231
+ /**
232
+ * Inline CSS string appended after the built-in theme styles.
233
+ * Use this for small tweaks. For full replacement, set builtinCss: false.
234
+ */
62
235
  customCss?: string
63
236
 
237
+ /** URL to an external stylesheet loaded via <link rel="stylesheet"> */
238
+ customCssUrl?: string
239
+
240
+ /**
241
+ * Include the built-in theme CSS. Set to false to strip all default
242
+ * styles and start from a blank slate. (default: true)
243
+ */
244
+ builtinCss?: boolean
245
+
246
+ /** Light mode CSS color token overrides */
247
+ colors?: ColorTokens
248
+
249
+ /** Dark mode CSS color token overrides */
250
+ colorsDark?: ColorTokens
251
+
252
+ /** Font stack overrides */
253
+ fonts?: FontTokens
254
+
255
+ /** Logo image displayed in the nav header */
256
+ logo?: LogoConfig
257
+
258
+ /** Site name / text logo displayed in the nav header */
259
+ logoText?: string
260
+
261
+ /** Render page title from frontmatter as <h1> above article content */
262
+ pageTitle?: boolean
263
+
264
+ /** Render publish/update date from frontmatter below the page title */
265
+ pageDate?: boolean
266
+
267
+ /** Show prev/next page navigation links at the bottom of the article */
268
+ prevNext?: boolean
269
+
270
+ /** Show estimated reading time near the page date */
271
+ readingTime?: boolean
272
+
64
273
  /** Show navigation sidebar */
65
274
  showNav: boolean
66
275
 
@@ -74,7 +283,7 @@ export interface ThemeConfig {
74
283
  colorScheme: 'system' | 'light' | 'dark'
75
284
 
76
285
  /** Syntax highlighting theme for Shiki */
77
- syntaxTheme: string
286
+ syntaxTheme?: string
78
287
 
79
288
  /** Dark mode syntax highlighting theme */
80
289
  syntaxThemeDark?: string
@@ -125,6 +334,9 @@ export interface ClientConfig {
125
334
 
126
335
  /** Enable client-side search (default: true when client enabled) */
127
336
  search: boolean
337
+
338
+ /** Enable Chart.js chart rendering (default: true when client enabled) */
339
+ charts: boolean
128
340
  }
129
341
 
130
342
  /**
@@ -0,0 +1,202 @@
1
+ import { parseFrontmatter } from './frontmatter.ts'
2
+ import type { ContentSource, ContentPage, NavNode } from './types.ts'
3
+ import type { ContentCache } from './cache.ts'
4
+ import { MemoryContentCache } from './cache.ts'
5
+ import { buildNavTree } from './nav-builder.ts'
6
+
7
+ interface ParsedFile {
8
+ path: string
9
+ meta: Record<string, unknown>
10
+ body: string
11
+ raw: string
12
+ }
13
+
14
+ export interface AssetsSourceConfig {
15
+ /** Workers Static Assets Fetcher binding */
16
+ assets: AssetsFetcher
17
+ /** Explicit list of .md file paths (if _manifest.json is not available) */
18
+ manifest?: string[]
19
+ /** Optional cache layer (defaults to in-memory). Pass KVContentCache for durable caching. */
20
+ cache?: ContentCache
21
+ }
22
+
23
+ // ─── Workers Static Assets type stub ─────────────────────────────────────────
24
+
25
+ interface AssetsFetcher {
26
+ fetch: (input: Request | string) => Promise<Response>
27
+ }
28
+
29
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
30
+
31
+ function slugToRelKey (slug: string): string {
32
+ const stripped = slug.replace(/^\/+|\.md$/g, '').replace(/\/+$/, '')
33
+ return stripped === '' ? 'index' : stripped
34
+ }
35
+
36
+ // ─── AssetsSource ────────────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * Content source that reads .md files from Cloudflare Workers Static Assets.
40
+ *
41
+ * Workers Static Assets are deployed alongside the Worker via wrangler deploy.
42
+ * Since the ASSETS binding doesn't support directory listing, a manifest of
43
+ * .md file paths is required. The manifest can be provided via:
44
+ *
45
+ * 1. A `_manifest.json` file in the assets directory (auto-discovered)
46
+ * 2. An explicit `manifest` array in the config
47
+ *
48
+ * Generate a manifest with:
49
+ * find content -name '*.md' -printf '%P\n' | sort | jq -R -s 'split("\n") | map(select(. != ""))' > content/_manifest.json
50
+ *
51
+ * Or use the helper:
52
+ * npx mkdnsite manifest --dir content > content/_manifest.json
53
+ */
54
+ export class AssetsSource implements ContentSource {
55
+ private readonly assets: AssetsFetcher
56
+ private readonly explicitManifest: string[] | null
57
+ private readonly cache: ContentCache
58
+
59
+ private rawCache: Map<string, ParsedFile> | null = null
60
+ private rawCacheExpiresAt: number = 0
61
+ private initPromise: Promise<Map<string, ParsedFile>> | null = null
62
+
63
+ constructor (config: AssetsSourceConfig) {
64
+ this.assets = config.assets
65
+ this.explicitManifest = config.manifest ?? null
66
+ this.cache = config.cache ?? new MemoryContentCache()
67
+ }
68
+
69
+ // ─── Public API ────────────────────────────────────────────────────────────
70
+
71
+ async getPage (slug: string): Promise<ContentPage | null> {
72
+ const key = slugToRelKey(slug)
73
+
74
+ // Try cache first
75
+ for (const candidate of [key, key + '/index', key + '/README', key + '/readme']) {
76
+ const cached = await this.cache.getPage(candidate)
77
+ if (cached != null) return cached
78
+ }
79
+
80
+ // Fall through to prefetch
81
+ const pages = await this.ensurePrefetched()
82
+
83
+ for (const candidate of [key, key + '/index', key + '/README', key + '/readme']) {
84
+ const file = pages.get(candidate)
85
+ if (file != null && file.meta.draft !== true) {
86
+ const page = this.toContentPage(file)
87
+ await this.cache.setPage(candidate, page)
88
+ return page
89
+ }
90
+ }
91
+ return null
92
+ }
93
+
94
+ async getNavTree (): Promise<NavNode> {
95
+ const cached = await this.cache.getNav()
96
+ if (cached != null) return cached
97
+
98
+ const pages = await this.ensurePrefetched()
99
+ const files = Array.from(pages.values())
100
+ const nav = buildNavTree(files)
101
+ await this.cache.setNav(nav)
102
+ return nav
103
+ }
104
+
105
+ async listPages (): Promise<ContentPage[]> {
106
+ const cached = await this.cache.getPageList()
107
+ if (cached != null) return cached
108
+
109
+ const pages = await this.ensurePrefetched()
110
+ const result = Array.from(pages.values())
111
+ .filter(f => f.meta.draft !== true)
112
+ .map(f => this.toContentPage(f))
113
+ await this.cache.setPageList(result)
114
+ return result
115
+ }
116
+
117
+ async refresh (): Promise<void> {
118
+ this.rawCache = null
119
+ this.rawCacheExpiresAt = 0
120
+ this.initPromise = null
121
+ await this.cache.clear()
122
+ }
123
+
124
+ // ─── Internal ──────────────────────────────────────────────────────────────
125
+
126
+ private async ensurePrefetched (): Promise<Map<string, ParsedFile>> {
127
+ if (this.rawCache != null && Date.now() < this.rawCacheExpiresAt) {
128
+ return this.rawCache
129
+ }
130
+ if (this.initPromise != null) return await this.initPromise
131
+ this.initPromise = this.prefetchAll()
132
+ const result = await this.initPromise
133
+ this.initPromise = null
134
+ return result
135
+ }
136
+
137
+ private async getManifest (): Promise<string[]> {
138
+ if (this.explicitManifest != null) return this.explicitManifest
139
+
140
+ // Try to fetch _manifest.json from static assets
141
+ try {
142
+ const response = await this.assets.fetch(new Request('http://assets/_manifest.json'))
143
+ if (response.ok) {
144
+ const manifest = await response.json() as string[]
145
+ if (Array.isArray(manifest)) return manifest
146
+ }
147
+ } catch {
148
+ // manifest not available
149
+ }
150
+
151
+ throw new Error(
152
+ 'AssetsSource: No manifest found. Create a _manifest.json file listing your .md paths, ' +
153
+ 'or pass a manifest array to AssetsSourceConfig.'
154
+ )
155
+ }
156
+
157
+ private async prefetchAll (): Promise<Map<string, ParsedFile>> {
158
+ const manifest = await this.getManifest()
159
+ const mdPaths = manifest.filter(p => p.endsWith('.md'))
160
+
161
+ const fetched = await Promise.all(
162
+ mdPaths.map(async (filePath) => {
163
+ try {
164
+ const response = await this.assets.fetch(new Request(`http://assets/${filePath}`))
165
+ if (!response.ok) return null
166
+ const raw = await response.text()
167
+ const { meta, body } = parseFrontmatter(raw)
168
+ return { path: filePath, meta: meta as Record<string, unknown>, body, raw } satisfies ParsedFile
169
+ } catch {
170
+ return null
171
+ }
172
+ })
173
+ )
174
+
175
+ const pages = new Map<string, ParsedFile>()
176
+ for (const file of fetched) {
177
+ if (file == null) continue
178
+ const key = file.path.replace(/\.md$/, '')
179
+ pages.set(key, file)
180
+ }
181
+
182
+ this.rawCache = pages
183
+ this.rawCacheExpiresAt = Date.now() + 5 * 60 * 1000
184
+ return pages
185
+ }
186
+
187
+ private toContentPage (file: ParsedFile): ContentPage {
188
+ const key = file.path.replace(/\.md$/, '')
189
+ const isIndex = /(?:^|\/)(?:index|README|readme)$/.test(key)
190
+ const cleanKey = isIndex
191
+ ? key.replace(/\/(?:index|README|readme)$/, '')
192
+ : key
193
+ const slug = cleanKey === '' || cleanKey === 'index' ? '/' : '/' + cleanKey
194
+ return {
195
+ slug,
196
+ sourcePath: file.path,
197
+ meta: file.meta,
198
+ body: file.body,
199
+ raw: file.raw
200
+ }
201
+ }
202
+ }
@@ -0,0 +1,232 @@
1
+ import type { ContentPage, NavNode } from './types.ts'
2
+
3
+ /**
4
+ * Content cache interface.
5
+ *
6
+ * Implementations:
7
+ * - MemoryContentCache: in-memory Map with TTL (default)
8
+ * - KVContentCache: Cloudflare KV-backed with TTL
9
+ */
10
+ export interface ContentCache {
11
+ getPage: (key: string) => Promise<ContentPage | null>
12
+ setPage: (key: string, page: ContentPage) => Promise<void>
13
+
14
+ getNav: () => Promise<NavNode | null>
15
+ setNav: (nav: NavNode) => Promise<void>
16
+
17
+ getPageList: () => Promise<ContentPage[] | null>
18
+ setPageList: (pages: ContentPage[]) => Promise<void>
19
+
20
+ /** Get serialized search index JSON (null if not cached) */
21
+ getSearchIndex: () => Promise<string | null>
22
+ /** Store serialized search index JSON */
23
+ setSearchIndex: (data: string) => Promise<void>
24
+
25
+ clear: () => Promise<void>
26
+ }
27
+
28
+ // ─── In-Memory Implementation ────────────────────────────────────────────────
29
+
30
+ interface CacheEntry<T> {
31
+ value: T
32
+ expiresAt: number
33
+ }
34
+
35
+ const DEFAULT_TTL_MS = 5 * 60 * 1000 // 5 minutes
36
+
37
+ export class MemoryContentCache implements ContentCache {
38
+ private readonly pages = new Map<string, CacheEntry<ContentPage>>()
39
+ private nav: CacheEntry<NavNode> | null = null
40
+ private pageList: CacheEntry<ContentPage[]> | null = null
41
+ private searchIndex: CacheEntry<string> | null = null
42
+ private readonly ttlMs: number
43
+
44
+ constructor (ttlMs?: number) {
45
+ this.ttlMs = ttlMs ?? DEFAULT_TTL_MS
46
+ }
47
+
48
+ async getPage (key: string): Promise<ContentPage | null> {
49
+ const entry = this.pages.get(key)
50
+ if (entry != null && Date.now() < entry.expiresAt) return entry.value
51
+ return null
52
+ }
53
+
54
+ async setPage (key: string, page: ContentPage): Promise<void> {
55
+ this.pages.set(key, { value: page, expiresAt: Date.now() + this.ttlMs })
56
+ }
57
+
58
+ async getNav (): Promise<NavNode | null> {
59
+ if (this.nav != null && Date.now() < this.nav.expiresAt) return this.nav.value
60
+ return null
61
+ }
62
+
63
+ async setNav (nav: NavNode): Promise<void> {
64
+ this.nav = { value: nav, expiresAt: Date.now() + this.ttlMs }
65
+ }
66
+
67
+ async getPageList (): Promise<ContentPage[] | null> {
68
+ if (this.pageList != null && Date.now() < this.pageList.expiresAt) return this.pageList.value
69
+ return null
70
+ }
71
+
72
+ async setPageList (pages: ContentPage[]): Promise<void> {
73
+ this.pageList = { value: pages, expiresAt: Date.now() + this.ttlMs }
74
+ }
75
+
76
+ async getSearchIndex (): Promise<string | null> {
77
+ if (this.searchIndex != null && Date.now() < this.searchIndex.expiresAt) {
78
+ return this.searchIndex.value
79
+ }
80
+ return null
81
+ }
82
+
83
+ async setSearchIndex (data: string): Promise<void> {
84
+ this.searchIndex = { value: data, expiresAt: Date.now() + this.ttlMs }
85
+ }
86
+
87
+ async clear (): Promise<void> {
88
+ this.pages.clear()
89
+ this.nav = null
90
+ this.pageList = null
91
+ this.searchIndex = null
92
+ }
93
+ }
94
+
95
+ // ─── Cloudflare KV Implementation ────────────────────────────────────────────
96
+
97
+ interface KVNamespace {
98
+ get: (key: string) => Promise<string | null>
99
+ put: (key: string, value: string, options?: { expirationTtl?: number }) => Promise<void>
100
+ delete: (key: string) => Promise<void>
101
+ list: (options?: { prefix?: string }) => Promise<{ keys: Array<{ name: string }> }>
102
+ }
103
+
104
+ const DEFAULT_KV_TTL_SECONDS = 300 // 5 minutes
105
+
106
+ /**
107
+ * Cloudflare KV-backed content cache.
108
+ *
109
+ * Serializes ContentPage and NavNode to JSON and stores in KV with TTL.
110
+ * Falls through to in-memory cache for hot-path performance within
111
+ * a single Worker isolate, with KV as the shared durable layer.
112
+ */
113
+ export class KVContentCache implements ContentCache {
114
+ private readonly kv: KVNamespace
115
+ private readonly prefix: string
116
+ private readonly ttlSeconds: number
117
+ private readonly memory: MemoryContentCache
118
+
119
+ constructor (kv: KVNamespace, options?: { prefix?: string, ttlSeconds?: number }) {
120
+ this.kv = kv
121
+ this.prefix = options?.prefix ?? 'content:'
122
+ this.ttlSeconds = options?.ttlSeconds ?? DEFAULT_KV_TTL_SECONDS
123
+ // In-memory layer for hot-path within the same isolate
124
+ this.memory = new MemoryContentCache(this.ttlSeconds * 1000)
125
+ }
126
+
127
+ async getPage (key: string): Promise<ContentPage | null> {
128
+ // L1: in-memory
129
+ const memResult = await this.memory.getPage(key)
130
+ if (memResult != null) return memResult
131
+
132
+ // L2: KV
133
+ const raw = await this.kv.get(this.prefix + 'page:' + key)
134
+ if (raw != null) {
135
+ try {
136
+ const page = JSON.parse(raw) as ContentPage
137
+ await this.memory.setPage(key, page)
138
+ return page
139
+ } catch {
140
+ return null
141
+ }
142
+ }
143
+ return null
144
+ }
145
+
146
+ async setPage (key: string, page: ContentPage): Promise<void> {
147
+ await this.memory.setPage(key, page)
148
+ await this.kv.put(this.prefix + 'page:' + key, JSON.stringify(page), {
149
+ expirationTtl: this.ttlSeconds
150
+ })
151
+ }
152
+
153
+ async getNav (): Promise<NavNode | null> {
154
+ const memResult = await this.memory.getNav()
155
+ if (memResult != null) return memResult
156
+
157
+ const raw = await this.kv.get(this.prefix + 'nav')
158
+ if (raw != null) {
159
+ try {
160
+ const nav = JSON.parse(raw) as NavNode
161
+ await this.memory.setNav(nav)
162
+ return nav
163
+ } catch {
164
+ return null
165
+ }
166
+ }
167
+ return null
168
+ }
169
+
170
+ async setNav (nav: NavNode): Promise<void> {
171
+ await this.memory.setNav(nav)
172
+ await this.kv.put(this.prefix + 'nav', JSON.stringify(nav), {
173
+ expirationTtl: this.ttlSeconds
174
+ })
175
+ }
176
+
177
+ async getPageList (): Promise<ContentPage[] | null> {
178
+ const memResult = await this.memory.getPageList()
179
+ if (memResult != null) return memResult
180
+
181
+ const raw = await this.kv.get(this.prefix + 'pages')
182
+ if (raw != null) {
183
+ try {
184
+ const pages = JSON.parse(raw) as ContentPage[]
185
+ await this.memory.setPageList(pages)
186
+ return pages
187
+ } catch {
188
+ return null
189
+ }
190
+ }
191
+ return null
192
+ }
193
+
194
+ async setPageList (pages: ContentPage[]): Promise<void> {
195
+ await this.memory.setPageList(pages)
196
+ await this.kv.put(this.prefix + 'pages', JSON.stringify(pages), {
197
+ expirationTtl: this.ttlSeconds
198
+ })
199
+ }
200
+
201
+ async getSearchIndex (): Promise<string | null> {
202
+ // L1: in-memory
203
+ const memResult = await this.memory.getSearchIndex()
204
+ if (memResult != null) return memResult
205
+
206
+ // L2: KV (search index stored as raw string, already JSON)
207
+ const raw = await this.kv.get(this.prefix + 'search-index')
208
+ if (raw != null) {
209
+ await this.memory.setSearchIndex(raw)
210
+ return raw
211
+ }
212
+ return null
213
+ }
214
+
215
+ async setSearchIndex (data: string): Promise<void> {
216
+ await this.memory.setSearchIndex(data)
217
+ await this.kv.put(this.prefix + 'search-index', data, {
218
+ expirationTtl: this.ttlSeconds
219
+ })
220
+ }
221
+
222
+ async clear (): Promise<void> {
223
+ await this.memory.clear()
224
+ // List and delete all keys with our prefix
225
+ try {
226
+ const result = await this.kv.list({ prefix: this.prefix })
227
+ await Promise.all(result.keys.map(async k => await this.kv.delete(k.name)))
228
+ } catch {
229
+ // Best-effort cleanup — KV TTL will handle expiry
230
+ }
231
+ }
232
+ }