mkdnsite 0.0.1 → 1.0.1
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 +8 -3
- package/src/adapters/cloudflare.ts +202 -15
- package/src/adapters/local.ts +38 -17
- 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 +301 -51
- package/src/client/scripts.ts +379 -3
- package/src/config/defaults.ts +66 -5
- package/src/config/schema.ts +200 -2
- package/src/content/assets.ts +202 -0
- package/src/content/cache.ts +232 -0
- package/src/content/filesystem.ts +17 -1
- package/src/content/github.ts +169 -102
- package/src/content/nav-builder.ts +120 -0
- package/src/content/r2.ts +214 -0
- package/src/handler.ts +341 -21
- 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 +249 -8
- package/src/search/index.ts +342 -0
- package/src/security/csp.ts +92 -0
- package/src/theme/{prose-css.ts → base-css.ts} +251 -11
- package/src/theme/build-css.ts +74 -0
|
@@ -0,0 +1,214 @@
|
|
|
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
|
+
/** Parsed R2 file ready for use */
|
|
8
|
+
interface ParsedFile {
|
|
9
|
+
/** Relative path from content root, e.g. 'docs/getting-started.md' */
|
|
10
|
+
path: string
|
|
11
|
+
meta: Record<string, unknown>
|
|
12
|
+
body: string
|
|
13
|
+
raw: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface R2ContentSourceConfig {
|
|
17
|
+
/** R2 bucket binding */
|
|
18
|
+
bucket: R2Bucket
|
|
19
|
+
/** Key prefix for content (e.g. 'sites/abc123/') */
|
|
20
|
+
basePath?: string
|
|
21
|
+
/** Optional cache layer (defaults to in-memory). Pass KVContentCache for durable caching. */
|
|
22
|
+
cache?: ContentCache
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── R2 type stubs (not available outside Cloudflare Workers) ────────────────
|
|
26
|
+
|
|
27
|
+
interface R2Bucket {
|
|
28
|
+
get: (key: string) => Promise<R2Object | null>
|
|
29
|
+
list: (options?: R2ListOptions) => Promise<R2ObjectList>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface R2Object {
|
|
33
|
+
key: string
|
|
34
|
+
uploaded: Date
|
|
35
|
+
size: number
|
|
36
|
+
text: () => Promise<string>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface R2ObjectList {
|
|
40
|
+
objects: R2Object[]
|
|
41
|
+
truncated: boolean
|
|
42
|
+
cursor?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface R2ListOptions {
|
|
46
|
+
prefix?: string
|
|
47
|
+
cursor?: string
|
|
48
|
+
limit?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
async function listAllMdFiles (bucket: R2Bucket, prefix: string): Promise<R2Object[]> {
|
|
54
|
+
const objects: R2Object[] = []
|
|
55
|
+
let cursor: string | undefined
|
|
56
|
+
do {
|
|
57
|
+
const result = await bucket.list({ prefix, cursor })
|
|
58
|
+
objects.push(...result.objects.filter(o => o.key.endsWith('.md')))
|
|
59
|
+
cursor = result.truncated ? result.cursor : undefined
|
|
60
|
+
} while (cursor != null)
|
|
61
|
+
return objects
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Convert a URL slug to a relative content key (no basePath prefix) */
|
|
65
|
+
function slugToRelKey (slug: string): string {
|
|
66
|
+
const stripped = slug.replace(/^\/+|\.md$/g, '').replace(/\/+$/, '')
|
|
67
|
+
return stripped === '' ? 'index' : stripped
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function keyToRelPath (key: string, basePath: string): string {
|
|
71
|
+
return basePath !== '' && key.startsWith(basePath)
|
|
72
|
+
? key.slice(basePath.length)
|
|
73
|
+
: key
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── R2ContentSource ─────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Content source that reads .md files from a Cloudflare R2 bucket.
|
|
80
|
+
*
|
|
81
|
+
* Lazy prefetch: on first access, lists all .md keys, fetches them in parallel,
|
|
82
|
+
* parses frontmatter, and caches for TTL_MS (5 minutes).
|
|
83
|
+
*/
|
|
84
|
+
export class R2ContentSource implements ContentSource {
|
|
85
|
+
private readonly bucket: R2Bucket
|
|
86
|
+
private readonly basePath: string
|
|
87
|
+
private readonly cache: ContentCache
|
|
88
|
+
|
|
89
|
+
private rawCache: Map<string, ParsedFile> | null = null
|
|
90
|
+
private rawCacheExpiresAt: number = 0
|
|
91
|
+
private initPromise: Promise<Map<string, ParsedFile>> | null = null
|
|
92
|
+
|
|
93
|
+
constructor (config: R2ContentSourceConfig) {
|
|
94
|
+
this.bucket = config.bucket
|
|
95
|
+
this.cache = config.cache ?? new MemoryContentCache()
|
|
96
|
+
// Normalise basePath to end with '/' or be empty
|
|
97
|
+
const raw = config.basePath ?? ''
|
|
98
|
+
this.basePath = raw !== '' && !raw.endsWith('/') ? raw + '/' : raw
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Public API ────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
async getPage (slug: string): Promise<ContentPage | null> {
|
|
104
|
+
const key = slugToRelKey(slug)
|
|
105
|
+
|
|
106
|
+
// Try cache first for each candidate
|
|
107
|
+
for (const candidate of [key, key + '/index', key + '/README', key + '/readme']) {
|
|
108
|
+
const cached = await this.cache.getPage(candidate)
|
|
109
|
+
if (cached != null) return cached
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Fall through to prefetch
|
|
113
|
+
const pages = await this.ensurePrefetched()
|
|
114
|
+
|
|
115
|
+
for (const candidate of [key, key + '/index', key + '/README', key + '/readme']) {
|
|
116
|
+
const file = pages.get(candidate)
|
|
117
|
+
if (file != null && file.meta.draft !== true) {
|
|
118
|
+
const page = this.toContentPage(file)
|
|
119
|
+
await this.cache.setPage(candidate, page)
|
|
120
|
+
return page
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async getNavTree (): Promise<NavNode> {
|
|
127
|
+
const cached = await this.cache.getNav()
|
|
128
|
+
if (cached != null) return cached
|
|
129
|
+
|
|
130
|
+
const pages = await this.ensurePrefetched()
|
|
131
|
+
const files = Array.from(pages.values())
|
|
132
|
+
const nav = buildNavTree(files)
|
|
133
|
+
await this.cache.setNav(nav)
|
|
134
|
+
return nav
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async listPages (): Promise<ContentPage[]> {
|
|
138
|
+
const cached = await this.cache.getPageList()
|
|
139
|
+
if (cached != null) return cached
|
|
140
|
+
|
|
141
|
+
const pages = await this.ensurePrefetched()
|
|
142
|
+
const result = Array.from(pages.values())
|
|
143
|
+
.filter(f => f.meta.draft !== true)
|
|
144
|
+
.map(f => this.toContentPage(f))
|
|
145
|
+
await this.cache.setPageList(result)
|
|
146
|
+
return result
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async refresh (): Promise<void> {
|
|
150
|
+
this.rawCache = null
|
|
151
|
+
this.rawCacheExpiresAt = 0
|
|
152
|
+
this.initPromise = null
|
|
153
|
+
await this.cache.clear()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── Internal ──────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
private async ensurePrefetched (): Promise<Map<string, ParsedFile>> {
|
|
159
|
+
if (this.rawCache != null && Date.now() < this.rawCacheExpiresAt) {
|
|
160
|
+
return this.rawCache
|
|
161
|
+
}
|
|
162
|
+
if (this.initPromise != null) return await this.initPromise
|
|
163
|
+
this.initPromise = this.prefetchAll()
|
|
164
|
+
const result = await this.initPromise
|
|
165
|
+
this.initPromise = null
|
|
166
|
+
return result
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async prefetchAll (): Promise<Map<string, ParsedFile>> {
|
|
170
|
+
const objects = await listAllMdFiles(this.bucket, this.basePath)
|
|
171
|
+
|
|
172
|
+
const fetched = await Promise.all(
|
|
173
|
+
objects.map(async (obj) => {
|
|
174
|
+
try {
|
|
175
|
+
const r2obj = await this.bucket.get(obj.key)
|
|
176
|
+
if (r2obj == null) return null
|
|
177
|
+
const raw = await r2obj.text()
|
|
178
|
+
const { meta, body } = parseFrontmatter(raw)
|
|
179
|
+
const relPath = keyToRelPath(obj.key, this.basePath)
|
|
180
|
+
return { path: relPath, meta: meta as Record<string, unknown>, body, raw } satisfies ParsedFile
|
|
181
|
+
} catch {
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
const pages = new Map<string, ParsedFile>()
|
|
188
|
+
for (const file of fetched) {
|
|
189
|
+
if (file == null) continue
|
|
190
|
+
const key = file.path.replace(/\.md$/, '')
|
|
191
|
+
pages.set(key, file)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
this.rawCache = pages
|
|
195
|
+
this.rawCacheExpiresAt = Date.now() + 5 * 60 * 1000
|
|
196
|
+
return pages
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private toContentPage (file: ParsedFile): ContentPage {
|
|
200
|
+
const key = file.path.replace(/\.md$/, '')
|
|
201
|
+
const isIndex = /(?:^|\/)(?:index|README|readme)$/.test(key)
|
|
202
|
+
const cleanKey = isIndex
|
|
203
|
+
? key.replace(/\/(?:index|README|readme)$/, '')
|
|
204
|
+
: key
|
|
205
|
+
const slug = cleanKey === '' || cleanKey === 'index' ? '/' : '/' + cleanKey
|
|
206
|
+
return {
|
|
207
|
+
slug,
|
|
208
|
+
sourcePath: file.path,
|
|
209
|
+
meta: file.meta,
|
|
210
|
+
body: file.body,
|
|
211
|
+
raw: file.raw
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
package/src/handler.ts
CHANGED
|
@@ -7,11 +7,42 @@ import { negotiateFormat } from './negotiate/accept.ts'
|
|
|
7
7
|
import { markdownHeaders, htmlHeaders, estimateTokens } from './negotiate/headers.ts'
|
|
8
8
|
import { renderPage, render404 } from './render/page-shell.ts'
|
|
9
9
|
import { generateLlmsTxt } from './discovery/llmstxt.ts'
|
|
10
|
+
import { createSearchIndex } from './search/index.ts'
|
|
11
|
+
import type { SearchIndex } from './search/index.ts'
|
|
12
|
+
import type { ContentCache } from './content/cache.ts'
|
|
13
|
+
import type { ResponseCache } from './cache/response.ts'
|
|
14
|
+
import { createMcpServer } from './mcp/server.ts'
|
|
15
|
+
import { buildCsp } from './security/csp.ts'
|
|
16
|
+
import { createMcpHandler } from './mcp/transport.ts'
|
|
17
|
+
import type { TrafficAnalytics, AnalyticsResponseFormat } from './analytics/types.ts'
|
|
18
|
+
import { classifyTraffic } from './analytics/classify.ts'
|
|
10
19
|
|
|
11
20
|
export interface HandlerOptions {
|
|
12
21
|
source: ContentSource
|
|
13
22
|
renderer: MarkdownRenderer
|
|
14
23
|
config: MkdnSiteConfig
|
|
24
|
+
/** Optional traffic analytics backend. When provided, every request is logged. */
|
|
25
|
+
analytics?: TrafficAnalytics
|
|
26
|
+
/** Site identifier for multi-tenant analytics isolation (e.g. mkdn.io). */
|
|
27
|
+
siteId?: string
|
|
28
|
+
/**
|
|
29
|
+
* Optional content cache.
|
|
30
|
+
* When provided, the search index is loaded from / stored in the cache
|
|
31
|
+
* so cold-start rebuilds are avoided across isolates (e.g. Cloudflare Workers + KV).
|
|
32
|
+
*/
|
|
33
|
+
contentCache?: ContentCache
|
|
34
|
+
/**
|
|
35
|
+
* Optional response cache.
|
|
36
|
+
* When provided, rendered HTML and markdown responses are cached to skip SSR
|
|
37
|
+
* on repeat requests. Works with MemoryResponseCache (single-process) or
|
|
38
|
+
* KVResponseCache (Cloudflare Workers with KV — cross-isolate sharing).
|
|
39
|
+
*/
|
|
40
|
+
responseCache?: ResponseCache
|
|
41
|
+
/**
|
|
42
|
+
* Optional secret token for authenticating POST /_refresh requests.
|
|
43
|
+
* When set, requests without `Authorization: Bearer <refreshToken>` are rejected with 401.
|
|
44
|
+
*/
|
|
45
|
+
refreshToken?: string
|
|
15
46
|
}
|
|
16
47
|
|
|
17
48
|
/**
|
|
@@ -26,42 +57,221 @@ export interface HandlerOptions {
|
|
|
26
57
|
* - Deno.serve()
|
|
27
58
|
*/
|
|
28
59
|
export function createHandler (opts: HandlerOptions): (request: Request) => Promise<Response> {
|
|
29
|
-
const { source, renderer, config } = opts
|
|
60
|
+
const { source, renderer, config, analytics, siteId, contentCache, responseCache, refreshToken } = opts
|
|
30
61
|
|
|
31
62
|
let llmsTxtCache: string | null = null
|
|
63
|
+
let mcpHandlerFn: ((req: Request) => Promise<Response>) | null = null
|
|
64
|
+
let mcpInitPromise: Promise<(req: Request) => Promise<Response>> | null = null
|
|
65
|
+
let searchIndexPromise: Promise<SearchIndex> | null = null
|
|
66
|
+
|
|
67
|
+
// Shared search index used by both /api/search and MCP
|
|
68
|
+
async function ensureSearchIndex (): Promise<SearchIndex> {
|
|
69
|
+
if (searchIndexPromise != null) return await searchIndexPromise
|
|
70
|
+
searchIndexPromise = (async () => {
|
|
71
|
+
const si = createSearchIndex()
|
|
72
|
+
// Try to restore from cache (avoids full rebuild on cold starts)
|
|
73
|
+
if (contentCache != null) {
|
|
74
|
+
const cached = await contentCache.getSearchIndex()
|
|
75
|
+
if (cached != null && cached !== '') {
|
|
76
|
+
try {
|
|
77
|
+
si.deserialize(cached)
|
|
78
|
+
return si
|
|
79
|
+
} catch {
|
|
80
|
+
// Corrupt / incompatible — fall through to rebuild
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
await si.rebuild(source)
|
|
85
|
+
// Persist to cache for next cold start
|
|
86
|
+
if (contentCache != null) {
|
|
87
|
+
try {
|
|
88
|
+
await contentCache.setSearchIndex(si.serialize())
|
|
89
|
+
} catch {
|
|
90
|
+
// Non-fatal — cache write failure shouldn't break search
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return si
|
|
94
|
+
})()
|
|
95
|
+
return await searchIndexPromise
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function ensureMcpHandler (): Promise<(req: Request) => Promise<Response>> {
|
|
99
|
+
if (mcpInitPromise != null) return await mcpInitPromise
|
|
100
|
+
mcpInitPromise = (async () => {
|
|
101
|
+
const si = await ensureSearchIndex()
|
|
102
|
+
const mcpServer = createMcpServer({ source, searchIndex: si })
|
|
103
|
+
return createMcpHandler(mcpServer)
|
|
104
|
+
})()
|
|
105
|
+
mcpHandlerFn = await mcpInitPromise
|
|
106
|
+
return mcpHandlerFn
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Eagerly init search index when client search is enabled
|
|
110
|
+
if (config.client.search) {
|
|
111
|
+
void ensureSearchIndex()
|
|
112
|
+
}
|
|
32
113
|
|
|
33
114
|
return async function handler (request: Request): Promise<Response> {
|
|
115
|
+
const start = Date.now()
|
|
34
116
|
const url = new URL(request.url)
|
|
35
117
|
const pathname = decodeURIComponent(url.pathname)
|
|
36
118
|
|
|
119
|
+
const { response, cacheHit } = await handleRequest(request, url, pathname)
|
|
120
|
+
|
|
121
|
+
if (analytics != null) {
|
|
122
|
+
const format = resolveAnalyticsFormat(request, pathname, response)
|
|
123
|
+
try {
|
|
124
|
+
analytics.logRequest({
|
|
125
|
+
timestamp: start,
|
|
126
|
+
path: pathname,
|
|
127
|
+
method: request.method,
|
|
128
|
+
format,
|
|
129
|
+
trafficType: classifyTraffic(request, format),
|
|
130
|
+
statusCode: response.status,
|
|
131
|
+
latencyMs: Date.now() - start,
|
|
132
|
+
userAgent: request.headers.get('User-Agent') ?? '',
|
|
133
|
+
contentLength: readContentLength(response),
|
|
134
|
+
cacheHit,
|
|
135
|
+
siteId
|
|
136
|
+
})
|
|
137
|
+
} catch {
|
|
138
|
+
// analytics must never break the response path
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return response
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---- Analytics format resolution ----
|
|
146
|
+
|
|
147
|
+
function resolveAnalyticsFormat (
|
|
148
|
+
request: Request,
|
|
149
|
+
pathname: string,
|
|
150
|
+
response: Response
|
|
151
|
+
): AnalyticsResponseFormat {
|
|
152
|
+
// MCP: check endpoint first (MCP responses may have various Content-Types)
|
|
153
|
+
const mcpEndpoint = config.mcp.endpoint ?? '/mcp'
|
|
154
|
+
if (
|
|
155
|
+
config.mcp.enabled &&
|
|
156
|
+
(pathname === mcpEndpoint || pathname.startsWith(mcpEndpoint + '/'))
|
|
157
|
+
) {
|
|
158
|
+
return 'mcp'
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Prefer response Content-Type when available
|
|
162
|
+
const contentType = response.headers.get('Content-Type') ?? ''
|
|
163
|
+
if (contentType.includes('text/markdown')) return 'markdown'
|
|
164
|
+
if (contentType.includes('text/html')) return 'html'
|
|
165
|
+
if (contentType.includes('application/json')) return 'api'
|
|
166
|
+
|
|
167
|
+
// Fallback: infer from request when Content-Type is missing (e.g. static files)
|
|
168
|
+
const accept = request.headers.get('Accept') ?? ''
|
|
169
|
+
if (
|
|
170
|
+
accept.includes('text/markdown') ||
|
|
171
|
+
accept.includes('text/x-markdown') ||
|
|
172
|
+
accept.includes('application/markdown') ||
|
|
173
|
+
pathname.endsWith('.md')
|
|
174
|
+
) {
|
|
175
|
+
return 'markdown'
|
|
176
|
+
}
|
|
177
|
+
return 'other'
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---- Inner request handler (no analytics instrumentation) ----
|
|
181
|
+
|
|
182
|
+
async function handleRequest (
|
|
183
|
+
request: Request,
|
|
184
|
+
url: URL,
|
|
185
|
+
pathname: string
|
|
186
|
+
): Promise<{ response: Response, cacheHit: boolean }> {
|
|
187
|
+
function ok (response: Response): { response: Response, cacheHit: boolean } {
|
|
188
|
+
return { response, cacheHit: false }
|
|
189
|
+
}
|
|
190
|
+
|
|
37
191
|
// ---- Special routes ----
|
|
38
192
|
|
|
39
193
|
if (pathname === '/_health') {
|
|
40
|
-
return new Response('ok', { status: 200 })
|
|
194
|
+
return ok(new Response('ok', { status: 200 }))
|
|
41
195
|
}
|
|
42
196
|
|
|
43
197
|
if (pathname === '/llms.txt' && config.llmsTxt.enabled) {
|
|
44
198
|
if (llmsTxtCache == null) {
|
|
45
199
|
llmsTxtCache = await generateLlmsTxt(source, config)
|
|
46
200
|
}
|
|
47
|
-
return
|
|
201
|
+
return ok(textResponse(llmsTxtCache, {
|
|
48
202
|
status: 200,
|
|
49
203
|
headers: {
|
|
50
204
|
'Content-Type': 'text/markdown; charset=utf-8',
|
|
51
205
|
'Cache-Control': 'public, max-age=3600'
|
|
52
206
|
}
|
|
53
|
-
})
|
|
207
|
+
}))
|
|
54
208
|
}
|
|
55
209
|
|
|
56
210
|
if (pathname === '/_refresh' && request.method === 'POST') {
|
|
211
|
+
// Optional Bearer token auth
|
|
212
|
+
if (refreshToken != null && refreshToken !== '') {
|
|
213
|
+
const authHeader = request.headers.get('Authorization') ?? ''
|
|
214
|
+
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''
|
|
215
|
+
if (!timingSafeEqual(token, refreshToken)) {
|
|
216
|
+
return ok(new Response('Unauthorized', { status: 401 }))
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
57
220
|
await source.refresh()
|
|
58
221
|
llmsTxtCache = null
|
|
59
|
-
|
|
222
|
+
|
|
223
|
+
// Clear cached search index
|
|
224
|
+
if (contentCache != null) {
|
|
225
|
+
try { await contentCache.setSearchIndex('') } catch { /* non-fatal */ }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Clear response cache (supports ?path= for single-entry invalidation)
|
|
229
|
+
if (responseCache != null) {
|
|
230
|
+
const pathParam = url.searchParams.get('path')
|
|
231
|
+
if (pathParam != null) {
|
|
232
|
+
// Invalidate both html and markdown variants
|
|
233
|
+
await responseCache.delete(pathParam + ':html')
|
|
234
|
+
await responseCache.delete(pathParam + ':markdown')
|
|
235
|
+
} else {
|
|
236
|
+
await responseCache.clear()
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Reset search index
|
|
241
|
+
if (searchIndexPromise != null) {
|
|
242
|
+
searchIndexPromise = null
|
|
243
|
+
void ensureSearchIndex()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return ok(new Response('cache cleared', { status: 200 }))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---- Search API ----
|
|
250
|
+
if (pathname === '/api/search' && config.client.search) {
|
|
251
|
+
const query = (url.searchParams.get('q') ?? '').slice(0, 200)
|
|
252
|
+
const rawLimit = parseInt(url.searchParams.get('limit') ?? '10', 10)
|
|
253
|
+
const limit = Math.min(isNaN(rawLimit) ? 10 : rawLimit, 50)
|
|
254
|
+
const si = await ensureSearchIndex()
|
|
255
|
+
const results = si.search(query, limit)
|
|
256
|
+
return ok(textResponse(JSON.stringify(results), {
|
|
257
|
+
status: 200,
|
|
258
|
+
headers: { 'Content-Type': 'application/json' }
|
|
259
|
+
}))
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---- MCP endpoint ----
|
|
263
|
+
if (
|
|
264
|
+
config.mcp.enabled &&
|
|
265
|
+
(pathname === (config.mcp.endpoint ?? '/mcp') ||
|
|
266
|
+
pathname.startsWith((config.mcp.endpoint ?? '/mcp') + '/'))
|
|
267
|
+
) {
|
|
268
|
+
const mcp = await ensureMcpHandler()
|
|
269
|
+
return ok(await mcp(request))
|
|
60
270
|
}
|
|
61
271
|
|
|
62
272
|
// ---- Static files passthrough ----
|
|
63
273
|
if (config.staticDir != null && hasStaticExtension(pathname)) {
|
|
64
|
-
return await serveStatic(pathname, config.staticDir)
|
|
274
|
+
return ok(await serveStatic(pathname, config.staticDir))
|
|
65
275
|
}
|
|
66
276
|
|
|
67
277
|
// ---- Content negotiation + page serving ----
|
|
@@ -83,38 +293,69 @@ export function createHandler (opts: HandlerOptions): (request: Request) => Prom
|
|
|
83
293
|
if (page == null) {
|
|
84
294
|
const format = negotiateFormat(request.headers.get('Accept'))
|
|
85
295
|
if (format === 'markdown') {
|
|
86
|
-
return
|
|
296
|
+
return ok(textResponse(
|
|
87
297
|
'# 404 — Page Not Found\n\nThe requested page does not exist.\n',
|
|
88
298
|
{
|
|
89
299
|
status: 404,
|
|
90
300
|
headers: { 'Content-Type': 'text/markdown; charset=utf-8' }
|
|
91
301
|
}
|
|
92
|
-
)
|
|
302
|
+
))
|
|
93
303
|
}
|
|
94
|
-
return
|
|
304
|
+
return ok(textResponse(render404(config), {
|
|
95
305
|
status: 404,
|
|
96
|
-
headers:
|
|
97
|
-
})
|
|
306
|
+
headers: htmlHeadersWithCsp(config)
|
|
307
|
+
}))
|
|
98
308
|
}
|
|
99
309
|
|
|
100
310
|
const format = forceMarkdown
|
|
101
311
|
? 'markdown'
|
|
102
312
|
: negotiateFormat(request.headers.get('Accept'))
|
|
103
313
|
|
|
314
|
+
// ---- Response cache check (content pages only) ----
|
|
315
|
+
const cacheEnabled = config.cache?.enabled === true && responseCache != null
|
|
316
|
+
const cacheKey = slug + ':' + format
|
|
317
|
+
|
|
318
|
+
if (cacheEnabled && responseCache != null) {
|
|
319
|
+
const cached = await responseCache.get(cacheKey)
|
|
320
|
+
if (cached != null) {
|
|
321
|
+
// 304 Not Modified support
|
|
322
|
+
const ifNoneMatch = request.headers.get('If-None-Match')
|
|
323
|
+
const etag = cached.headers.ETag
|
|
324
|
+
if (ifNoneMatch != null && etag != null && ifNoneMatch === etag) {
|
|
325
|
+
return { response: new Response(null, { status: 304, headers: notModifiedHeaders(cached.headers) }), cacheHit: true }
|
|
326
|
+
}
|
|
327
|
+
return { response: textResponse(cached.body, { status: cached.status, headers: cached.headers }), cacheHit: true }
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
104
331
|
if (format === 'markdown') {
|
|
105
332
|
const tokens = config.negotiation.includeTokenCount
|
|
106
333
|
? estimateTokens(page.body)
|
|
107
334
|
: null
|
|
108
335
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
336
|
+
const headers = markdownHeaders(tokens, config.negotiation.contentSignals, config.cache, slug)
|
|
337
|
+
|
|
338
|
+
// 304 Not Modified support for non-cached path
|
|
339
|
+
const ifNoneMatch = request.headers.get('If-None-Match')
|
|
340
|
+
if (ifNoneMatch != null && headers.ETag != null && ifNoneMatch === headers.ETag) {
|
|
341
|
+
return ok(new Response(null, { status: 304, headers: notModifiedHeaders(headers) }))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const response = textResponse(page.body, { status: 200, headers })
|
|
345
|
+
if (cacheEnabled && responseCache != null) {
|
|
346
|
+
await responseCache.set(cacheKey, {
|
|
347
|
+
body: page.body,
|
|
348
|
+
status: 200,
|
|
349
|
+
headers,
|
|
350
|
+
timestamp: Date.now()
|
|
351
|
+
}, config.cache?.maxAgeMarkdown)
|
|
352
|
+
}
|
|
353
|
+
return ok(response)
|
|
113
354
|
}
|
|
114
355
|
|
|
115
356
|
// ---- Render HTML via React SSR ----
|
|
116
357
|
const renderedHtml = renderer.renderToHtml(page.body, config.theme.components)
|
|
117
|
-
const nav = config.theme.showNav
|
|
358
|
+
const nav = (config.theme.showNav || config.theme.prevNext === true)
|
|
118
359
|
? await source.getNavTree()
|
|
119
360
|
: undefined
|
|
120
361
|
|
|
@@ -123,16 +364,58 @@ export function createHandler (opts: HandlerOptions): (request: Request) => Prom
|
|
|
123
364
|
meta: page.meta,
|
|
124
365
|
config,
|
|
125
366
|
nav,
|
|
126
|
-
currentSlug: page.slug
|
|
367
|
+
currentSlug: page.slug,
|
|
368
|
+
body: page.body
|
|
127
369
|
})
|
|
128
370
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
371
|
+
const htmlHdrs = htmlHeadersWithCsp(config, slug)
|
|
372
|
+
|
|
373
|
+
// 304 Not Modified support for non-cached path
|
|
374
|
+
const ifNoneMatch = request.headers.get('If-None-Match')
|
|
375
|
+
if (ifNoneMatch != null && htmlHdrs.ETag != null && ifNoneMatch === htmlHdrs.ETag) {
|
|
376
|
+
return ok(new Response(null, { status: 304, headers: notModifiedHeaders(htmlHdrs) }))
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const htmlResponse = textResponse(fullPage, { status: 200, headers: htmlHdrs })
|
|
380
|
+
if (cacheEnabled && responseCache != null) {
|
|
381
|
+
await responseCache.set(cacheKey, {
|
|
382
|
+
body: fullPage,
|
|
383
|
+
status: 200,
|
|
384
|
+
headers: htmlHdrs,
|
|
385
|
+
timestamp: Date.now()
|
|
386
|
+
}, config.cache?.maxAge)
|
|
387
|
+
}
|
|
388
|
+
return ok(htmlResponse)
|
|
133
389
|
}
|
|
134
390
|
}
|
|
135
391
|
|
|
392
|
+
/**
|
|
393
|
+
* Read content length from the response Content-Length header.
|
|
394
|
+
* Returns 0 when the header is absent or unparseable.
|
|
395
|
+
*/
|
|
396
|
+
function readContentLength (response: Response): number {
|
|
397
|
+
const header = response.headers.get('Content-Length')
|
|
398
|
+
if (header == null) return 0
|
|
399
|
+
const parsed = parseInt(header, 10)
|
|
400
|
+
return isNaN(parsed) ? 0 : parsed
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Create a Response with Content-Length set from the body string. */
|
|
404
|
+
function textResponse (body: string, init: ResponseInit): Response {
|
|
405
|
+
const encoded = new TextEncoder().encode(body)
|
|
406
|
+
const headers = new Headers(init.headers)
|
|
407
|
+
headers.set('Content-Length', String(encoded.byteLength))
|
|
408
|
+
return new Response(encoded, { ...init, headers })
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function htmlHeadersWithCsp (config: MkdnSiteConfig, slug?: string): Record<string, string> {
|
|
412
|
+
const headers = htmlHeaders(config.cache, slug)
|
|
413
|
+
if (config.csp?.enabled !== false) {
|
|
414
|
+
headers['Content-Security-Policy'] = buildCsp(config)
|
|
415
|
+
}
|
|
416
|
+
return headers
|
|
417
|
+
}
|
|
418
|
+
|
|
136
419
|
const STATIC_EXTENSIONS = new Set([
|
|
137
420
|
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico',
|
|
138
421
|
'.mp4', '.webm', '.ogg', '.mp3', '.wav',
|
|
@@ -172,6 +455,43 @@ const MIME_TYPES: Record<string, string> = {
|
|
|
172
455
|
'.eot': 'application/vnd.ms-fontobject'
|
|
173
456
|
}
|
|
174
457
|
|
|
458
|
+
/**
|
|
459
|
+
* Extract headers required by RFC 7232 for 304 Not Modified responses.
|
|
460
|
+
* Must include ETag, Cache-Control, Vary (and Content-Type if set).
|
|
461
|
+
*/
|
|
462
|
+
function notModifiedHeaders (original: Record<string, string>): Record<string, string> {
|
|
463
|
+
const headers: Record<string, string> = {}
|
|
464
|
+
if (original.ETag != null) headers.ETag = original.ETag
|
|
465
|
+
if (original['Cache-Control'] != null) headers['Cache-Control'] = original['Cache-Control']
|
|
466
|
+
if (original.Vary != null) headers.Vary = original.Vary
|
|
467
|
+
return headers
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Constant-time string comparison to prevent timing side-channel attacks.
|
|
472
|
+
* Uses TextEncoder (Web API, available on all runtimes) instead of Buffer
|
|
473
|
+
* for Deno compatibility. Always compares full length regardless of mismatch.
|
|
474
|
+
*/
|
|
475
|
+
function timingSafeEqual (a: string, b: string): boolean {
|
|
476
|
+
const encoder = new TextEncoder()
|
|
477
|
+
const aBuf = encoder.encode(a)
|
|
478
|
+
const bBuf = encoder.encode(b)
|
|
479
|
+
if (aBuf.length !== bBuf.length) {
|
|
480
|
+
// Still do a full comparison to avoid leaking length info via timing
|
|
481
|
+
let mismatch = 1
|
|
482
|
+
for (let i = 0; i < bBuf.length; i++) {
|
|
483
|
+
mismatch |= bBuf[i] ^ bBuf[i]
|
|
484
|
+
}
|
|
485
|
+
void mismatch
|
|
486
|
+
return false
|
|
487
|
+
}
|
|
488
|
+
let mismatch = 0
|
|
489
|
+
for (let i = 0; i < aBuf.length; i++) {
|
|
490
|
+
mismatch |= aBuf[i] ^ bBuf[i]
|
|
491
|
+
}
|
|
492
|
+
return mismatch === 0
|
|
493
|
+
}
|
|
494
|
+
|
|
175
495
|
async function serveStatic (pathname: string, staticDir: string): Promise<Response> {
|
|
176
496
|
try {
|
|
177
497
|
const filePath = `${staticDir}${pathname}`
|