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.
@@ -0,0 +1,121 @@
1
+ import type { MkdnSiteConfig } from '../config/schema.ts'
2
+ import type { MarkdownMeta, NavNode } from '../content/types.ts'
3
+ import { THEME_CSS } from '../theme/prose-css.ts'
4
+ import { CLIENT_SCRIPTS } from '../client/scripts.ts'
5
+
6
+ interface PageShellProps {
7
+ renderedContent: string
8
+ meta: MarkdownMeta
9
+ config: MkdnSiteConfig
10
+ nav?: NavNode
11
+ currentSlug: string
12
+ }
13
+
14
+ /**
15
+ * Render a full HTML page wrapping the markdown content.
16
+ * This is pure SSR — no client-side React hydration required.
17
+ */
18
+ export function renderPage (props: PageShellProps): string {
19
+ const { renderedContent, meta, config, nav, currentSlug } = props
20
+
21
+ const title = meta.title != null
22
+ ? `${meta.title} — ${config.site.title}`
23
+ : config.site.title
24
+ const description = meta.description ?? config.site.description ?? ''
25
+ const lang = config.site.lang ?? 'en'
26
+
27
+ const navHtml = (config.theme.showNav && nav != null)
28
+ ? renderNav(nav, currentSlug)
29
+ : ''
30
+
31
+ const clientScripts = config.client.enabled
32
+ ? CLIENT_SCRIPTS(config.client)
33
+ : ''
34
+
35
+ const themeToggleEnabled = config.client.enabled && config.client.themeToggle
36
+ const colorScheme = config.theme.colorScheme ?? 'system'
37
+
38
+ // Blocking script to set data-theme before paint (prevents FOUC)
39
+ const themeInitScript = colorScheme !== 'system'
40
+ // Fixed color scheme: always use the configured value
41
+ ? `<script>document.documentElement.setAttribute("data-theme","${colorScheme}")</script>`
42
+ : themeToggleEnabled
43
+ // System default + toggle: check localStorage first, then system preference
44
+ ? '<script>(function(){var t=localStorage.getItem("mkdn-theme");if(t==="dark"||t==="light"){document.documentElement.setAttribute("data-theme",t)}else{document.documentElement.setAttribute("data-theme",window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light")}})()</script>'
45
+ // System default, no toggle: just respect system preference
46
+ : '<script>(function(){document.documentElement.setAttribute("data-theme",window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light")})()</script>'
47
+
48
+ const themeToggleHtml = themeToggleEnabled
49
+ ? `<button class="mkdn-theme-toggle" type="button" aria-label="Toggle theme">
50
+ <svg class="mkdn-icon-sun" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>
51
+ <svg class="mkdn-icon-moon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>
52
+ </button>`
53
+ : ''
54
+
55
+ return `<!DOCTYPE html>
56
+ <html lang="${esc(lang)}">
57
+ <head>
58
+ <meta charset="utf-8">
59
+ <meta name="viewport" content="width=device-width, initial-scale=1">
60
+ <title>${esc(title)}</title>
61
+ ${description !== '' ? `<meta name="description" content="${esc(description)}">` : ''}
62
+ <meta name="generator" content="mkdnsite">
63
+ ${config.client.math ? '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16/dist/katex.min.css" crossorigin="anonymous">' : ''}
64
+ <style>${THEME_CSS}</style>
65
+ ${themeInitScript}
66
+ </head>
67
+ <body>
68
+ ${themeToggleHtml}
69
+ <div class="mkdn-layout">
70
+ ${navHtml !== '' ? `<nav class="mkdn-nav" aria-label="Site navigation">${navHtml}</nav>` : ''}
71
+ <main class="mkdn-main">
72
+ <article class="mkdn-article mkdn-prose">
73
+ ${renderedContent}
74
+ </article>
75
+ <footer class="mkdn-footer">
76
+ <p>Powered by <a href="https://mkdn.site">mkdnsite</a></p>
77
+ </footer>
78
+ </main>
79
+ </div>
80
+ ${clientScripts}
81
+ </body>
82
+ </html>`
83
+ }
84
+
85
+ export function render404 (config: MkdnSiteConfig): string {
86
+ return renderPage({
87
+ renderedContent: '<h1>404 — Page Not Found</h1><p>The page you\'re looking for doesn\'t exist.</p><p><a href="/">← Back to home</a></p>',
88
+ meta: { title: 'Not Found' },
89
+ config,
90
+ currentSlug: ''
91
+ })
92
+ }
93
+
94
+ function renderNav (node: NavNode, currentSlug: string, depth = 0): string {
95
+ if (depth === 0) {
96
+ const items = node.children.map(c => renderNav(c, currentSlug, 1)).join('\n')
97
+ return `<div class="mkdn-nav-inner"><ul class="mkdn-nav-list">${items}</ul></div>`
98
+ }
99
+
100
+ const isActive = currentSlug === node.slug
101
+
102
+ if (node.isSection && node.children.length > 0) {
103
+ const childItems = node.children
104
+ .map(c => renderNav(c, currentSlug, depth + 1))
105
+ .join('\n')
106
+ return `<li class="mkdn-nav-section">
107
+ <span class="mkdn-nav-section-title">${esc(node.title)}</span>
108
+ <ul>${childItems}</ul>
109
+ </li>`
110
+ }
111
+
112
+ return `<li${isActive ? ' class="active"' : ''}><a href="${node.slug}"${isActive ? ' aria-current="page"' : ''}>${esc(node.title)}</a></li>`
113
+ }
114
+
115
+ function esc (str: string): string {
116
+ return str
117
+ .replace(/&/g, '&amp;')
118
+ .replace(/</g, '&lt;')
119
+ .replace(/>/g, '&gt;')
120
+ .replace(/"/g, '&quot;')
121
+ }
@@ -0,0 +1,222 @@
1
+ import React from 'react'
2
+ import { renderToString } from 'react-dom/server'
3
+ import Markdown from 'react-markdown'
4
+ import remarkGfm from 'remark-gfm'
5
+ import remarkMath from 'remark-math'
6
+ import { remarkAlert } from 'remark-github-blockquote-alert'
7
+ import rehypeSlug from 'rehype-slug'
8
+ import rehypeKatex from 'rehype-katex'
9
+ import rehypeRaw from 'rehype-raw'
10
+ import rehypeSanitize from 'rehype-sanitize'
11
+ import { defaultSchema } from 'hast-util-sanitize'
12
+ import type { ReactElement } from 'react'
13
+ import type { Highlighter } from 'shiki'
14
+ import type { ComponentOverrides } from '../config/schema.ts'
15
+ import type { MarkdownRenderer } from './types.ts'
16
+ import { buildComponents } from './components/index.ts'
17
+
18
+ /**
19
+ * Extended sanitization schema.
20
+ * Starts from the GitHub-style default and adds support for:
21
+ * - className on div, span, p, svg, path (alerts, KaTeX)
22
+ * - SVG elements and attributes (alert icons)
23
+ * - KaTeX attributes (style, aria)
24
+ */
25
+ const sanitizeSchema = {
26
+ ...defaultSchema,
27
+ tagNames: [
28
+ ...(defaultSchema.tagNames ?? []),
29
+ 'svg', 'path', 'span'
30
+ ],
31
+ attributes: {
32
+ ...defaultSchema.attributes,
33
+ div: [...(defaultSchema.attributes?.div ?? []), 'className', 'dir'],
34
+ span: [...(defaultSchema.attributes?.span ?? []), 'className', 'style'],
35
+ p: [...(defaultSchema.attributes?.p ?? []), 'className'],
36
+ svg: ['viewBox', 'width', 'height', 'ariaHidden', 'fill', 'xmlns', 'className'],
37
+ path: ['d', 'fill', 'fillRule']
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Portable markdown renderer using react-markdown.
43
+ *
44
+ * Works in any JavaScript runtime (Bun, Node, CF Workers, Deno).
45
+ * Uses remark-gfm for GitHub-Flavored Markdown support and
46
+ * rehype plugins for heading IDs and anchor links.
47
+ */
48
+ export class PortableRenderer implements MarkdownRenderer {
49
+ readonly engine = 'portable' as const
50
+ private readonly highlighter?: Highlighter
51
+ private readonly syntaxTheme?: string
52
+ private readonly syntaxThemeDark?: string
53
+ private readonly math: boolean
54
+
55
+ constructor (highlighter?: Highlighter, syntaxTheme?: string, syntaxThemeDark?: string, math?: boolean) {
56
+ this.highlighter = highlighter
57
+ this.syntaxTheme = syntaxTheme
58
+ this.syntaxThemeDark = syntaxThemeDark
59
+ this.math = math !== false
60
+ }
61
+
62
+ renderToElement (markdown: string, overrides?: ComponentOverrides): ReactElement {
63
+ const components = buildComponents(overrides)
64
+
65
+ // Map our ComponentOverrides to react-markdown's expected component shape
66
+ const rmComponents = mapToReactMarkdownComponents(
67
+ components,
68
+ this.highlighter,
69
+ this.syntaxTheme,
70
+ this.syntaxThemeDark
71
+ )
72
+
73
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
+ const remarkPlugins: any[] = [remarkGfm, remarkAlert]
75
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
76
+ const rehypePlugins: any[] = [rehypeSlug, rehypeRaw, [rehypeSanitize, sanitizeSchema]]
77
+
78
+ if (this.math) {
79
+ remarkPlugins.push(remarkMath)
80
+ // rehype-katex must run before rehype-raw
81
+ rehypePlugins.splice(1, 0, rehypeKatex)
82
+ }
83
+
84
+ return React.createElement(Markdown, {
85
+ remarkPlugins,
86
+ rehypePlugins,
87
+ components: rmComponents
88
+ }, markdown)
89
+ }
90
+
91
+ renderToHtml (markdown: string, overrides?: ComponentOverrides): string {
92
+ const element = this.renderToElement(markdown, overrides)
93
+ return renderToString(element)
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Extract raw text from React children recursively.
99
+ * Needed to get the source code string from react-markdown's code element.
100
+ */
101
+ function extractText (children: unknown): string {
102
+ if (typeof children === 'string') return children
103
+ if (typeof children === 'number') return String(children)
104
+ if (children == null || typeof children === 'boolean') return ''
105
+ if (Array.isArray(children)) return children.map(extractText).join('')
106
+ if (typeof children === 'object' && 'props' in (children as Record<string, unknown>)) {
107
+ const props = (children as Record<string, unknown>).props as Record<string, unknown>
108
+ return extractText(props.children)
109
+ }
110
+ return ''
111
+ }
112
+
113
+ /**
114
+ * Map our ComponentOverrides type to react-markdown's Components type.
115
+ * react-markdown passes slightly different props, so we adapt here.
116
+ */
117
+ function mapToReactMarkdownComponents (
118
+ overrides: ComponentOverrides,
119
+ highlighter?: Highlighter,
120
+ syntaxTheme?: string,
121
+ syntaxThemeDark?: string
122
+ ): Record<string, React.ComponentType<Record<string, unknown>>> {
123
+ const mapped: Record<string, React.ComponentType<Record<string, unknown>>> = {}
124
+
125
+ if (overrides.h1 != null) {
126
+ const H1 = overrides.h1
127
+ mapped.h1 = (props: Record<string, unknown>) =>
128
+ React.createElement(H1, {
129
+ id: props.id as string | undefined,
130
+ level: 1
131
+ }, props.children as React.ReactNode)
132
+ }
133
+ if (overrides.h2 != null) {
134
+ const H2 = overrides.h2
135
+ mapped.h2 = (props: Record<string, unknown>) =>
136
+ React.createElement(H2, {
137
+ id: props.id as string | undefined,
138
+ level: 2
139
+ }, props.children as React.ReactNode)
140
+ }
141
+ if (overrides.h3 != null) {
142
+ const H3 = overrides.h3
143
+ mapped.h3 = (props: Record<string, unknown>) =>
144
+ React.createElement(H3, {
145
+ id: props.id as string | undefined,
146
+ level: 3
147
+ }, props.children as React.ReactNode)
148
+ }
149
+
150
+ // Direct pass-through for components with matching prop shapes
151
+ const directMappings: Array<keyof ComponentOverrides> = [
152
+ 'p', 'a', 'img', 'blockquote', 'table', 'thead', 'tbody',
153
+ 'tr', 'th', 'td', 'ul', 'ol', 'li', 'hr', 'code'
154
+ ]
155
+
156
+ for (const key of directMappings) {
157
+ if (overrides[key] != null) {
158
+ mapped[key] = overrides[key] as React.ComponentType<Record<string, unknown>>
159
+ }
160
+ }
161
+
162
+ // Code block handling: use shiki when available, otherwise fall back to default
163
+ if (highlighter != null && syntaxTheme != null) {
164
+ const loadedLangs = new Set(highlighter.getLoadedLanguages())
165
+ const darkTheme = syntaxThemeDark
166
+ const lightTheme = syntaxTheme
167
+
168
+ mapped.pre = (props: Record<string, unknown>) => {
169
+ const children = props.children as React.ReactElement
170
+ const codeProps = children?.props as Record<string, unknown> | undefined
171
+ const className = (codeProps?.className as string) ?? ''
172
+ const langMatch = className.match(/language-(\S+)/)
173
+ const language = langMatch?.[1]
174
+
175
+ // Extract raw code text from React children
176
+ const raw = extractText(codeProps?.children).replace(/\n$/, '')
177
+
178
+ // Only use shiki if the language is loaded
179
+ if (language != null && loadedLangs.has(language)) {
180
+ const themeOpts = darkTheme != null
181
+ ? { themes: { light: lightTheme, dark: darkTheme }, defaultColor: false as const }
182
+ : { theme: lightTheme }
183
+
184
+ const html = highlighter.codeToHtml(raw, { lang: language, ...themeOpts })
185
+
186
+ return React.createElement('div', {
187
+ className: 'mkdn-code-block',
188
+ 'data-language': language,
189
+ dangerouslySetInnerHTML: { __html: html }
190
+ })
191
+ }
192
+
193
+ // Fall back to default pre/code rendering
194
+ if (overrides.pre != null) {
195
+ const Pre = overrides.pre
196
+ return React.createElement(Pre, {
197
+ language: language != null && language !== '' ? language : undefined
198
+ }, codeProps?.children as React.ReactNode)
199
+ }
200
+
201
+ return React.createElement('pre', null,
202
+ React.createElement('code', {
203
+ className: language != null ? `language-${language}` : undefined
204
+ }, codeProps?.children as React.ReactNode)
205
+ )
206
+ }
207
+ } else if (overrides.pre != null) {
208
+ const Pre = overrides.pre
209
+ mapped.pre = (props: Record<string, unknown>) => {
210
+ const children = props.children as React.ReactElement
211
+ const codeProps = children?.props as Record<string, unknown> | undefined
212
+ const className = (codeProps?.className as string) ?? ''
213
+ const language = className.replace('language-', '')
214
+
215
+ return React.createElement(Pre, {
216
+ language: language !== '' ? language : undefined
217
+ }, codeProps?.children as React.ReactNode)
218
+ }
219
+ }
220
+
221
+ return mapped
222
+ }
@@ -0,0 +1,74 @@
1
+ import type { ReactElement } from 'react'
2
+ import type { Highlighter } from 'shiki'
3
+ import type { ComponentOverrides, RendererEngine } from '../config/schema.ts'
4
+
5
+ /**
6
+ * Markdown renderer interface.
7
+ *
8
+ * Implementations convert markdown strings into React elements
9
+ * that can be rendered to HTML via renderToString().
10
+ *
11
+ * The component overrides allow users to customize how each
12
+ * markdown element is rendered (headings, links, code blocks, etc.).
13
+ */
14
+ export interface MarkdownRenderer {
15
+ /** Which engine is actually in use */
16
+ readonly engine: RendererEngine
17
+
18
+ /**
19
+ * Render a markdown string to a React element tree.
20
+ * The returned element can be passed to renderToString().
21
+ */
22
+ renderToElement: (markdown: string, overrides?: ComponentOverrides) => ReactElement
23
+
24
+ /**
25
+ * Render a markdown string directly to an HTML string.
26
+ * Convenience method that calls renderToElement + renderToString.
27
+ */
28
+ renderToHtml: (markdown: string, overrides?: ComponentOverrides) => string
29
+ }
30
+
31
+ export interface RendererOptions {
32
+ engine?: RendererEngine
33
+ syntaxTheme?: string
34
+ syntaxThemeDark?: string
35
+ math?: boolean
36
+ }
37
+
38
+ /**
39
+ * Create the appropriate renderer for the current runtime and config.
40
+ */
41
+ export async function createRenderer (engineOrOpts: RendererEngine | RendererOptions = 'portable'): Promise<MarkdownRenderer> {
42
+ const opts: RendererOptions = typeof engineOrOpts === 'string'
43
+ ? { engine: engineOrOpts }
44
+ : engineOrOpts
45
+ const engine = opts.engine ?? 'portable'
46
+
47
+ // Create shiki highlighter if syntax themes are provided
48
+ let highlighter: Highlighter | undefined
49
+ if (opts.syntaxTheme != null) {
50
+ const { createHighlighter } = await import('shiki')
51
+ const themes = [opts.syntaxTheme]
52
+ if (opts.syntaxThemeDark != null) themes.push(opts.syntaxThemeDark)
53
+ const langs = [
54
+ 'javascript', 'typescript', 'jsx', 'tsx',
55
+ 'html', 'css', 'json', 'jsonc',
56
+ 'bash', 'shell', 'sh', 'zsh',
57
+ 'python', 'ruby', 'go', 'rust', 'java', 'c', 'cpp',
58
+ 'yaml', 'toml', 'markdown', 'sql', 'graphql',
59
+ 'diff', 'xml', 'docker', 'nginx', 'ini'
60
+ ]
61
+ highlighter = await createHighlighter({ themes, langs })
62
+ }
63
+
64
+ if (engine === 'bun-native') {
65
+ if (typeof Bun !== 'undefined' && Bun.markdown != null) {
66
+ const { BunNativeRenderer } = await import('./bun-native.ts')
67
+ return new BunNativeRenderer()
68
+ }
69
+ console.warn('mkdnsite: bun-native renderer requested but Bun.markdown not available. Falling back to portable.')
70
+ }
71
+
72
+ const { PortableRenderer } = await import('./portable.ts')
73
+ return new PortableRenderer(highlighter, opts.syntaxTheme, opts.syntaxThemeDark, opts.math)
74
+ }