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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mkdnsite",
3
- "version": "0.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Markdown for the web. HTML for humans, Markdown for agents.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -15,7 +15,10 @@
15
15
  "./adapters/fly": "./src/adapters/fly.ts"
16
16
  },
17
17
  "scripts": {
18
- "dev": "bun run --watch src/cli.ts ./content",
18
+ "dev": "bun run --watch src/cli.ts ./content --static ./static --logo /mkdnsite-logo.png --logo-text mkdnsite",
19
+ "dev:themed": "bun run --watch src/cli.ts --config themed.config.ts",
20
+ "dev:light": "bun run --watch src/cli.ts --config themed.config.ts --color-scheme light",
21
+ "dev:dark": "bun run --watch src/cli.ts --config themed.config.ts --color-scheme dark",
19
22
  "start": "bun run src/cli.ts",
20
23
  "test": "bun test",
21
24
  "lint": "ts-standard src/ test/",
@@ -44,9 +47,11 @@
44
47
  "src"
45
48
  ],
46
49
  "dependencies": {
50
+ "@modelcontextprotocol/sdk": "^1.27.1",
47
51
  "gray-matter": "^4.0.3",
48
52
  "katex": "^0.16.38",
49
53
  "lucide-react": "^0.577.0",
54
+ "picomatch": "^4.0.3",
50
55
  "react": "^19.0.0",
51
56
  "react-dom": "^19.0.0",
52
57
  "react-markdown": "^9.0.0",
@@ -57,13 +62,15 @@
57
62
  "remark-gfm": "^4.0.0",
58
63
  "remark-github-blockquote-alert": "^2.0.1",
59
64
  "remark-math": "^6.0.0",
60
- "shiki": "^3.0.0"
65
+ "shiki": "^3.0.0",
66
+ "zod": "^4.3.6"
61
67
  },
62
68
  "ts-standard": {
63
69
  "project": "./tsconfig.json"
64
70
  },
