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/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
|
|
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
|
-
|
|
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 (
|
|
64
|
-
|
|
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
|
|
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
|
|
319
|
+
return ok(textResponse(render404(config), {
|
|
95
320
|
status: 404,
|
|
96
|
-
headers:
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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'
|