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/src/handler.ts CHANGED
@@ -7,11 +7,51 @@ 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
46
+ /**
47
+ * Optional custom static file handler.
48
+ * When provided, this function is called instead of the built-in filesystem-based
49
+ * serveStatic for requests matching static file extensions.
50
+ * Return a Response to serve it, or null to fall through to the built-in serveStatic
51
+ * (if config.staticDir is set) or ultimately a 404.
52
+ * Use this for non-filesystem deployments (R2, S3, KV, GitHub API, etc.).
53
+ */
54
+ staticHandler?: (pathname: string) => Promise<Response | null>
15
55
  }
16
56
 
17
57
  /**
@@ -26,42 +66,227 @@ export interface HandlerOptions {
26
66
  * - Deno.serve()
27
67
  */
28
68
  export function createHandler (opts: HandlerOptions): (request: Request) => Promise<Response> {
29
- const { source, renderer, config } = opts
69
+ const { source, renderer, config, analytics, siteId, contentCache, responseCache, refreshToken, staticHandler } = opts
30
70
 
31
71
  let llmsTxtCache: string | null = null
72
+ let mcpHandlerFn: ((req: Request) => Promise<Response>) | null = null
73
+ let mcpInitPromise: Promise<(req: Request) => Promise<Response>> | null = null
74
+ let searchIndexPromise: Promise<SearchIndex> | null = null
75
+
76
+ // Shared search index used by both /api/search and MCP
77
+ async function ensureSearchIndex (): Promise<SearchIndex> {
78
+ if (searchIndexPromise != null) return await searchIndexPromise
79
+ searchIndexPromise = (async () => {
80
+ const si = createSearchIndex()
81
+ // Try to restore from cache (avoids full rebuild on cold starts)
82
+ if (contentCache != null) {
83
+ const cached = await contentCache.getSearchIndex()
84
+ if (cached != null && cached !== '') {
85
+ try {
86
+ si.deserialize(cached)
87
+ return si
88
+ } catch {
89
+ // Corrupt / incompatible — fall through to rebuild
90
+ }
91
+ }
92
+ }
93
+ await si.rebuild(source)
94
+ // Persist to cache for next cold start
95
+ if (contentCache != null) {
96
+ try {
97
+ await contentCache.setSearchIndex(si.serialize())
98
+ } catch {
99
+ // Non-fatal — cache write failure shouldn't break search
100
+ }
101
+ }
102
+ return si
103
+ })()
104
+ return await searchIndexPromise
105
+ }
106
+
107
+ async function ensureMcpHandler (): Promise<(req: Request) => Promise<Response>> {
108
+ if (mcpInitPromise != null) return await mcpInitPromise
109
+ mcpInitPromise = (async () => {
110
+ const si = await ensureSearchIndex()
111
+ const mcpServer = createMcpServer({ source, searchIndex: si })
112
+ return createMcpHandler(mcpServer)
113
+ })()
114
+ mcpHandlerFn = await mcpInitPromise
115
+ return mcpHandlerFn
116
+ }
117
+
118
+ // Eagerly init search index when client search is enabled
119
+ if (config.client.search) {
120
+ void ensureSearchIndex()
121
+ }
32
122
 
33
123
  return async function handler (request: Request): Promise<Response> {
124
+ const start = Date.now()
34
125
  const url = new URL(request.url)
35
126
  const pathname = decodeURIComponent(url.pathname)
36
127
 
128
+ const { response, cacheHit } = await handleRequest(request, url, pathname)
129
+
130
+ if (analytics != null) {
131
+ const format = resolveAnalyticsFormat(request, pathname, response)
132
+ try {
133
+ analytics.logRequest({
134
+ timestamp: start,
135
+ path: pathname,
136
+ method: request.method,
137
+ format,
138
+ trafficType: classifyTraffic(request, format),
139
+ statusCode: response.status,
140
+ latencyMs: Date.now() - start,
141
+ userAgent: request.headers.get('User-Agent') ?? '',
142
+ contentLength: readContentLength(response),
143
+ cacheHit,
144
+ siteId
145
+ })
146
+ } catch {
147
+ // analytics must never break the response path
148
+ }
149
+ }
150
+
151
+ return response
152
+ }
153
+
154
+ // ---- Analytics format resolution ----
155
+
156
+ function resolveAnalyticsFormat (
157
+ request: Request,
158
+ pathname: string,
159
+ response: Response
160
+ ): AnalyticsResponseFormat {
161
+ // MCP: check endpoint first (MCP responses may have various Content-Types)
162
+ const mcpEndpoint: string = config.mcp.endpoint ?? '/mcp'
163
+ if (
164
+ config.mcp.enabled &&
165
+ (pathname === mcpEndpoint || pathname.startsWith(mcpEndpoint + '/'))
166
+ ) {
167
+ return 'mcp'
168
+ }
169
+
170
+ // Prefer response Content-Type when available
171
+ const contentType = response.headers.get('Content-Type') ?? ''
172
+ if (contentType.includes('text/markdown')) return 'markdown'
173
+ if (contentType.includes('text/html')) return 'html'
174
+ if (contentType.includes('application/json')) return 'api'
175
+
176
+ // Fallback: infer from request when Content-Type is missing (e.g. static files)
177
+ const accept = request.headers.get('Accept') ?? ''
178
+ if (
179
+ accept.includes('text/markdown') ||
180
+ accept.includes('text/x-markdown') ||
181
+ accept.includes('application/markdown') ||
182
+ pathname.endsWith('.md')
183
+ ) {
184
+ return 'markdown'
185
+ }
186
+ return 'other'
187
+ }
188
+
189
+ // ---- Inner request handler (no analytics instrumentation) ----
190
+
191
+ async function handleRequest (
192
+ request: Request,
193
+ url: URL,
194
+ pathname: string
195
+ ): Promise<{ response: Response, cacheHit: boolean }> {
196
+ function ok (response: Response): { response: Response, cacheHit: boolean } {
197
+ return { response, cacheHit: false }
198
+ }
199
+
37
200
  // ---- Special routes ----
38
201
 
39
202
  if (pathname === '/_health') {
40
- return new Response('ok', { status: 200 })
203
+ return ok(new Response('ok', { status: 200 }))
41
204
  }
42
205
 
43
206
  if (pathname === '/llms.txt' && config.llmsTxt.enabled) {
44
207
  if (llmsTxtCache == null) {
45
208
  llmsTxtCache = await generateLlmsTxt(source, config)
46
209
  }
47
- return new Response(llmsTxtCache, {
210
+ return ok(textResponse(llmsTxtCache, {
48
211
  status: 200,
49
212
  headers: {
50
213
  'Content-Type': 'text/markdown; charset=utf-8',
51
214
  'Cache-Control': 'public, max-age=3600'
52
215
  }
53
- })
216
+ }))
54
217
  }
55
218
 
56
219
  if (pathname === '/_refresh' && request.method === 'POST') {
220
+ // Optional Bearer token auth
221
+ if (refreshToken != null && refreshToken !== '') {
222
+ const authHeader = request.headers.get('Authorization') ?? ''
223
+ const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''
224
+ if (!timingSafeEqual(token, refreshToken)) {
225
+ return ok(new Response('Unauthorized', { status: 401 }))
226
+ }
227
+ }
228
+
57
229
  await source.refresh()
58
230
  llmsTxtCache = null
59
- return new Response('cache cleared', { status: 200 })
231
+
232
+ // Clear cached search index
233
+ if (contentCache != null) {
234
+ try { await contentCache.setSearchIndex('') } catch { /* non-fatal */ }
235
+ }
236
+
237
+ // Clear response cache (supports ?path= for single-entry invalidation)
238
+ if (responseCache != null) {
239
+ const pathParam = url.searchParams.get('path')
240
+ if (pathParam != null) {
241
+ // Invalidate both html and markdown variants
242
+ await responseCache.delete(pathParam + ':html')
243
+ await responseCache.delete(pathParam + ':markdown')
244
+ } else {
245
+ await responseCache.clear()
246
+ }
247
+ }
248
+
249
+ // Reset search index
250
+ if (searchIndexPromise != null) {
251
+ searchIndexPromise = null
252
+ void ensureSearchIndex()
253
+ }
254
+
255
+ return ok(new Response('cache cleared', { status: 200 }))
256
+ }
257
+
258
+ // ---- Search API ----
259
+ if (pathname === '/api/search' && config.client.search) {
260
+ const query = (url.searchParams.get('q') ?? '').slice(0, 200)
261
+ const rawLimit = parseInt(url.searchParams.get('limit') ?? '10', 10)
262
+ const limit = Math.min(isNaN(rawLimit) ? 10 : rawLimit, 50)
263
+ const si = await ensureSearchIndex()
264
+ const results = si.search(query, limit)
265
+ return ok(textResponse(JSON.stringify(results), {
266
+ status: 200,
267
+ headers: { 'Content-Type': 'application/json' }
268
+ }))
269
+ }
270
+
271
+ // ---- MCP endpoint ----
272
+ const mcpPath: string = config.mcp.endpoint ?? '/mcp'
273
+ if (
274
+ config.mcp.enabled &&
275
+ (pathname === mcpPath || pathname.startsWith(mcpPath + '/'))
276
+ ) {
277
+ const mcp = await ensureMcpHandler()
278
+ return ok(await mcp(request))
60
279
  }
61
280
 
62
281
  // ---- Static files passthrough ----
63
- if (config.staticDir != null && hasStaticExtension(pathname)) {
64
- return await serveStatic(pathname, config.staticDir)
282
+ if (hasStaticExtension(pathname)) {
283
+ if (staticHandler != null) {
284
+ const staticResponse = await staticHandler(pathname)
285
+ if (staticResponse != null) return ok(staticResponse)
286
+ }
287
+ if (config.staticDir != null) {
288
+ return ok(await serveStatic(pathname, config.staticDir))
289
+ }
65
290
  }
66
291
 
67
292
  // ---- Content negotiation + page serving ----
@@ -83,38 +308,69 @@ export function createHandler (opts: HandlerOptions): (request: Request) => Prom
83
308
  if (page == null) {
84
309
  const format = negotiateFormat(request.headers.get('Accept'))
85
310
  if (format === 'markdown') {
86
- return new Response(
311
+ return ok(textResponse(
87
312
  '# 404 — Page Not Found\n\nThe requested page does not exist.\n',
88
313
  {
89
314
  status: 404,
90
315
  headers: { 'Content-Type': 'text/markdown; charset=utf-8' }
91
316
  }
92
- )
317
+ ))
93
318
  }
94
- return new Response(render404(config), {
319
+ return ok(textResponse(render404(config), {
95
320
  status: 404,
96
- headers: htmlHeaders()
97
- })
321
+ headers: htmlHeadersWithCsp(config)
322
+ }))
98
323
  }
99
324
 
100
325
  const format = forceMarkdown
101
326
  ? 'markdown'
102
327
  : negotiateFormat(request.headers.get('Accept'))
103
328
 
329
+ // ---- Response cache check (content pages only) ----
330
+ const cacheEnabled = config.cache?.enabled === true && responseCache != null
331
+ const cacheKey = slug + ':' + format
332
+
333
+ if (cacheEnabled && responseCache != null) {
334
+ const cached = await responseCache.get(cacheKey)
335
+ if (cached != null) {
336
+ // 304 Not Modified support
337
+ const ifNoneMatch = request.headers.get('If-None-Match')
338
+ const etag = cached.headers.ETag
339
+ if (ifNoneMatch != null && etag != null && ifNoneMatch === etag) {
340
+ return { response: new Response(null, { status: 304, headers: notModifiedHeaders(cached.headers) }), cacheHit: true }
341
+ }
342
+ return { response: textResponse(cached.body, { status: cached.status, headers: cached.headers }), cacheHit: true }
343
+ }
344
+ }
345
+
104
346
  if (format === 'markdown') {
105
347
  const tokens = config.negotiation.includeTokenCount
106
348
  ? estimateTokens(page.body)
107
349
  : null
108
350
 
109
- return new Response(page.body, {
110
- status: 200,
111
- headers: markdownHeaders(tokens, config.negotiation.contentSignals)
112
- })
351
+ const headers = markdownHeaders(tokens, config.negotiation.contentSignals, config.cache, slug)
352
+
353
+ // 304 Not Modified support for non-cached path
354
+ const ifNoneMatch = request.headers.get('If-None-Match')
355
+ if (ifNoneMatch != null && headers.ETag != null && ifNoneMatch === headers.ETag) {
356
+ return ok(new Response(null, { status: 304, headers: notModifiedHeaders(headers) }))
357
+ }
358
+
359
+ const response = textResponse(page.body, { status: 200, headers })
360
+ if (cacheEnabled && responseCache != null) {
361
+ await responseCache.set(cacheKey, {
362
+ body: page.body,
363
+ status: 200,
364
+ headers,
365
+ timestamp: Date.now()
366
+ }, config.cache?.maxAgeMarkdown)
367
+ }
368
+ return ok(response)
113
369
  }
114
370
 
115
371
  // ---- Render HTML via React SSR ----
116
372
  const renderedHtml = renderer.renderToHtml(page.body, config.theme.components)
117
- const nav = config.theme.showNav
373
+ const nav = (config.theme.showNav || config.theme.prevNext === true)
118
374
  ? await source.getNavTree()
119
375
  : undefined
120
376
 
@@ -123,16 +379,58 @@ export function createHandler (opts: HandlerOptions): (request: Request) => Prom
123
379
  meta: page.meta,
124
380
  config,
125
381
  nav,
126
- currentSlug: page.slug
382
+ currentSlug: page.slug,
383
+ body: page.body
127
384
  })
128
385
 
129
- return new Response(fullPage, {
130
- status: 200,
131
- headers: htmlHeaders()
132
- })
386
+ const htmlHdrs = htmlHeadersWithCsp(config, slug)
387
+
388
+ // 304 Not Modified support for non-cached path
389
+ const ifNoneMatch = request.headers.get('If-None-Match')
390
+ if (ifNoneMatch != null && htmlHdrs.ETag != null && ifNoneMatch === htmlHdrs.ETag) {
391
+ return ok(new Response(null, { status: 304, headers: notModifiedHeaders(htmlHdrs) }))
392
+ }
393
+
394
+ const htmlResponse = textResponse(fullPage, { status: 200, headers: htmlHdrs })
395
+ if (cacheEnabled && responseCache != null) {
396
+ await responseCache.set(cacheKey, {
397
+ body: fullPage,
398
+ status: 200,
399
+ headers: htmlHdrs,
400
+ timestamp: Date.now()
401
+ }, config.cache?.maxAge)
402
+ }
403
+ return ok(htmlResponse)
133
404
  }
