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/LICENSE +21 -0
- package/README.md +211 -0
- package/package.json +72 -0
- package/src/adapters/cloudflare.ts +85 -0
- package/src/adapters/fly.ts +22 -0
- package/src/adapters/local.ts +153 -0
- package/src/adapters/netlify.ts +17 -0
- package/src/adapters/types.ts +54 -0
- package/src/adapters/vercel.ts +48 -0
- package/src/cli.ts +140 -0
- package/src/client/scripts.ts +106 -0
- package/src/config/defaults.ts +68 -0
- package/src/config/schema.ts +192 -0
- package/src/content/filesystem.ts +210 -0
- package/src/content/frontmatter.ts +66 -0
- package/src/content/github.ts +211 -0
- package/src/content/types.ts +86 -0
- package/src/discovery/llmstxt.ts +70 -0
- package/src/handler.ts +188 -0
- package/src/index.ts +57 -0
- package/src/negotiate/accept.ts +72 -0
- package/src/negotiate/headers.ts +56 -0
- package/src/render/bun-native.ts +54 -0
- package/src/render/components/index.ts +149 -0
- package/src/render/page-shell.ts +121 -0
- package/src/render/portable.ts +222 -0
- package/src/render/types.ts +74 -0
- package/src/theme/prose-css.ts +377 -0
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
|
+
}
|