mkdnsite 0.0.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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'
@@ -0,0 +1,164 @@
1
+ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
2
+ import { z } from 'zod'
3
+ import type { ContentSource } from '../content/types.ts'
4
+ import type { SearchIndex } from '../search/index.ts'
5
+
6
+ export function createMcpServer (opts: {
7
+ source: ContentSource
8
+ searchIndex: SearchIndex
9
+ }): McpServer {
10
+ const { source, searchIndex } = opts
11
+
12
+ const server = new McpServer({
13
+ name: 'mkdnsite',
14
+ version: '0.1.0'
15
+ })
16
+
17
+ // ─── Tool: search_docs ──────────────────────────────────────────────────────
18
+
19
+ server.tool(
20
+ 'search_docs',
21
+ 'Full-text search across all documentation pages',
22
+ {
23
+ query: z.string().describe('Search query'),
24
+ limit: z.number().min(1).max(50).optional().describe('Max results (default 10, max 50)')
25
+ },
26
+ async ({ query, limit }) => {
27
+ const results = searchIndex.search(query, limit ?? 10)
28
+ return {
29
+ content: [{
30
+ type: 'text' as const,
31
+ text: JSON.stringify(results.map(r => ({
32
+ title: r.title,
33
+ slug: r.slug,
34
+ description: r.description,
35
+ excerpt: r.excerpt,
36
+ score: Math.round(r.score * 1000) / 1000
37
+ })), null, 2)
38
+ }]
39
+ }
40
+ }
41
+ )
42
+
43
+ // ─── Tool: get_page ─────────────────────────────────────────────────────────
44
+
45
+ server.tool(
46
+ 'get_page',
47
+ 'Retrieve the full markdown content of a documentation page by slug',
48
+ {
49
+ slug: z.string().describe('Page slug (e.g. /docs/getting-started)')
50
+ },
51
+ async ({ slug }) => {
52
+ const page = await source.getPage(slug)
53
+ if (page == null) {
54
+ return {
55
+ isError: true,
56
+ content: [{
57
+ type: 'text' as const,
58
+ text: `Page not found: ${slug}`
59
+ }]
60
+ }
61
+ }
62
+
63
+ const lines: string[] = []
64
+ if (page.meta.title != null) lines.push(`# ${String(page.meta.title)}`)
65
+ if (page.meta.description != null) lines.push(`> ${String(page.meta.description)}`)
66
+ if (lines.length > 0) lines.push('')
67
+ lines.push(page.body)
68
+
69
+ return {
70
+ content: [{
71
+ type: 'text' as const,
72
+ text: lines.join('\n')
73
+ }]
74
+ }
75
+ }
76
+ )
77
+
78
+ // ─── Tool: list_pages ───────────────────────────────────────────────────────
79
+
80
+ server.tool(
81
+ 'list_pages',
82
+ 'List all available documentation pages',
83
+ {},
84
+ async () => {
85
+ const pages = await source.listPages()
86
+ const list = pages.map(p => ({
87
+ title: String(p.meta.title ?? p.slug),
88
+ slug: p.slug,
89
+ description: p.meta.description != null ? String(p.meta.description) : undefined
90
+ }))
91
+ return {
92
+ content: [{
93
+ type: 'text' as const,
94
+ text: JSON.stringify(list, null, 2)
95
+ }]
96
+ }
97
+ }
98
+ )
99
+
100
+ // ─── Tool: get_nav ──────────────────────────────────────────────────────────
101
+
102
+ server.tool(
103
+ 'get_nav',
104
+ 'Get the documentation navigation tree',
105
+ {},
106
+ async () => {
107
+ const nav = await source.getNavTree()
108
+ return {
109
+ content: [{
110
+ type: 'text' as const,
111
+ text: JSON.stringify(nav, null, 2)
112
+ }]
113
+ }
114
+ }
115
+ )
116
+
117
+ // ─── Resources: pages ───────────────────────────────────────────────────────
118
+
119
+ const pageTemplate = new ResourceTemplate(
120
+ 'mkdnsite://pages/{slug}',
121
+ {
122
+ list: async () => {
123
+ const pages = await source.listPages()
124
+ return {
125
+ resources: pages.map(p => ({
126
+ uri: `mkdnsite://pages${p.slug}`,
127
+ name: String(p.meta.title ?? p.slug),
128
+ description: p.meta.description != null ? String(p.meta.description) : undefined,
129
+ mimeType: 'text/markdown'
130
+ }))
131
+ }
132
+ }
133
+ }
134
+ )
135
+
136
+ server.resource(
137
+ 'page',
138
+ pageTemplate,
139
+ { mimeType: 'text/markdown' },
140
+ async (uri) => {
141
+ // uri.href = "mkdnsite://pages/docs/getting-started"
142
+ const rawSlug = uri.href.replace(/^mkdnsite:\/\/pages/, '')
143
+ const resolvedSlug = rawSlug === '' ? '/' : rawSlug
144
+ const page = await source.getPage(resolvedSlug)
145
+ if (page == null) {
146
+ return { contents: [] }
147
+ }
148
+ const lines: string[] = []
149
+ if (page.meta.title != null) lines.push(`# ${String(page.meta.title)}`)
150
+ if (page.meta.description != null) lines.push(`> ${String(page.meta.description)}`)
151
+ if (lines.length > 0) lines.push('')
152
+ lines.push(page.body)
153
+ return {
154
+ contents: [{
155
+ uri: uri.href,
156
+ mimeType: 'text/markdown',
157
+ text: lines.join('\n')
158
+ }]
159
+ }
160
+ }
161
+ )
162
+
163
+ return server
164
+ }
@@ -0,0 +1,29 @@
1
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
2
+ import type { MkdnSiteConfig } from '../config/schema.ts'
3
+ import { createSearchIndex } from '../search/index.ts'
4
+ import { createMcpServer } from './server.ts'
5
+
6
+ /**
7
+ * Run the MCP server over stdio (JSON-RPC via stdin/stdout).
8
+ *
9
+ * Used by the `mkdnsite mcp` subcommand for MCP clients like Claude Desktop
10
+ * that communicate via stdio rather than HTTP. Does NOT start a web server.
11
+ */
12
+ export async function runMcpStdio (config: MkdnSiteConfig): Promise<void> {
13
+ let source
14
+
15
+ if (config.github?.owner != null && config.github?.repo != null) {
16
+ const { GitHubSource } = await import('../content/github.ts')
17
+ source = new GitHubSource(config.github)
18
+ } else {
19
+ const { FilesystemSource } = await import('../content/filesystem.ts')
20
+ source = new FilesystemSource(config.contentDir)
21
+ }
22
+
23
+ const searchIndex = createSearchIndex()
24
+ await searchIndex.rebuild(source)
25
+
26
+ const server = createMcpServer({ source, searchIndex })
27
+ const transport = new StdioServerTransport()
28
+ await server.connect(transport)
29
+ }
@@ -0,0 +1,29 @@
1
+ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'
2
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
3
+
4
+ /**
5
+ * Creates a fetch-compatible handler that routes HTTP requests to the MCP server
6
+ * via the Web Standard Streamable HTTP transport.
7
+ *
8
+ * Uses stateless mode (no session management) — each request is independent.
9
+ * A single transport instance handles all concurrent requests.
10
+ */
11
+ export function createMcpHandler (server: McpServer): (request: Request) => Promise<Response> {
12
+ const transport = new WebStandardStreamableHTTPServerTransport({
13
+ sessionIdGenerator: undefined // stateless mode
14
+ })
15
+
16
+ let connectPromise: Promise<void> | null = null
17
+
18
+ async function ensureConnected (): Promise<void> {
19
+ if (connectPromise == null) {
20
+ connectPromise = server.connect(transport)
21
+ }
22
+ await connectPromise
23
+ }
24
+
25
+ return async (request: Request): Promise<Response> => {
26
+ await ensureConnected()
27
+ return await transport.handleRequest(request)
28
+ }
29
+ }
@@ -1,13 +1,35 @@
1
- import type { ContentSignals } from '../config/schema.ts'
1
+ import type { ContentSignals, CacheConfig } from '../config/schema.ts'
2
+
3
+ function buildCacheControl (maxAge: number, swr: number): string {
4
+ let cc = 'public, max-age=' + String(maxAge)
5
+ if (swr > 0) cc += ', stale-while-revalidate=' + String(swr)
6
+ return cc
7
+ }
8
+
9
+ function buildEtag (versionTag: string, slug?: string): string {
10
+ // Sanitize versionTag: strip quotes and non-printable chars to prevent malformed ETag headers
11
+ // eslint-disable-next-line no-control-regex
12
+ const safeVersion = versionTag.replace(/["\\\x00-\x1f]/g, '')
13
+ const tag = slug != null ? safeVersion + '-' + slug.replace(/[^a-zA-Z0-9-]/g, '_') : safeVersion
14
+ return 'W/"' + tag + '"'
15
+ }
2
16
 
3
17
  export function markdownHeaders (
4
18
  tokenCount: number | null,
5
- signals?: ContentSignals
19
+ signals?: ContentSignals,
20
+ cache?: CacheConfig,
21
+ slug?: string
6
22
  ): Record<string, string> {
23
+ const maxAge = cache?.maxAgeMarkdown ?? 300
24
+ const swr = cache?.staleWhileRevalidate ?? 0
7
25
  const headers: Record<string, string> = {
8
26
  'Content-Type': 'text/markdown; charset=utf-8',
9
27
  Vary: 'Accept',
10
- 'Cache-Control': 'public, max-age=300'
28
+ 'Cache-Control': buildCacheControl(maxAge, swr)
29
+ }
30
+
31
+ if (cache?.versionTag != null && cache.versionTag !== '') {
32
+ headers.ETag = buildEtag(cache.versionTag, slug)
11
33
  }
12
34
 
13
35
  if (tokenCount != null) {
@@ -16,9 +38,9 @@ export function markdownHeaders (
16
38
 
17
39
  if (signals != null) {
18
40
  const parts: string[] = []
19
- if (signals.aiTrain !== undefined) parts.push(`ai-train=${signals.aiTrain}`)
20
- if (signals.search !== undefined) parts.push(`search=${signals.search}`)
21
- if (signals.aiInput !== undefined) parts.push(`ai-input=${signals.aiInput}`)
41
+ if (signals.aiTrain !== undefined) parts.push('ai-train=' + String(signals.aiTrain))
42
+ if (signals.search !== undefined) parts.push('search=' + String(signals.search))
43
+ if (signals.aiInput !== undefined) parts.push('ai-input=' + String(signals.aiInput))
22
44
  if (parts.length > 0) {
23
45
  headers['Content-Signal'] = parts.join(', ')
24
46
  }
@@ -27,12 +49,18 @@ export function markdownHeaders (
27
49
  return headers
28
50
  }
29
51
 
30
- export function htmlHeaders (): Record<string, string> {
31
- return {
52
+ export function htmlHeaders (cache?: CacheConfig, slug?: string): Record<string, string> {
53
+ const maxAge = cache?.maxAge ?? 300
54
+ const swr = cache?.staleWhileRevalidate ?? 0
55
+ const headers: Record<string, string> = {
32
56
  'Content-Type': 'text/html; charset=utf-8',
33
57
  Vary: 'Accept',
34
- 'Cache-Control': 'public, max-age=300'
58
+ 'Cache-Control': buildCacheControl(maxAge, swr)
35
59
  }
60
+ if (cache?.versionTag != null && cache.versionTag !== '') {
61
+ headers.ETag = buildEtag(cache.versionTag, slug)
62
+ }
63
+ return headers
36
64
  }
37
65
 
38
66
  /**