134
405
  }
135
406
 
407
+ /**
408
+ * Read content length from the response Content-Length header.
409
+ * Returns 0 when the header is absent or unparseable.
410
+ */
411
+ function readContentLength (response: Response): number {
412
+ const header = response.headers.get('Content-Length')
413
+ if (header == null) return 0
414
+ const parsed = parseInt(header, 10)
415
+ return isNaN(parsed) ? 0 : parsed
416
+ }
417
+
418
+ /** Create a Response with Content-Length set from the body string. */
419
+ function textResponse (body: string, init: ResponseInit): Response {
420
+ const encoded = new TextEncoder().encode(body)
421
+ const headers = new Headers(init.headers)
422
+ headers.set('Content-Length', String(encoded.byteLength))
423
+ return new Response(encoded, { ...init, headers })
424
+ }
425
+
426
+ function htmlHeadersWithCsp (config: MkdnSiteConfig, slug?: string): Record<string, string> {
427
+ const headers = htmlHeaders(config.cache, slug)
428
+ if (config.csp?.enabled !== false) {
429
+ headers['Content-Security-Policy'] = buildCsp(config)
430
+ }
431
+ return headers
432
+ }
433
+
136
434
  const STATIC_EXTENSIONS = new Set([
137
435
  '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico',
138
436
  '.mp4', '.webm', '.ogg', '.mp3', '.wav',
@@ -172,6 +470,43 @@ const MIME_TYPES: Record<string, string> = {
172
470
  '.eot': 'application/vnd.ms-fontobject'
173
471
  }
174
472
 
473
+ /**
474
+ * Extract headers required by RFC 7232 for 304 Not Modified responses.
475
+ * Must include ETag, Cache-Control, Vary (and Content-Type if set).
476
+ */
477
+ function notModifiedHeaders (original: Record<string, string>): Record<string, string> {
478
+ const headers: Record<string, string> = {}
479
+ if (original.ETag != null) headers.ETag = original.ETag
480
+ if (original['Cache-Control'] != null) headers['Cache-Control'] = original['Cache-Control']
481
+ if (original.Vary != null) headers.Vary = original.Vary
482
+ return headers
483
+ }
484
+
485
+ /**
486
+ * Constant-time string comparison to prevent timing side-channel attacks.
487
+ * Uses TextEncoder (Web API, available on all runtimes) instead of Buffer
488
+ * for Deno compatibility. Always compares full length regardless of mismatch.
489
+ */
490
+ function timingSafeEqual (a: string, b: string): boolean {
491
+ const encoder = new TextEncoder()
492
+ const aBuf = encoder.encode(a)
493
+ const bBuf = encoder.encode(b)
494
+ if (aBuf.length !== bBuf.length) {
495
+ // Still do a full comparison to avoid leaking length info via timing
496
+ let mismatch = 1
497
+ for (let i = 0; i < bBuf.length; i++) {
498
+ mismatch |= bBuf[i] ^ bBuf[i]
499
+ }
500
+ void mismatch
501
+ return false
502
+ }
503
+ let mismatch = 0
504
+ for (let i = 0; i < aBuf.length; i++) {
505
+ mismatch |= aBuf[i] ^ bBuf[i]
506
+ }
507
+ return mismatch === 0
508
+ }
509
+
175
510
  async function serveStatic (pathname: string, staticDir: string): Promise<Response> {
176
511
  try {
177
512
  const filePath = `${staticDir}${pathname}`
package/src/index.ts CHANGED
@@ -13,6 +13,12 @@ export type { HandlerOptions } from './handler.ts'
13
13
  export { resolveConfig, DEFAULT_CONFIG } from './config/defaults.ts'
14
14
  export type {
15
15
  MkdnSiteConfig,
16
+ SiteConfig,
17
+ OgConfig,
18
+ AnalyticsConfig,
19
+ CspConfig,
20
+ CacheConfig,
21
+ FaviconConfig,
16
22
  ThemeConfig,
17
23
  NegotiationConfig,
18
24
  ClientConfig,
@@ -22,12 +28,30 @@ export type {
22
28
  LinkProps,
23
29
  ImageProps,
24
30
  CodeBlockProps,
25
- InlineCodeProps
31
+ InlineCodeProps,
32
+ ColorTokens,
33
+ FontTokens,
34
+ LogoConfig
26
35
  } from './config/schema.ts'
27
36
 
37
+ // Theme utilities
38
+ export { buildThemeCss } from './theme/build-css.ts'
39
+ export { BASE_THEME_CSS } from './theme/base-css.ts'
40
+
28
41
  // Content sources
29
42
  export { FilesystemSource } from './content/filesystem.ts'
30
43
  export { GitHubSource } from './content/github.ts'
44
+ export { R2ContentSource } from './content/r2.ts'
45
+ export type { R2ContentSourceConfig } from './content/r2.ts'
46
+ export { AssetsSource } from './content/assets.ts'
47
+ export type { AssetsSourceConfig } from './content/assets.ts'
48
+ export { buildNavTree } from './content/nav-builder.ts'
49
+ export type { ContentCache } from './content/cache.ts'
50
+ export { MemoryContentCache, KVContentCache } from './content/cache.ts'
51
+ export type { ResponseCache, CachedResponse } from './cache/response.ts'
52
+ export { MemoryResponseCache } from './cache/memory.ts'
53
+ export { KVResponseCache } from './cache/kv.ts'
54
+ export type { FileEntry } from './content/nav-builder.ts'
31
55
  export type {
32
56
  ContentSource,
33
57
  ContentPage,
@@ -55,3 +79,27 @@ export { generateLlmsTxt } from './discovery/llmstxt.ts'
55
79
  export type { DeploymentAdapter } from './adapters/types.ts'
56
80
  export { detectRuntime } from './adapters/types.ts'
57
81
  export { LocalAdapter } from './adapters/local.ts'
82
+ export { CloudflareAdapter } from './adapters/cloudflare.ts'
83
+ export type { CloudflareEnv } from './adapters/cloudflare.ts'
84
+
85
+ // Search
86
+ export { createSearchIndex } from './search/index.ts'
87
+ export type { SearchIndex, SearchResult, SerializedSearchIndex } from './search/index.ts'
88
+
89
+ // MCP
90
+ export { createMcpServer } from './mcp/server.ts'
91
+ export { createMcpHandler } from './mcp/transport.ts'
92
+ export type { McpConfig } from './config/schema.ts'
93
+
94
+ // Traffic analytics
95
+ export type {
96
+ TrafficAnalytics,
97
+ TrafficEvent,
98
+ TrafficType,
99
+ AnalyticsResponseFormat
100
+ } from './analytics/types.ts'
101
+ export { classifyTraffic, BOT_PATTERNS } from './analytics/classify.ts'
102
+ export { NoopAnalytics } from './analytics/noop.ts'
103
+ export { ConsoleAnalytics } from './analytics/console.ts'
104
+ export { WorkersAnalyticsEngineAnalytics } from './adapters/cloudflare.ts'
105
+ export type { TrafficAnalyticsConfig } from './config/schema.ts'