mkdnsite 0.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/handler.ts ADDED
@@ -0,0 +1,188 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { extname } from 'node:path'
3
+ import type { MkdnSiteConfig } from './config/schema.ts'
4
+ import type { ContentSource } from './content/types.ts'
5
+ import type { MarkdownRenderer } from './render/types.ts'
6
+ import { negotiateFormat } from './negotiate/accept.ts'
7
+ import { markdownHeaders, htmlHeaders, estimateTokens } from './negotiate/headers.ts'
8
+ import { renderPage, render404 } from './render/page-shell.ts'
9
+ import { generateLlmsTxt } from './discovery/llmstxt.ts'
10
+
11
+ export interface HandlerOptions {
12
+ source: ContentSource
13
+ renderer: MarkdownRenderer
14
+ config: MkdnSiteConfig
15
+ }
16
+
17
+ /**
18
+ * Create a portable fetch handler for mkdnsite.
19
+ *
20
+ * This handler conforms to the Web API fetch(request): Response
21
+ * pattern, making it compatible with:
22
+ * - Bun.serve()
23
+ * - Cloudflare Workers
24
+ * - Vercel Edge Functions
25
+ * - Netlify Edge Functions
26
+ * - Deno.serve()
27
+ */
28
+ export function createHandler (opts: HandlerOptions): (request: Request) => Promise<Response> {
29
+ const { source, renderer, config } = opts
30
+
31
+ let llmsTxtCache: string | null = null
32
+
33
+ return async function handler (request: Request): Promise<Response> {
34
+ const url = new URL(request.url)
35
+ const pathname = decodeURIComponent(url.pathname)
36
+
37
+ // ---- Special routes ----
38
+
39
+ if (pathname === '/_health') {
40
+ return new Response('ok', { status: 200 })
41
+ }
42
+
43
+ if (pathname === '/llms.txt' && config.llmsTxt.enabled) {
44
+ if (llmsTxtCache == null) {
45
+ llmsTxtCache = await generateLlmsTxt(source, config)
46
+ }
47
+ return new Response(llmsTxtCache, {
48
+ status: 200,
49
+ headers: {
50
+ 'Content-Type': 'text/markdown; charset=utf-8',
51
+ 'Cache-Control': 'public, max-age=3600'
52
+ }
53
+ })
54
+ }
55
+
56
+ if (pathname === '/_refresh' && request.method === 'POST') {
57
+ await source.refresh()
58
+ llmsTxtCache = null
59
+ return new Response('cache cleared', { status: 200 })
60
+ }
61
+
62
+ // ---- Static files passthrough ----
63
+ if (config.staticDir != null && hasStaticExtension(pathname)) {
64
+ return await serveStatic(pathname, config.staticDir)
65
+ }
66
+
67
+ // ---- Content negotiation + page serving ----
68
+
69
+ let slug = pathname
70
+ let forceMarkdown = false
71
+
72
+ if (slug.endsWith('.md')) {
73
+ slug = slug.slice(0, -3)
74
+ forceMarkdown = true
75
+ }
76
+
77
+ if (slug !== '/' && slug.endsWith('/')) {
78
+ slug = slug.slice(0, -1)
79
+ }
80
+
81
+ const page = await source.getPage(slug)
82
+
83
+ if (page == null) {
84
+ const format = negotiateFormat(request.headers.get('Accept'))
85
+ if (format === 'markdown') {
86
+ return new Response(
87
+ '# 404 — Page Not Found\n\nThe requested page does not exist.\n',
88
+ {
89
+ status: 404,
90
+ headers: { 'Content-Type': 'text/markdown; charset=utf-8' }
91
+ }
92
+ )
93
+ }
94
+ return new Response(render404(config), {
95
+ status: 404,
96
+ headers: htmlHeaders()
97
+ })
98
+ }
99
+
100
+ const format = forceMarkdown
101
+ ? 'markdown'
102
+ : negotiateFormat(request.headers.get('Accept'))
103
+
104
+ if (format === 'markdown') {
105
+ const tokens = config.negotiation.includeTokenCount
106
+ ? estimateTokens(page.body)
107
+ : null
108
+
109
+ return new Response(page.body, {
110
+ status: 200,
111
+ headers: markdownHeaders(tokens, config.negotiation.contentSignals)
112
+ })
113
+ }
114
+
115
+ // ---- Render HTML via React SSR ----
116
+ const renderedHtml = renderer.renderToHtml(page.body, config.theme.components)
117
+ const nav = config.theme.showNav
118
+ ? await source.getNavTree()
119
+ : undefined
120
+
121
+ const fullPage = renderPage({
122
+ renderedContent: renderedHtml,
123
+ meta: page.meta,
124
+ config,
125
+ nav,
126
+ currentSlug: page.slug
127
+ })
128
+
129
+ return new Response(fullPage, {
130
+ status: 200,
131
+ headers: htmlHeaders()
132
+ })
133
+ }
134
+ }
135
+
136
+ const STATIC_EXTENSIONS = new Set([
137
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico',
138
+ '.mp4', '.webm', '.ogg', '.mp3', '.wav',
139
+ '.pdf', '.zip', '.tar', '.gz',
140
+ '.css', '.js', '.json', '.xml',
141
+ '.woff', '.woff2', '.ttf', '.eot'
142
+ ])
143
+
144
+ function hasStaticExtension (pathname: string): boolean {
145
+ const ext = pathname.slice(pathname.lastIndexOf('.')).toLowerCase()
146
+ return STATIC_EXTENSIONS.has(ext)
147
+ }
148
+
149
+ const MIME_TYPES: Record<string, string> = {
150
+ '.html': 'text/html',
151
+ '.css': 'text/css',
152
+ '.js': 'application/javascript',
153
+ '.json': 'application/json',
154
+ '.xml': 'application/xml',
155
+ '.png': 'image/png',
156
+ '.jpg': 'image/jpeg',
157
+ '.jpeg': 'image/jpeg',
158
+ '.gif': 'image/gif',
159
+ '.webp': 'image/webp',
160
+ '.svg': 'image/svg+xml',
161
+ '.ico': 'image/x-icon',
162
+ '.mp4': 'video/mp4',
163
+ '.webm': 'video/webm',
164
+ '.ogg': 'audio/ogg',
165
+ '.mp3': 'audio/mpeg',
166
+ '.wav': 'audio/wav',
167
+ '.pdf': 'application/pdf',
168
+ '.zip': 'application/zip',
169
+ '.woff': 'font/woff',
170
+ '.woff2': 'font/woff2',
171
+ '.ttf': 'font/ttf',
172
+ '.eot': 'application/vnd.ms-fontobject'
173
+ }
174
+
175
+ async function serveStatic (pathname: string, staticDir: string): Promise<Response> {
176
+ try {
177
+ const filePath = `${staticDir}${pathname}`
178
+ const data = await readFile(filePath)
179
+ const ext = extname(pathname).toLowerCase()
180
+ const contentType = MIME_TYPES[ext] ?? 'application/octet-stream'
181
+ return new Response(data, {
182
+ status: 200,
183
+ headers: { 'Content-Type': contentType }
184
+ })
185
+ } catch {
186
+ return new Response('Not Found', { status: 404 })
187
+ }
188
+ }
package/src/index.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * mkdnsite — Markdown for the web.
3
+ * HTML for humans, Markdown for agents.
4
+ *
5
+ * @module mkdnsite
6
+ */
7
+
8
+ // Core handler
9
+ export { createHandler } from './handler.ts'
10
+ export type { HandlerOptions } from './handler.ts'
11
+
12
+ // Config
13
+ export { resolveConfig, DEFAULT_CONFIG } from './config/defaults.ts'
14
+ export type {
15
+ MkdnSiteConfig,
16
+ ThemeConfig,
17
+ NegotiationConfig,
18
+ ClientConfig,
19
+ ComponentOverrides,
20
+ RendererEngine,
21
+ HeadingProps,
22
+ LinkProps,
23
+ ImageProps,
24
+ CodeBlockProps,
25
+ InlineCodeProps
26
+ } from './config/schema.ts'
27
+
28
+ // Content sources
29
+ export { FilesystemSource } from './content/filesystem.ts'
30
+ export { GitHubSource } from './content/github.ts'
31
+ export type {
32
+ ContentSource,
33
+ ContentPage,
34
+ NavNode,
35
+ MarkdownMeta,
36
+ GitHubSourceConfig
37
+ } from './content/types.ts'
38
+ export { parseFrontmatter } from './content/frontmatter.ts'
39
+
40
+ // Rendering
41
+ export { createRenderer } from './render/types.ts'
42
+ export type { MarkdownRenderer } from './render/types.ts'
43
+ export { PortableRenderer } from './render/portable.ts'
44
+ export { buildComponents } from './render/components/index.ts'
45
+
46
+ // Content negotiation
47
+ export { negotiateFormat } from './negotiate/accept.ts'
48
+ export type { ResponseFormat } from './negotiate/accept.ts'
49
+ export { markdownHeaders, htmlHeaders, estimateTokens } from './negotiate/headers.ts'
50
+
51
+ // Discovery
52
+ export { generateLlmsTxt } from './discovery/llmstxt.ts'
53
+
54
+ // Adapters
55
+ export type { DeploymentAdapter } from './adapters/types.ts'
56
+ export { detectRuntime } from './adapters/types.ts'
57
+ export { LocalAdapter } from './adapters/local.ts'
@@ -0,0 +1,72 @@
1
+ export type ResponseFormat = 'html' | 'markdown'
2
+
3
+ /**
4
+ * Parse the Accept header and determine the preferred response format.
5
+ */
6
+ export function negotiateFormat (acceptHeader: string | null): ResponseFormat {
7
+ if (acceptHeader == null) return 'html'
8
+
9
+ const types = parseAcceptHeader(acceptHeader)
10
+
11
+ let markdownQ = -1
12
+ let htmlQ = -1
13
+
14
+ for (const { type, quality } of types) {
15
+ if (
16
+ type === 'text/markdown' ||
17
+ type === 'text/x-markdown' ||
18
+ type === 'application/markdown'
19
+ ) {
20
+ markdownQ = Math.max(markdownQ, quality)
21
+ } else if (type === 'text/html') {
22
+ htmlQ = Math.max(htmlQ, quality)
23
+ } else if (type === '*/*') {
24
+ if (htmlQ < 0) htmlQ = quality * 0.01
25
+ }
26
+ }
27
+
28
+ if (markdownQ > htmlQ && markdownQ > 0) {
29
+ return 'markdown'
30
+ }
31
+
32
+ if (markdownQ === htmlQ && markdownQ > 0) {
33
+ const markdownIdx = types.findIndex(t =>
34
+ t.type === 'text/markdown' ||
35
+ t.type === 'text/x-markdown' ||
36
+ t.type === 'application/markdown'
37
+ )
38
+ const htmlIdx = types.findIndex(t => t.type === 'text/html')
39
+
40
+ if (markdownIdx >= 0 && (htmlIdx < 0 || markdownIdx < htmlIdx)) {
41
+ return 'markdown'
42
+ }
43
+ }
44
+
45
+ return 'html'
46
+ }
47
+
48
+ interface ParsedMediaType {
49
+ type: string
50
+ quality: number
51
+ }
52
+
53
+ function parseAcceptHeader (header: string): ParsedMediaType[] {
54
+ return header
55
+ .split(',')
56
+ .map(part => {
57
+ const trimmed = part.trim()
58
+ const [type, ...params] = trimmed.split(';').map(s => s.trim())
59
+
60
+ let quality = 1.0
61
+ for (const param of params) {
62
+ const match = param.match(/^q=([\d.]+)$/)
63
+ if (match != null) {
64
+ quality = parseFloat(match[1])
65
+ break
66
+ }
67
+ }
68
+
69
+ return { type: type.toLowerCase(), quality }
70
+ })
71
+ .sort((a, b) => b.quality - a.quality)
72
+ }
@@ -0,0 +1,56 @@
1
+ import type { ContentSignals } from '../config/schema.ts'
2
+
3
+ export function markdownHeaders (
4
+ tokenCount: number | null,
5
+ signals?: ContentSignals
6
+ ): Record<string, string> {
7
+ const headers: Record<string, string> = {
8
+ 'Content-Type': 'text/markdown; charset=utf-8',
9
+ Vary: 'Accept',
10
+ 'Cache-Control': 'public, max-age=300'
11
+ }
12
+
13
+ if (tokenCount != null) {
14
+ headers['x-markdown-tokens'] = String(tokenCount)
15
+ }
16
+
17
+ if (signals != null) {
18
+ 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}`)
22
+ if (parts.length > 0) {
23
+ headers['Content-Signal'] = parts.join(', ')
24
+ }
25
+ }
26
+
27
+ return headers
28
+ }
29
+
30
+ export function htmlHeaders (): Record<string, string> {
31
+ return {
32
+ 'Content-Type': 'text/html; charset=utf-8',
33
+ Vary: 'Accept',
34
+ 'Cache-Control': 'public, max-age=300'
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Rough token count estimation for x-markdown-tokens header.
40
+ * Approximation (~4 chars per token for English text).
41
+ */
42
+ export function estimateTokens (text: string): number {
43
+ if (text === '') return 0
44
+
45
+ const words = text.split(/\s+/).filter(Boolean)
46
+ let tokens = 0
47
+
48
+ for (const word of words) {
49
+ tokens += word.length <= 4 ? 1 : Math.ceil(word.length / 3.5)
50
+ }
51
+
52
+ const syntaxOverhead = (text.match(/[#*_`[\](){}|>~-]{2,}/g) ?? []).length
53
+ tokens += syntaxOverhead
54
+
55
+ return Math.max(1, Math.round(tokens))
56
+ }
@@ -0,0 +1,54 @@
1
+ import { renderToString } from 'react-dom/server'
2
+ import type { ReactElement } from 'react'
3
+ import type { ComponentOverrides } from '../config/schema.ts'
4
+ import type { MarkdownRenderer } from './types.ts'
5
+ import { buildComponents } from './components/index.ts'
6
+
7
+ /**
8
+ * High-performance renderer using Bun.markdown.react().
9
+ *
10
+ * Only works in Bun runtime. Uses Bun's built-in Zig-based
11
+ * markdown parser which is significantly faster than JavaScript
12
+ * alternatives.
13
+ *
14
+ * Falls back gracefully if Bun.markdown is not available.
15
+ */
16
+ export class BunNativeRenderer implements MarkdownRenderer {
17
+ readonly engine = 'bun-native' as const
18
+
19
+ renderToElement (markdown: string, overrides?: ComponentOverrides): ReactElement {
20
+ const components = buildComponents(overrides)
21
+
22
+ // Map our ComponentOverrides to Bun.markdown.react() component shape
23
+ // Bun.markdown.react() accepts overrides keyed by HTML tag name
24
+ const bunComponents: Record<string, unknown> = {}
25
+
26
+ if (components.h1 != null) bunComponents.h1 = components.h1
27
+ if (components.h2 != null) bunComponents.h2 = components.h2
28
+ if (components.h3 != null) bunComponents.h3 = components.h3
29
+ if (components.h4 != null) bunComponents.h4 = components.h4
30
+ if (components.h5 != null) bunComponents.h5 = components.h5
31
+ if (components.h6 != null) bunComponents.h6 = components.h6
32
+ if (components.a != null) bunComponents.a = components.a
33
+ if (components.img != null) bunComponents.img = components.img
34
+ if (components.pre != null) bunComponents.pre = components.pre
35
+ if (components.code != null) bunComponents.code = components.code
36
+ if (components.blockquote != null) bunComponents.blockquote = components.blockquote
37
+ if (components.table != null) bunComponents.table = components.table
38
+ if (components.p != null) bunComponents.p = components.p
39
+
40
+ return Bun.markdown.react(markdown, bunComponents, {
41
+ tables: true,
42
+ strikethrough: true,
43
+ tasklists: true,
44
+ autolinks: true,
45
+ headings: { ids: true },
46
+ tagFilter: true
47
+ })
48
+ }
49
+
50
+ renderToHtml (markdown: string, overrides?: ComponentOverrides): string {
51
+ const element = this.renderToElement(markdown, overrides)
52
+ return renderToString(element)
53
+ }
54
+ }
@@ -0,0 +1,149 @@
1
+ import React from 'react'
2
+ import { Link as LinkIcon } from 'lucide-react'
3
+ import type {
4
+ HeadingProps,
5
+ LinkProps,
6
+ ImageProps,
7
+ CodeBlockProps,
8
+ InlineCodeProps,
9
+ ComponentOverrides
10
+ } from '../../config/schema.ts'
11
+
12
+ const ICON_SIZES: Record<number, number> = {
13
+ 1: 20,
14
+ 2: 18,
15
+ 3: 16
16
+ }
17
+
18
+ /**
19
+ * Default heading component with anchor link support.
20
+ * h1-h3 show a lucide link icon on hover; h4-h6 show a # symbol.
21
+ */
22
+ function Heading ({ children, id, level }: HeadingProps): React.ReactElement {
23
+ const Tag = `h${level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
24
+ const className = `mkdn-heading mkdn-h${level}`
25
+
26
+ if (id != null) {
27
+ const iconSize = ICON_SIZES[level]
28
+ const anchorChild = iconSize != null
29
+ ? React.createElement(LinkIcon, { size: iconSize, 'aria-hidden': true })
30
+ : '#'
31
+
32
+ return React.createElement(Tag, { id, className },
33
+ React.createElement('a', {
34
+ href: `#${id}`,
35
+ className: 'mkdn-heading-anchor',
36
+ 'aria-hidden': 'true'
37
+ }, anchorChild),
38
+ ' ',
39
+ children
40
+ )
41
+ }
42
+
43
+ return React.createElement(Tag, { className }, children)
44
+ }
45
+
46
+ /**
47
+ * Default link component with external link detection.
48
+ */
49
+ function Link ({ children, href, title }: LinkProps): React.ReactElement {
50
+ const isExternal = href?.startsWith('http://') === true || href?.startsWith('https://') === true
51
+ const props: Record<string, unknown> = {
52
+ href,
53
+ title,
54
+ className: 'mkdn-link'
55
+ }
56
+
57
+ if (isExternal) {
58
+ props.target = '_blank'
59
+ props.rel = 'noopener noreferrer'
60
+ }
61
+
62
+ return React.createElement('a', props, children)
63
+ }
64
+
65
+ /**
66
+ * Default image component with lazy loading.
67
+ */
68
+ function Image ({ src, alt, title }: ImageProps): React.ReactElement {
69
+ return React.createElement('img', {
70
+ src,
71
+ alt: alt ?? '',
72
+ title,
73
+ loading: 'lazy',
74
+ className: 'mkdn-image'
75
+ })
76
+ }
77
+
78
+ /**
79
+ * Default code block component.
80
+ * Syntax highlighting is applied server-side by the renderer using Shiki.
81
+ * This component wraps the highlighted output.
82
+ */
83
+ function CodeBlock ({ children, language }: CodeBlockProps): React.ReactElement {
84
+ return React.createElement('div', { className: 'mkdn-code-block', 'data-language': language },
85
+ React.createElement('pre', null,
86
+ React.createElement('code', {
87
+ className: language != null ? `language-${language}` : undefined
88
+ }, children)
89
+ )
90
+ )
91
+ }
92
+
93
+ /**
94
+ * Default inline code component.
95
+ */
96
+ function InlineCode ({ children }: InlineCodeProps): React.ReactElement {
97
+ return React.createElement('code', { className: 'mkdn-inline-code' }, children)
98
+ }
99
+
100
+ /**
101
+ * Default blockquote component.
102
+ */
103
+ function Blockquote ({ children }: { children?: React.ReactNode }): React.ReactElement {
104
+ return React.createElement('blockquote', { className: 'mkdn-blockquote' }, children)
105
+ }
106
+
107
+ /**
108
+ * Default table component with responsive wrapper.
109
+ */
110
+ function Table ({ children }: { children?: React.ReactNode }): React.ReactElement {
111
+ return React.createElement('div', { className: 'mkdn-table-wrapper' },
112
+ React.createElement('table', { className: 'mkdn-table' }, children)
113
+ )
114
+ }
115
+
116
+ /**
117
+ * Build the full component overrides map, merging user overrides
118
+ * with defaults. User overrides take precedence.
119
+ */
120
+ export function buildComponents (userOverrides?: ComponentOverrides): ComponentOverrides {
121
+ const defaults: ComponentOverrides = {
122
+ h1: (props) => Heading({ ...props, level: 1 }),
123
+ h2: (props) => Heading({ ...props, level: 2 }),
124
+ h3: (props) => Heading({ ...props, level: 3 }),
125
+ h4: (props) => Heading({ ...props, level: 4 }),
126
+ h5: (props) => Heading({ ...props, level: 5 }),
127
+ h6: (props) => Heading({ ...props, level: 6 }),
128
+ a: Link,
129
+ img: Image,
130
+ pre: CodeBlock,
131
+ code: InlineCode,
132
+ blockquote: Blockquote,
133
+ table: Table
134
+ }
135
+
136
+ if (userOverrides == null) return defaults
137
+
138
+ return { ...defaults, ...userOverrides }
139
+ }
140
+
141
+ export {
142
+ Heading,
143
+ Link,
144
+ Image,
145
+ CodeBlock,
146
+ InlineCode,
147
+ Blockquote,
148
+ Table
149
+ }