@barodoc/theme-docs 5.0.0 → 6.0.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": "@barodoc/theme-docs",
3
- "version": "5.0.0",
3
+ "version": "6.0.0",
4
4
  "description": "Documentation theme for Barodoc",
5
5
  "type": "module",
6
6
  "exports": {
@@ -28,9 +28,13 @@
28
28
  "class-variance-authority": "^0.7.1",
29
29
  "clsx": "^2.1.1",
30
30
  "lucide-react": "^0.563.0",
31
+ "medium-zoom": "^1.1.0",
31
32
  "mermaid": "^11.12.2",
33
+ "reading-time": "^1.5.0",
34
+ "rehype-katex": "^7.0.1",
35
+ "remark-math": "^6.0.0",
32
36
  "tailwind-merge": "^3.4.0",
33
- "@barodoc/core": "5.0.0"
37
+ "@barodoc/core": "6.0.0"
34
38
  },
35
39
  "peerDependencies": {
36
40
  "astro": "^5.0.0",
@@ -0,0 +1,71 @@
1
+ ---
2
+ import { execSync } from "node:child_process";
3
+
4
+ interface Props {
5
+ filePath?: string;
6
+ }
7
+
8
+ interface Contributor {
9
+ name: string;
10
+ email: string;
11
+ avatarUrl: string;
12
+ }
13
+
14
+ const { filePath } = Astro.props;
15
+
16
+ let contributors: Contributor[] = [];
17
+
18
+ if (filePath) {
19
+ try {
20
+ const raw = execSync(
21
+ `git log --format='%aN|%aE' -- "${filePath}"`,
22
+ { encoding: "utf-8", timeout: 5000 }
23
+ ).trim();
24
+
25
+ if (raw) {
26
+ const seen = new Map<string, Contributor>();
27
+ for (const line of raw.split("\n")) {
28
+ const [name, email] = line.split("|");
29
+ if (!name || seen.has(email)) continue;
30
+ const hash = email.trim().toLowerCase();
31
+ seen.set(email, {
32
+ name: name.trim(),
33
+ email: email.trim(),
34
+ avatarUrl: `https://gravatar.com/avatar/${await computeHash(hash)}?s=64&d=mp`,
35
+ });
36
+ }
37
+ contributors = Array.from(seen.values());
38
+ }
39
+ } catch {
40
+ // git not available or file not tracked
41
+ }
42
+ }
43
+
44
+ async function computeHash(input: string): Promise<string> {
45
+ const encoder = new TextEncoder();
46
+ const data = encoder.encode(input);
47
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
48
+ return Array.from(new Uint8Array(hashBuffer))
49
+ .map((b) => b.toString(16).padStart(2, "0"))
50
+ .join("");
51
+ }
52
+ ---
53
+
54
+ {contributors.length > 0 && (
55
+ <div class="bd-contributors">
56
+ <span class="bd-contributors-label">Contributors</span>
57
+ <div class="bd-contributors-avatars">
58
+ {contributors.map((c) => (
59
+ <img
60
+ src={c.avatarUrl}
61
+ alt={c.name}
62
+ title={c.name}
63
+ class="bd-contributor-avatar"
64
+ loading="lazy"
65
+ width="28"
66
+ height="28"
67
+ />
68
+ ))}
69
+ </div>
70
+ </div>
71
+ )}
@@ -0,0 +1,108 @@
1
+ ---
2
+ // Keyboard shortcuts: Cmd/Ctrl+K → search, ←/→ → prev/next, ? → help modal
3
+ ---
4
+
5
+ <script>
6
+ function initKeyboardShortcuts() {
7
+ if (window.__bdShortcutsInit) return;
8
+ window.__bdShortcutsInit = true;
9
+
10
+ const isMac = navigator.platform.toUpperCase().includes('MAC');
11
+ const modLabel = isMac ? '⌘' : 'Ctrl';
12
+
13
+ function isInputFocused(): boolean {
14
+ const el = document.activeElement;
15
+ if (!el) return false;
16
+ const tag = el.tagName;
17
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (el as HTMLElement).isContentEditable;
18
+ }
19
+
20
+ function showShortcutsModal() {
21
+ if (document.querySelector('.bd-shortcuts-overlay')) return;
22
+
23
+ const overlay = document.createElement('div');
24
+ overlay.className = 'bd-shortcuts-overlay';
25
+ overlay.addEventListener('click', (e) => {
26
+ if (e.target === overlay) overlay.remove();
27
+ });
28
+
29
+ overlay.innerHTML = `
30
+ <div class="bd-shortcuts-modal">
31
+ <h3>Keyboard Shortcuts</h3>
32
+ <div class="bd-shortcut-row">
33
+ <span class="bd-shortcut-desc">Search</span>
34
+ <span class="bd-shortcut-keys"><kbd class="bd-kbd">${modLabel}</kbd><kbd class="bd-kbd">K</kbd></span>
35
+ </div>
36
+ <div class="bd-shortcut-row">
37
+ <span class="bd-shortcut-desc">Previous page</span>
38
+ <span class="bd-shortcut-keys"><kbd class="bd-kbd">←</kbd></span>
39
+ </div>
40
+ <div class="bd-shortcut-row">
41
+ <span class="bd-shortcut-desc">Next page</span>
42
+ <span class="bd-shortcut-keys"><kbd class="bd-kbd">→</kbd></span>
43
+ </div>
44
+ <div class="bd-shortcut-row">
45
+ <span class="bd-shortcut-desc">Show shortcuts</span>
46
+ <span class="bd-shortcut-keys"><kbd class="bd-kbd">?</kbd></span>
47
+ </div>
48
+ </div>
49
+ `;
50
+
51
+ document.body.appendChild(overlay);
52
+ }
53
+
54
+ document.addEventListener('keydown', (e: KeyboardEvent) => {
55
+ // Close modal on Escape
56
+ if (e.key === 'Escape') {
57
+ const modal = document.querySelector('.bd-shortcuts-overlay');
58
+ if (modal) { modal.remove(); return; }
59
+ }
60
+
61
+ // Cmd/Ctrl+K → open search
62
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
63
+ e.preventDefault();
64
+ const searchBtn = document.querySelector('[data-search-trigger]') as HTMLButtonElement;
65
+ if (searchBtn) searchBtn.click();
66
+ return;
67
+ }
68
+
69
+ if (isInputFocused()) return;
70
+
71
+ // ? → show shortcuts
72
+ if (e.key === '?' || (e.shiftKey && e.key === '/')) {
73
+ e.preventDefault();
74
+ showShortcutsModal();
75
+ return;
76
+ }
77
+
78
+ // ← → prev page
79
+ if (e.key === 'ArrowLeft' && !e.metaKey && !e.ctrlKey && !e.altKey) {
80
+ const prev = document.querySelector('nav[aria-label="Page navigation"] a:first-of-type') as HTMLAnchorElement;
81
+ if (prev) { prev.click(); return; }
82
+ }
83
+
84
+ // → → next page
85
+ if (e.key === 'ArrowRight' && !e.metaKey && !e.ctrlKey && !e.altKey) {
86
+ const links = document.querySelectorAll('nav[aria-label="Page navigation"] a');
87
+ const next = links[links.length - 1] as HTMLAnchorElement;
88
+ if (next && links.length > 1) { next.click(); return; }
89
+ if (next && links.length === 1) {
90
+ const parent = next.closest('.col-span-1');
91
+ if (parent && parent === parent.parentElement?.lastElementChild) { next.click(); }
92
+ }
93
+ }
94
+ });
95
+ }
96
+
97
+ initKeyboardShortcuts();
98
+ document.addEventListener('astro:page-load', () => {
99
+ window.__bdShortcutsInit = false;
100
+ initKeyboardShortcuts();
101
+ });
102
+
103
+ declare global {
104
+ interface Window {
105
+ __bdShortcutsInit?: boolean;
106
+ }
107
+ }
108
+ </script>
@@ -0,0 +1,79 @@
1
+ import * as React from "react";
2
+ import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
3
+
4
+ interface VersionConfig {
5
+ label: string;
6
+ path: string;
7
+ }
8
+
9
+ interface VersionSwitcherProps {
10
+ versions: VersionConfig[];
11
+ currentPath: string;
12
+ }
13
+
14
+ export function VersionSwitcher({ versions, currentPath }: VersionSwitcherProps) {
15
+ if (!versions || versions.length <= 1) return null;
16
+
17
+ const current = versions.find((v) => currentPath.includes(`/docs/${v.path}/`)) || versions[0];
18
+
19
+ function switchVersion(target: VersionConfig) {
20
+ const regex = /\/docs\/([^/]+)\//;
21
+ const match = currentPath.match(regex);
22
+ if (match) {
23
+ const newPath = currentPath.replace(`/docs/${match[1]}/`, `/docs/${target.path}/`);
24
+ window.location.href = newPath;
25
+ } else {
26
+ window.location.href = `/docs/${target.path}/`;
27
+ }
28
+ }
29
+
30
+ return (
31
+ <DropdownMenu.Root>
32
+ <DropdownMenu.Trigger asChild>
33
+ <button className="bd-version-trigger">
34
+ {current.label}
35
+ <svg
36
+ width="12"
37
+ height="12"
38
+ viewBox="0 0 24 24"
39
+ fill="none"
40
+ stroke="currentColor"
41
+ strokeWidth="2"
42
+ strokeLinecap="round"
43
+ strokeLinejoin="round"
44
+ >
45
+ <path d="m6 9 6 6 6-6" />
46
+ </svg>
47
+ </button>
48
+ </DropdownMenu.Trigger>
49
+
50
+ <DropdownMenu.Portal>
51
+ <DropdownMenu.Content className="bd-version-menu" sideOffset={4} align="start">
52
+ {versions.map((v) => (
53
+ <DropdownMenu.Item
54
+ key={v.path}
55
+ className={`bd-version-item ${v.path === current.path ? "bd-version-active" : ""}`}
56
+ onSelect={() => switchVersion(v)}
57
+ >
58
+ {v.label}
59
+ {v.path === current.path && (
60
+ <svg
61
+ width="14"
62
+ height="14"
63
+ viewBox="0 0 24 24"
64
+ fill="none"
65
+ stroke="currentColor"
66
+ strokeWidth="2.5"
67
+ strokeLinecap="round"
68
+ strokeLinejoin="round"
69
+ >
70
+ <polyline points="20 6 9 17 4 12" />
71
+ </svg>
72
+ )}
73
+ </DropdownMenu.Item>
74
+ ))}
75
+ </DropdownMenu.Content>
76
+ </DropdownMenu.Portal>
77
+ </DropdownMenu.Root>
78
+ );
79
+ }
@@ -24,6 +24,11 @@ export { Expandable, ExpandableList, ExpandableItem } from "./mdx/Expandable.tsx
24
24
  export { Icon, CheckIcon, XIcon, InfoIcon, WarningIcon } from "./mdx/Icon.tsx";