65
71
  "devDependencies": {
66
72
  "@types/bun": "latest",
73
+ "@types/picomatch": "^4.0.2",
67
74
  "@types/react": "^19.0.0",
68
75
  "@types/react-dom": "^19.0.0",
69
76
  "ts-standard": "^12.0.2",
@@ -3,12 +3,23 @@ import type { MkdnSiteConfig } from '../config/schema.ts'
3
3
  import type { ContentSource } from '../content/types.ts'
4
4
  import type { MarkdownRenderer } from '../render/types.ts'
5
5
  import { createRenderer } from '../render/types.ts'
6
+ import { GitHubSource } from '../content/github.ts'
7
+ import { R2ContentSource } from '../content/r2.ts'
8
+ import { AssetsSource } from '../content/assets.ts'
9
+ import type { ContentCache } from '../content/cache.ts'
10
+ import { KVContentCache } from '../content/cache.ts'
11
+ import type { ResponseCache } from '../cache/response.ts'
12
+ import { KVResponseCache } from '../cache/kv.ts'
13
+ import type { TrafficAnalytics, TrafficEvent } from '../analytics/types.ts'
6
14
 
7
15
  /**
8
16
  * Cloudflare Workers deployment adapter.
9
17
  *
10
- * Uses R2 for content storage, KV for caching.
11
- * Wildcard DNS routes (*.mkdn.io) for hosted service.
18
+ * Auto-detects content source from env bindings:
19
+ * - CONTENT_SOURCE=github or config.github set GitHubSource
20
+ * - CONTENT_SOURCE=r2 or CONTENT_BUCKET present → R2ContentSource
21
+ * - CONTENT_SOURCE=assets or ASSETS binding present → AssetsSource
22
+ * - Explicit CONTENT_SOURCE env var overrides auto-detection
12
23
  *
13
24
  * Usage in a Worker:
14
25
  *
@@ -38,48 +49,298 @@ export class CloudflareAdapter implements DeploymentAdapter {
38
49
  this.env = env
39
50
  }
40
51
 
41
- createContentSource (_config: MkdnSiteConfig): ContentSource {
42
- // TODO: Implement R2ContentSource
43
- // return new R2ContentSource(this.env.CONTENT_BUCKET)
52
+ private createCache (prefix?: string): ContentCache | undefined {
53
+ if (this.env.CACHE_KV == null) return undefined
54
+ return new KVContentCache(this.env.CACHE_KV, { prefix })
55
+ }
56
+
57
+ createContentSource (config: MkdnSiteConfig): ContentSource {
58
+ const sourceType = this.env.CONTENT_SOURCE
59
+
60
+ // GitHub source: explicit CONTENT_SOURCE=github or config.github set
61
+ if (sourceType === 'github' || (sourceType == null && config.github != null)) {
62
+ const ghConfig = config.github ?? {
63
+ owner: this.env.GITHUB_OWNER ?? '',
64
+ repo: this.env.GITHUB_REPO ?? '',
65
+ ref: this.env.GITHUB_REF,
66
+ token: this.env.GITHUB_TOKEN
67
+ }
68
+ return new GitHubSource({
69
+ ...ghConfig,
70
+ include: config.include,
71
+ exclude: config.exclude
72
+ })
73
+ }
74
+
75
+ // R2 source: explicit CONTENT_SOURCE=r2 or CONTENT_BUCKET binding present
76
+ if (sourceType === 'r2' || (sourceType == null && this.env.CONTENT_BUCKET != null)) {
77
+ if (this.env.CONTENT_BUCKET == null) {
78
+ throw new Error(
79
+ 'CloudflareAdapter: CONTENT_SOURCE=r2 requires a CONTENT_BUCKET binding in wrangler.toml.'
80
+ )
81
+ }
82
+ return new R2ContentSource({
83
+ bucket: this.env.CONTENT_BUCKET,
84
+ basePath: this.env.CONTENT_BASE_PATH,
85
+ cache: this.createCache(this.env.CONTENT_BASE_PATH)
86
+ })
87
+ }
88
+
89
+ // Assets source: explicit CONTENT_SOURCE=assets or ASSETS binding present
90
+ if (sourceType === 'assets' || (sourceType == null && this.env.ASSETS != null)) {
91
+ if (this.env.ASSETS == null) {
92
+ throw new Error(
93
+ 'CloudflareAdapter: CONTENT_SOURCE=assets requires an ASSETS binding in wrangler.toml.'
94
+ )
95
+ }
96
+ const manifest = this.env.CONTENT_MANIFEST != null
97
+ ? JSON.parse(this.env.CONTENT_MANIFEST) as string[]
98
+ : undefined
99
+ return new AssetsSource({
100
+ assets: this.env.ASSETS,
101
+ manifest,
102
+ cache: this.createCache('assets:')
103
+ })
104
+ }
105
+
44
106
  throw new Error(
45
- 'CloudflareAdapter.createContentSource() not yet implemented. ' +
46
- 'Provide an R2-backed ContentSource implementation.'
107
+ 'CloudflareAdapter: No content source configured. ' +
108
+ 'Set CONTENT_SOURCE=github|r2|assets, provide CONTENT_BUCKET (R2), ASSETS binding, or set config.github.'
47
109
  )
48
110
  }
49
111
 
50
112
  async createRenderer (_config: MkdnSiteConfig): Promise<MarkdownRenderer> {
51
- // CF Workers don't have Bun.markdown, always use portable
113
+ // CF Workers don't have Bun.markdown always use portable renderer
52
114
  return await createRenderer('portable')
53
115
  }
116
+
117
+ /**
118
+ * Create a TrafficAnalytics instance if the ANALYTICS binding is present.
119
+ *
120
+ * Returns `undefined` when the binding is absent so callers can skip
121
+ * passing analytics to createHandler without any change in behaviour.
122
+ *
123
+ * Usage:
124
+ * ```ts
125
+ * const handler = createHandler({
126
+ * source: adapter.createContentSource(config),
127
+ * renderer: await adapter.createRenderer(config),
128
+ * config,
129
+ * analytics: adapter.createTrafficAnalytics()
130
+ * })
131
+ * ```
132
+ */
133
+ /**
134
+ * Create a ResponseCache backed by CACHE_KV when available.
135
+ * Returns undefined when no KV binding is present.
136
+ */
137
+ createResponseCache (): ResponseCache | undefined {
138
+ if (this.env.CACHE_KV == null) return undefined
139
+ return new KVResponseCache(this.env.CACHE_KV, { prefix: 'resp:' })
140
+ }
141
+
142
+ createTrafficAnalytics (): TrafficAnalytics | undefined {
143
+ if (this.env.ANALYTICS == null) return undefined
144
+ return new WorkersAnalyticsEngineAnalytics(this.env.ANALYTICS)
145
+ }
146
+
147
+ /**
148
+ * Create a static file handler backed by an R2 bucket.
149
+ *
150
+ * When a `STATIC_BUCKET` binding is present in the Worker environment, this
151
+ * method returns a `staticHandler` function that reads static assets (images,
152
+ * fonts, CSS, etc.) from R2. Pass the result to `createHandler` via the
153
+ * `staticHandler` option:
154
+ *
155
+ * ```ts
156
+ * const handler = createHandler({
157
+ * source: adapter.createContentSource(config),
158
+ * renderer: await adapter.createRenderer(config),
159
+ * config,
160
+ * staticHandler: adapter.createStaticHandler()
161
+ * })
162
+ * ```
163
+ *
164
+ * Returns `undefined` when no `STATIC_BUCKET` binding is configured, so
165
+ * callers can pass the result unconditionally — `createHandler` ignores
166
+ * `undefined` staticHandler values.
167
+ *
168
+ * The handler strips the leading `/` from the pathname before fetching from
169
+ * R2 (e.g. `/logo.png` → key `logo.png`). Returns `null` for missing objects
170
+ * so the request falls through to the built-in `serveStatic` or a 404.
171
+ */
172
+ createStaticHandler (): ((pathname: string) => Promise<Response | null>) | undefined {
173
+ const bucket = this.env.STATIC_BUCKET
174
+ if (bucket == null) return undefined
175
+
176
+ return async (pathname: string): Promise<Response | null> => {
177
+ const key = pathname.replace(/^\//, '')
178
+ const obj = await bucket.get(key)
179
+ if (obj == null) return null
180
+
181
+ const ext = key.split('.').pop() ?? ''
182
+ const contentType = MIME_TYPES[ext] ?? 'application/octet-stream'
183
+ const body = await obj.text()
184
+
185
+ return new Response(body, {
186
+ status: 200,
187
+ headers: {
188
+ 'Content-Type': contentType,
189
+ 'Cache-Control': 'public, max-age=31536000, immutable'
190
+ }
191
+ })
192
+ }
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Cloudflare Workers Analytics Engine implementation of TrafficAnalytics.
198
+ *
199
+ * Writes a data point to a CF Analytics Engine dataset binding (`ANALYTICS`).
200
+ * Each field maps to an index (string "blobs") or double (numeric values).
201
+ *
202
+ * Usage: automatically created by `CloudflareAdapter.createTrafficAnalytics()`
203
+ * when the `ANALYTICS` binding is present.
204
+ */
205
+ export class WorkersAnalyticsEngineAnalytics implements TrafficAnalytics {
206
+ private readonly dataset: AnalyticsEngineDataset
207
+
208
+ constructor (dataset: AnalyticsEngineDataset) {
209
+ this.dataset = dataset
210
+ }
211
+
212
+ logRequest (event: TrafficEvent): void {
213
+ // Field ordering is significant — CF Analytics Engine queries reference
214
+ // fields by index (blob1, blob2, ..., double1, double2, ...).
215
+ // Do NOT reorder without updating all downstream queries.
216
+ this.dataset.writeDataPoint({
217
+ indexes: [
218
+ event.siteId ?? '' // index1: site isolation key (empty for single-site)
219
+ ],
220
+ blobs: [
221
+ event.path, // blob1: URL pathname
222
+ event.method, // blob2: HTTP method
223
+ event.format, // blob3: response format (html|markdown|mcp|api|other)
224
+ event.trafficType, // blob4: traffic classification (human|ai_agent|bot|mcp)
225
+ event.userAgent // blob5: raw User-Agent string
226
+ ],
227
+ doubles: [
228
+ event.statusCode, // double1: HTTP status code
229
+ event.latencyMs, // double2: handler latency in ms
230
+ event.contentLength, // double3: response body size in bytes
231
+ event.cacheHit ? 1 : 0, // double4: cache hit (1) or miss (0)
232
+ event.timestamp // double5: request timestamp (epoch ms)
233
+ ]
234
+ })
235
+ }
54
236
  }
55
237
 
56
238
  /**
57
239
  * Expected Cloudflare Worker environment bindings.
58
240
  */
59
241
  export interface CloudflareEnv {
60
- /** R2 bucket for markdown content */
242
+ /** Explicit content source selection */
243
+ CONTENT_SOURCE?: 'github' | 'r2' | 'assets'
244
+
245
+ /** R2 bucket binding for markdown content */
61
246
  CONTENT_BUCKET?: R2Bucket
62
- /** KV namespace for caching */
247
+ /** Key prefix within the R2 bucket (e.g. 'sites/abc123/') */
248
+ CONTENT_BASE_PATH?: string
249
+
250
+ /** Workers Static Assets binding */
251
+ ASSETS?: AssetsFetcher
252
+ /** JSON array of .md file paths (alternative to _manifest.json in assets) */
253
+ CONTENT_MANIFEST?: string
254
+
255
+ /** KV namespace for caching (future use) */
63
256
  CACHE_KV?: KVNamespace
64
- /** Site title from env var */
257
+
258
+ /** R2 bucket for serving static assets (images, fonts, CSS, etc.) */
259
+ STATIC_BUCKET?: R2Bucket
260
+
261
+ /** GitHub owner (used if config.github not set) */
262
+ GITHUB_OWNER?: string
263
+ /** GitHub repo (used if config.github not set) */
264
+ GITHUB_REPO?: string
265
+ /** GitHub branch/tag (default: main) */
266
+ GITHUB_REF?: string
267
+ /** GitHub token for private repos / higher rate limits */
268
+ GITHUB_TOKEN?: string
269
+
270
+ /** Site title (can override config.site.title) */
65
271
  SITE_TITLE?: string
66
- /** Site URL from env var */
272
+ /** Site URL */
67
273
  SITE_URL?: string
274
+
275
+ /** Secret token for authenticating POST /_refresh requests */
276
+ REFRESH_TOKEN?: string
277
+
278
+ /** Workers Analytics Engine dataset binding for traffic analytics */
279
+ ANALYTICS?: AnalyticsEngineDataset
68
280
  }
69
281
 
70
- // Type stubs for CF runtime types (not available in non-CF environments)
282
+ // ─── Static asset MIME type map ──────────────────────────────────────────────
283
+ const MIME_TYPES: Record<string, string> = {
284
+ png: 'image/png',
285
+ jpg: 'image/jpeg',
286
+ jpeg: 'image/jpeg',
287
+ gif: 'image/gif',
288
+ webp: 'image/webp',
289
+ svg: 'image/svg+xml',
290
+ ico: 'image/x-icon',
291
+ css: 'text/css; charset=utf-8',
292
+ js: 'text/javascript; charset=utf-8',
293
+ woff: 'font/woff',
294
+ woff2: 'font/woff2',
295
+ ttf: 'font/ttf',
296
+ otf: 'font/otf',
297
+ pdf: 'application/pdf',
298
+ json: 'application/json'
299
+ }
300
+
301
+ // ─── Cloudflare R2 type stubs ─────────────────────────────────────────────────
302
+ // These types are provided by the CF Workers runtime; stubs here for type-checking
303
+ // in non-CF environments.
304
+
71
305
  interface R2Bucket {
72
306
  get: (key: string) => Promise<R2Object | null>
73
- list: (options?: Record<string, unknown>) => Promise<{ objects: R2Object[] }>
307
+ list: (options?: R2ListOptions) => Promise<R2ObjectList>
74
308
  }
75
309
 
76
310
  interface R2Object {
77
311
  key: string
78
312
  uploaded: Date
313
+ size: number
79
314
  text: () => Promise<string>
80
315
  }
81
316
 
317
+ interface R2ObjectList {
318
+ objects: R2Object[]
319
+ truncated: boolean
320
+ cursor?: string
321
+ }
322
+
323
+ interface R2ListOptions {
324
+ prefix?: string
325
+ cursor?: string
326
+ limit?: number
327
+ }
328
+
329
+ interface AssetsFetcher {
330
+ fetch: (input: Request | string) => Promise<Response>
331
+ }
332
+
82
333
  interface KVNamespace {
83
334
  get: (key: string) => Promise<string | null>
84
- put: (key: string, value: string, options?: Record<string, unknown>) => Promise<void>
335
+ put: (key: string, value: string, options?: { expirationTtl?: number }) => Promise<void>
336
+ delete: (key: string) => Promise<void>
337
+ list: (options?: { prefix?: string }) => Promise<{ keys: Array<{ name: string }> }>
338
+ }
339
+
340
+ interface AnalyticsEngineDataset {
341
+ writeDataPoint: (data: {
342
+ blobs?: string[]
343
+ doubles?: number[]
344
+ indexes?: string[]
345
+ }) => void
85
346
  }
@@ -1,9 +1,11 @@
1
+ import { Buffer } from 'node:buffer'
1
2
  import type { DeploymentAdapter } from './types.ts'
2
3
  import { detectRuntime } from './types.ts'
3
4
  import type { MkdnSiteConfig } from '../config/schema.ts'
4
5
  import type { ContentSource } from '../content/types.ts'
5
6
  import type { MarkdownRenderer } from '../render/types.ts'
6
7
  import { FilesystemSource } from '../content/filesystem.ts'
8
+ import { GitHubSource } from '../content/github.ts'
7
9
  import { createRenderer } from '../render/types.ts'
8
10
 
9
11
  export class LocalAdapter implements DeploymentAdapter {
@@ -15,7 +17,17 @@ export class LocalAdapter implements DeploymentAdapter {
15
17
  }
16
18
 
17
19
  createContentSource (config: MkdnSiteConfig): ContentSource {
18
- return new FilesystemSource(config.contentDir)
20
+ if (config.github != null) {
21
+ return new GitHubSource({
22
+ ...config.github,
23
+ include: config.include,
24
+ exclude: config.exclude
25
+ })
26
+ }
27
+ return new FilesystemSource(config.contentDir, {
28
+ include: config.include,
29
+ exclude: config.exclude
30
+ })
19
31
  }
20
32
 
21
33
  async createRenderer (config: MkdnSiteConfig): Promise<MarkdownRenderer> {
@@ -129,25 +141,43 @@ export class LocalAdapter implements DeploymentAdapter {
129
141
 
130
142
  private printStartup (config: MkdnSiteConfig, port: number): void {
131
143
  const url = `http://localhost:${String(port)}`
144
+ const DIM_CYAN = '\x1b[2;36m'
145
+ const BOLD_GREEN = '\x1b[1;32m'
146
+ const DIM = '\x1b[2m'
147
+ const RESET = '\x1b[0m'
148
+
149
+ // ASCII art header
132
150
  console.log('')
133
- console.log(' ┌──────────────────────────────────────────────┐')
134
- console.log(' │ │')
135
- console.log(' │ mkdnsite is running │')
136
- console.log(` │ ${url.padEnd(42)} │`)
137
- console.log(' │ │')
138
- console.log(` │ Runtime: ${this.name.padEnd(32)} │`)
139
- console.log(` │ Content: ${config.contentDir.padEnd(32)} │`)
140
- console.log(` │ Renderer: ${this.rendererEngine.padEnd(30)} │`)
141
- console.log(` │ Theme mode: ${config.theme.mode.padEnd(28)} │`)
142
- console.log(` │ Client JS: ${(config.client.enabled ? 'on' : 'off').padEnd(29)} │`)
143
- console.log(` │ Content negotiation: ${(config.negotiation.enabled ? 'on' : 'off').padEnd(19)} │`)
144
- console.log(' │ │')
145
- console.log(' └──────────────────────────────────────────────┘')
151
+ console.log(`${DIM_CYAN} ▌ ▌ ▘▗ `)
152
+ console.log('▛▛▌▙▘▛▌▛▌▛▘▌▜▘█▌')
153
+ console.log(`▌▌▌▛▖▙▌▌▌▄▌▌▐▖▙▖${RESET}`)
154
+ console.log('')
155
+ console.log(` ${BOLD_GREEN}\u2192 ${url}${RESET}`)
156
+ console.log('')
157
+
158
+ const row = (label: string, value: string): void => {
159
+ console.log(` ${DIM}${label.padEnd(12)}${RESET}${value}`)
160
+ }
161
+
162
+ row('Runtime', `local (${this.name})`)
163
+ if (config.github != null) {
164
+ const owner: string = config.github.owner
165
+ const repo: string = config.github.repo
166
+ const ref: string = config.github.ref ?? 'main'
167
+ row('GitHub', `${owner}/${repo}@${ref}`)
168
+ } else {
169
+ row('Content', config.contentDir)
170
+ }
171
+ row('Renderer', this.rendererEngine)
172
+ if (config.mcp.enabled) {
173
+ row('MCP', config.mcp.endpoint ?? '/mcp')
174
+ }
175
+ if (config.client.search) {
176
+ row('Search', '/api/search')
177
+ }
178
+
146
179
  console.log('')
147
- console.log(' Try:')
148
- console.log(` curl ${url}`)
149
- console.log(` curl -H "Accept: text/markdown" ${url}`)
150
- console.log(` curl ${url}/llms.txt`)
180
+ console.log(` ${DIM}Ctrl+C to stop${RESET}`)
151
181
  console.log('')
152
182
  }
153
183
  }
@@ -0,0 +1,65 @@
1
+ import type { TrafficType, AnalyticsResponseFormat } from './types.ts'
2
+
3
+ /**
4
+ * Known crawler / bot User-Agent patterns.
5
+ * Checked case-insensitively.
6
+ *
7
+ * This list is intentionally extensible — add entries as new crawlers appear.
8
+ */
9
+ export const BOT_PATTERNS: RegExp[] = [
10
+ /googlebot/i,
11
+ /bingbot/i,
12
+ /slurp/i, // Yahoo
13
+ /duckduckbot/i,
14
+ /baiduspider/i,
15
+ /yandexbot/i,
16
+ /sogou/i,
17
+ /exabot/i,
18
+ /facebot/i,
19
+ /ia_archiver/i, // Alexa / Internet Archive
20
+ /semrushbot/i,
21
+ /ahrefsbot/i,
22
+ /mj12bot/i,
23
+ /dotbot/i,
24
+ /rogerbot/i,
25
+ /archive\.org_bot/i,
26
+ /petalbot/i,
27
+ /bytespider/i, // TikTok
28
+ /applebot/i,
29
+ /linkedinbot/i,
30
+ /twitterbot/i,
31
+ /facebookexternalhit/i,
32
+ /whatsapp/i,
33
+ /telegrambot/i,
34
+ /discordbot/i,
35
+ /slackbot/i
36
+ ]
37
+
38
+ /**
39
+ * Classify a request as human, ai_agent, bot, or mcp traffic.
40
+ *
41
+ * Rules (evaluated in order):
42
+ * 1. MCP format → 'mcp'
43
+ * 2. markdown format (already resolved by the handler from Accept header / .md URL) → 'ai_agent'
44
+ * 3. User-Agent matches a known bot pattern → 'bot'
45
+ * 4. Otherwise → 'human'
46
+ *
47
+ * The `format` parameter is pre-resolved by the handler's `resolveAnalyticsFormat()`,
48
+ * which already checks Content-Type, Accept headers, and .md URL suffix — so we
49
+ * avoid duplicating that logic here.
50
+ */
51
+ export function classifyTraffic (request: Request, format: AnalyticsResponseFormat): TrafficType {
52
+ // MCP traffic
53
+ if (format === 'mcp') return 'mcp'
54
+
55
+ // AI agent: served raw markdown (format resolved from Accept header / .md URL / Content-Type)
56
+ if (format === 'markdown') return 'ai_agent'
57
+
58
+ // Known bot by User-Agent
59
+ const ua = request.headers.get('User-Agent') ?? ''
60
+ if (ua !== '' && BOT_PATTERNS.some(pattern => pattern.test(ua))) {
61
+ return 'bot'
62
+ }
63
+
64
+ return 'human'
65
+ }
@@ -0,0 +1,39 @@
1
+ import type { TrafficAnalytics, TrafficEvent } from './types.ts'
2
+
3
+ /**
4
+ * Console analytics implementation — writes a structured log line to stdout.
5
+ *
6
+ * Useful during development and debugging. Output format is a single JSON line
7
+ * per request so it can be piped to `jq` or similar tools.
8
+ *
9
+ * Example output:
10
+ * {"ts":1710000000000,"method":"GET","path":"/docs","format":"html","type":"human","status":200,"ms":12,"bytes":4321,"cache":false}
11
+ */
12
+ export class ConsoleAnalytics implements TrafficAnalytics {
13
+ private readonly output: (line: string) => void
14
+
15
+ /**
16
+ * @param output - Write function (defaults to console.log). Injectable for
17
+ * testing without polluting test output.
18
+ */
19
+ constructor (output?: (line: string) => void) {
20
+ this.output = output ?? console.log
21
+ }
22
+
23
+ logRequest (event: TrafficEvent): void {
24
+ const obj: Record<string, unknown> = {
25
+ ts: event.timestamp,
26
+ method: event.method,
27
+ path: event.path,
28
+ format: event.format,
29
+ type: event.trafficType,
30
+ status: event.statusCode,
31
+ ms: event.latencyMs,
32
+ bytes: event.contentLength,
33
+ cache: event.cacheHit,
34
+ ua: event.userAgent
35
+ }
36
+ if (event.siteId != null) obj.site = event.siteId
37
+ this.output(JSON.stringify(obj))
38
+ }
39
+ }
@@ -0,0 +1,15 @@
1
+ import type { TrafficAnalytics, TrafficEvent } from './types.ts'
2
+
3
+ /**
4
+ * No-op analytics implementation.
5
+ *
6
+ * The default when no analytics backend is configured. logRequest() is a
7
+ * genuine no-op — zero allocations, zero overhead beyond the null check in
8
+ * the handler.
9
+ */
10
+ export class NoopAnalytics implements TrafficAnalytics {
11
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
12
+ logRequest (_event: TrafficEvent): void {
13
+ // intentionally empty
14
+ }
15
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Traffic analytics types for mkdnsite.
3
+ *
4
+ * The TrafficAnalytics interface is the core extension point — implement it
5
+ * to route request events to any analytics backend (console, CF Analytics Engine,
6
+ * ClickHouse, Plausible, etc.).
7
+ */
8
+
9
+ /** Classification of who sent the request */
10
+ export type TrafficType = 'human' | 'ai_agent' | 'bot' | 'mcp'
11
+
12
+ /** What format was served in the response */
13
+ export type AnalyticsResponseFormat = 'html' | 'markdown' | 'mcp' | 'api' | 'other'
14
+
15
+ /** A single request event captured by the analytics hook */
16
+ export interface TrafficEvent {
17
+ /** Unix timestamp (ms) when the request started — Date.now() */
18
+ timestamp: number
19
+ /** URL pathname */
20
+ path: string
21
+ /** HTTP method (GET, POST, etc.) */
22
+ method: string
23
+ /** What was served */
24
+ format: AnalyticsResponseFormat
25
+ /** Classified caller type */
26
+ trafficType: TrafficType
27
+ /** HTTP status code of the response */
28
+ statusCode: number
29
+ /** End-to-end handler latency in milliseconds */
30
+ latencyMs: number
31
+ /** Raw User-Agent string */
32
+ userAgent: string
33
+ /** Response body size in bytes */
34
+ contentLength: number
35
+ /** Whether the response was served from cache */
36
+ cacheHit: boolean
37
+ /** Site identifier for multi-tenant deployments (e.g. mkdn.io). Undefined for single-site. */
38
+ siteId?: string
39
+ }
40
+
41
+ /**
42
+ * Pluggable traffic analytics backend.
43
+ *
44
+ * `logRequest` is fire-and-forget and must be synchronous (or fire async work
45
+ * without blocking the response). Implementations must never throw.
46
+ */
47
+ export interface TrafficAnalytics {
48
+ logRequest: (event: TrafficEvent) => void
49
+ }