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.
- package/package.json +10 -3
- package/src/adapters/cloudflare.ts +276 -15
- package/src/adapters/local.ts +48 -18
- package/src/analytics/classify.ts +65 -0
- package/src/analytics/console.ts +39 -0
- package/src/analytics/noop.ts +15 -0
- package/src/analytics/types.ts +49 -0
- package/src/cache/kv.ts +81 -0
- package/src/cache/memory.ts +46 -0
- package/src/cache/response.ts +24 -0
- package/src/cli.ts +311 -51
- package/src/client/scripts.ts +405 -3
- package/src/config/defaults.ts +68 -5
- package/src/config/schema.ts +214 -2
- package/src/content/assets.ts +202 -0
- package/src/content/cache.ts +232 -0
- package/src/content/filesystem.ts +53 -2
- package/src/content/github.ts +194 -103
- package/src/content/nav-builder.ts +120 -0
- package/src/content/r2.ts +214 -0
- package/src/content/types.ts +10 -0
- package/src/handler.ts +357 -22
- package/src/index.ts +49 -1
- package/src/mcp/server.ts +164 -0
- package/src/mcp/stdio.ts +29 -0
- package/src/mcp/transport.ts +29 -0
- package/src/negotiate/headers.ts +37 -9
- package/src/render/page-shell.ts +250 -8
- package/src/search/index.ts +342 -0
- package/src/security/csp.ts +92 -0
- package/src/theme/{prose-css.ts → base-css.ts} +325 -15
- package/src/theme/build-css.ts +74 -0
package/src/config/schema.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
+
}
|