25
25
  export { Steps, Step } from "./mdx/Steps.tsx";
26
26
  export { Mermaid } from "./mdx/Mermaid.tsx";
27
+ export { ImageZoom } from "./mdx/ImageZoom.tsx";
28
+ export { Video } from "./mdx/Video.tsx";
29
+ export { ApiPlayground } from "./mdx/ApiPlayground.tsx";
30
+
31
+ export { VersionSwitcher } from "./VersionSwitcher.tsx";
27
32
 
28
33
  // Legacy exports for backwards compatibility
29
34
  export { Tabs, Tab } from "./mdx/Tabs.tsx";
@@ -0,0 +1,200 @@
1
+ import * as React from "react";
2
+ import { cn } from "../../lib/utils.js";
3
+
4
+ interface ParamDef {
5
+ name: string;
6
+ in: "query" | "path" | "header" | "body";
7
+ type?: string;
8
+ required?: boolean;
9
+ defaultValue?: string;
10
+ description?: string;
11
+ }
12
+
13
+ interface ApiPlaygroundProps {
14
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
15
+ url: string;
16
+ params?: ParamDef[];
17
+ body?: string;
18
+ headers?: Record<string, string>;
19
+ className?: string;
20
+ }
21
+
22
+ const methodColors: Record<string, string> = {
23
+ GET: "bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300",
24
+ POST: "bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300",
25
+ PUT: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
26
+ PATCH: "bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-300",
27
+ DELETE: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
28
+ };
29
+
30
+ export function ApiPlayground({
31
+ method = "GET",
32
+ url,
33
+ params = [],
34
+ body: initialBody,
35
+ headers: initialHeaders = {},
36
+ className,
37
+ }: ApiPlaygroundProps) {
38
+ const [paramValues, setParamValues] = React.useState<Record<string, string>>(() => {
39
+ const vals: Record<string, string> = {};
40
+ for (const p of params) {
41
+ vals[p.name] = p.defaultValue || "";
42
+ }
43
+ return vals;
44
+ });
45
+ const [bodyValue, setBodyValue] = React.useState(initialBody || "");
46
+ const [response, setResponse] = React.useState<{
47
+ status: number;
48
+ statusText: string;
49
+ body: string;
50
+ time: number;
51
+ } | null>(null);
52
+ const [loading, setLoading] = React.useState(false);
53
+ const [error, setError] = React.useState<string | null>(null);
54
+
55
+ function buildUrl(): string {
56
+ let finalUrl = url;
57
+ const queryParams = new URLSearchParams();
58
+
59
+ for (const p of params) {
60
+ const val = paramValues[p.name] || "";
61
+ if (p.in === "path") {
62
+ finalUrl = finalUrl.replace(`{${p.name}}`, encodeURIComponent(val));
63
+ } else if (p.in === "query" && val) {
64
+ queryParams.set(p.name, val);
65
+ }
66
+ }
67
+
68
+ const qs = queryParams.toString();
69
+ return qs ? `${finalUrl}?${qs}` : finalUrl;
70
+ }
71
+
72
+ async function sendRequest() {
73
+ setLoading(true);
74
+ setError(null);
75
+ setResponse(null);
76
+
77
+ const start = performance.now();
78
+ const reqUrl = buildUrl();
79
+
80
+ const headers: Record<string, string> = { ...initialHeaders };
81
+ for (const p of params) {
82
+ if (p.in === "header" && paramValues[p.name]) {
83
+ headers[p.name] = paramValues[p.name];
84
+ }
85
+ }
86
+
87
+ const hasBody = ["POST", "PUT", "PATCH"].includes(method) && bodyValue;
88
+ if (hasBody && !headers["Content-Type"]) {
89
+ headers["Content-Type"] = "application/json";
90
+ }
91
+
92
+ try {
93
+ const res = await fetch(reqUrl, {
94
+ method,
95
+ headers,
96
+ body: hasBody ? bodyValue : undefined,
97
+ });
98
+
99
+ const elapsed = Math.round(performance.now() - start);
100
+ let text: string;
101
+ try {
102
+ const json = await res.json();
103
+ text = JSON.stringify(json, null, 2);
104
+ } catch {
105
+ text = await res.text();
106
+ }
107
+
108
+ setResponse({ status: res.status, statusText: res.statusText, body: text, time: elapsed });
109
+ } catch (err) {
110
+ setError(err instanceof Error ? err.message : "Request failed");
111
+ } finally {
112
+ setLoading(false);
113
+ }
114
+ }
115
+
116
+ return (
117
+ <div className={cn("bd-playground", className)}>
118
+ {/* Header */}
119
+ <div className="bd-playground-header">
120
+ <span className={cn("bd-playground-method", methodColors[method])}>
121
+ {method}
122
+ </span>
123
+ <code className="bd-playground-url">{url}</code>
124
+ <button
125
+ className="bd-playground-send"
126
+ onClick={sendRequest}
127
+ disabled={loading}
128
+ >
129
+ {loading ? "Sending..." : "Send"}
130
+ </button>
131
+ </div>
132
+
133
+ {/* Parameters */}
134
+ {params.length > 0 && (
135
+ <div className="bd-playground-params">
136
+ {params.map((p) => (
137
+ <div key={p.name} className="bd-playground-param">
138
+ <label className="bd-playground-label">
139
+ <span>{p.name}</span>
140
+ <span className="bd-playground-param-meta">
141
+ {p.in}
142
+ {p.required && <span className="bd-playground-required">*</span>}
143
+ </span>
144
+ </label>
145
+ <input
146
+ type="text"
147
+ className="bd-playground-input"
148
+ value={paramValues[p.name] || ""}
149
+ placeholder={p.description || p.type || ""}
150
+ onChange={(e) =>
151
+ setParamValues((prev) => ({ ...prev, [p.name]: e.target.value }))
152
+ }
153
+ />
154
+ </div>
155
+ ))}
156
+ </div>
157
+ )}
158
+
159
+ {/* Body */}
160
+ {["POST", "PUT", "PATCH"].includes(method) && (
161
+ <div className="bd-playground-body-section">
162
+ <label className="bd-playground-label">Request Body</label>
163
+ <textarea
164
+ className="bd-playground-textarea"
165
+ value={bodyValue}
166
+ onChange={(e) => setBodyValue(e.target.value)}
167
+ rows={6}
168
+ placeholder='{ "key": "value" }'
169
+ />
170
+ </div>
171
+ )}
172
+
173
+ {/* Response */}
174
+ {(response || error) && (
175
+ <div className="bd-playground-response">
176
+ {error ? (
177
+ <div className="bd-playground-error">{error}</div>
178
+ ) : response ? (
179
+ <>
180
+ <div className="bd-playground-response-header">
181
+ <span
182
+ className={cn(
183
+ "bd-playground-status",
184
+ response.status < 300 ? "bd-status-ok" : "bd-status-err"
185
+ )}
186
+ >
187
+ {response.status} {response.statusText}
188
+ </span>
189
+ <span className="bd-playground-time">{response.time}ms</span>
190
+ </div>
191
+ <pre className="bd-playground-pre">
192
+ <code>{response.body}</code>
193
+ </pre>
194
+ </>
195
+ ) : null}
196
+ </div>
197
+ )}
198
+ </div>
199
+ );
200
+ }
@@ -0,0 +1,35 @@
1
+ import * as React from "react";
2
+ import mediumZoom from "medium-zoom";
3
+ import type { Zoom } from "medium-zoom";
4
+
5
+ interface ImageZoomProps extends React.ImgHTMLAttributes<HTMLImageElement> {
6
+ zoomSrc?: string;
7
+ }
8
+
9
+ export function ImageZoom({ zoomSrc, ...props }: ImageZoomProps) {
10
+ const imgRef = React.useRef<HTMLImageElement>(null);
11
+ const zoomRef = React.useRef<Zoom | null>(null);
12
+
13
+ React.useEffect(() => {
14
+ if (!imgRef.current) return;
15
+
16
+ zoomRef.current = mediumZoom(imgRef.current, {
17
+ margin: 24,
18
+ background: "var(--bd-bg)",
19
+ scrollOffset: 0,
20
+ });
21
+
22
+ return () => {
23
+ zoomRef.current?.detach();
24
+ };
25
+ }, []);
26
+
27
+ return (
28
+ <img
29
+ ref={imgRef}
30
+ data-zoom-src={zoomSrc}
31
+ className="bd-image-zoom"
32
+ {...props}
33
+ />
34
+ );
35
+ }
@@ -0,0 +1,71 @@
1
+ import * as React from "react";
2
+ import { cn } from "../../lib/utils.js";
3
+
4
+ interface VideoProps {
5
+ url: string;
6
+ title?: string;
7
+ caption?: string;
8
+ className?: string;
9
+ }
10
+
11
+ function parseVideoUrl(url: string): { provider: string; embedUrl: string } | null {
12
+ // YouTube: youtube.com/watch?v=ID, youtu.be/ID, youtube.com/embed/ID
13
+ const ytMatch = url.match(
14
+ /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/
15
+ );
16
+ if (ytMatch) {
17
+ return {
18
+ provider: "youtube",
19
+ embedUrl: `https://www.youtube-nocookie.com/embed/${ytMatch[1]}?rel=0`,
20
+ };
21
+ }
22
+
23
+ // Vimeo: vimeo.com/ID, player.vimeo.com/video/ID
24
+ const vimeoMatch = url.match(/(?:vimeo\.com\/|player\.vimeo\.com\/video\/)(\d+)/);
25
+ if (vimeoMatch) {
26
+ return {
27
+ provider: "vimeo",
28
+ embedUrl: `https://player.vimeo.com/video/${vimeoMatch[1]}?dnt=1`,
29
+ };
30
+ }
31
+
32
+ // Loom: loom.com/share/ID, loom.com/embed/ID
33
+ const loomMatch = url.match(/loom\.com\/(?:share|embed)\/([a-f0-9]+)/);
34
+ if (loomMatch) {
35
+ return {
36
+ provider: "loom",
37
+ embedUrl: `https://www.loom.com/embed/${loomMatch[1]}`,
38
+ };
39
+ }
40
+
41
+ return null;
42
+ }
43
+
44
+ export function Video({ url, title, caption, className }: VideoProps) {
45
+ const parsed = parseVideoUrl(url);
46
+
47
+ if (!parsed) {
48
+ return (
49
+ <div className={cn("bd-video", className)} style={{ paddingTop: 0, padding: "2rem" }}>
50
+ <p style={{ color: "var(--bd-text-muted)", margin: 0, textAlign: "center" }}>
51
+ Unsupported video URL: {url}
52
+ </p>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ return (
58
+ <figure className={className}>
59
+ <div className="bd-video">
60
+ <iframe
61
+ src={parsed.embedUrl}
62
+ title={title || `${parsed.provider} video`}
63
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
64
+ allowFullScreen
65
+ loading="lazy"
66
+ />
67
+ </div>
68
+ {caption && <figcaption className="bd-video-caption">{caption}</figcaption>}
69
+ </figure>
70
+ );
71
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,8 @@ import type { ThemeExport, ResolvedBarodocConfig } from "@barodoc/core";
3
3
  import mdx from "@astrojs/mdx";
4
4
  import react from "@astrojs/react";
5
5
  import tailwindcss from "@tailwindcss/vite";
6
+ import remarkMath from "remark-math";
7
+ import rehypeKatex from "rehype-katex";
6
8
 
7
9
  export interface DocsThemeOptions {
8
10
  customCss?: string[];
@@ -79,9 +81,33 @@ function createThemeIntegration(
79
81
  entrypoint: "@barodoc/theme-docs/pages/docs/[...slug].astro",
80
82
  });
81
83
 
84
+ // Blog routes
85
+ if (config?.blog?.enabled !== false) {
86
+ injectRoute({
87
+ pattern: "/blog",
88
+ entrypoint: "@barodoc/theme-docs/pages/blog/index.astro",
89
+ });
90
+ injectRoute({
91
+ pattern: "/blog/[...slug]",
92
+ entrypoint: "@barodoc/theme-docs/pages/blog/[...slug].astro",
93
+ });
94
+ }
95
+
96
+ // Changelog route
97
+ injectRoute({
98
+ pattern: "/changelog",
99
+ entrypoint: "@barodoc/theme-docs/pages/changelog/index.astro",
100
+ });
101
+
82
102
  // Update Astro config with integrations and Vite plugins
83
103
  updateConfig({
84
- integrations: [mdx(), react()],
104
+ integrations: [
105
+ mdx({
106
+ remarkPlugins: [remarkMath],
107
+ rehypePlugins: [rehypeKatex],
108
+ }),
109
+ react(),
110
+ ],
85
111
  vite: {
86
112
  plugins: [tailwindcss()],
87
113
  optimizeDeps: {
@@ -45,6 +45,9 @@ const themeCSS = config.theme?.colors ? generateThemeCSS(config.theme.colors) :
45
45
  <meta name="twitter:description" content={description} />
46
46
  {ogImageUrl && <meta name="twitter:image" content={ogImageUrl} />}
47
47
 
48
+ <!-- KaTeX math rendering -->
49
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css" crossorigin="anonymous" />
50
+
48
51
  <title>{title}</title>
49
52
  <ThemeScript />
50
53
  <ClientRouter />