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.
@@ -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 new Response(llmsTxtCache, {
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
- return new Response('cache cleared', { status: 200 })
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 new Response(
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 new Response(render404(config), {
304
+ return ok(textResponse(render404(config), {
95
305
  status: 404,
96
- headers: htmlHeaders()
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
- return new Response(page.body, {
110
- status: 200,
111
- headers: markdownHeaders(tokens, config.negotiation.contentSignals)
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
- return new Response(fullPage, {
130
- status: 200,
131
- headers: htmlHeaders()
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}`