@affanhamid/markdown-renderer 2.0.0 → 2.1.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/README.md +135 -0
- package/dist/index.cjs +65 -0
- package/dist/index.js +65 -0
- package/package.json +12 -4
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# @affanhamid/markdown-renderer
|
|
2
|
+
|
|
3
|
+
A React markdown renderer built for AI-generated content. Handles the math rendering problems that react-markdown + remark-math can't.
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
If you've used `react-markdown` with `remark-math` and `rehype-katex` to render LLM output, you've hit these problems:
|
|
8
|
+
|
|
9
|
+
**1. Dollar signs break everything.** `remark-math` uses `$` for LaTeX math, but `$` is also currency. Their `singleDollarTextMath: false` option disables single-dollar math entirely, forcing `$$` for everything. This package disambiguates intelligently: `$20` renders as currency (digit follows `$`), while `$x + y$` renders as math. It also handles CJK characters, Devanagari/Hindi punctuation, and fullwidth punctuation as valid math boundaries.
|
|
10
|
+
|
|
11
|
+
**2. AI models use inconsistent math delimiters.** GPT, Claude, and Gemini variously output `$...$`, `$$...$$`, `\(...\)`, and `\[...\]`. `remark-math` does not support `\(...\)` or `\[...\]` — there's an [open discussion](https://github.com/remarkjs/remark-math/issues) with no resolution. This package normalizes all four formats automatically before rendering.
|
|
12
|
+
|
|
13
|
+
**3. Too many moving parts.** The standard setup requires `react-markdown` + `remark-math` + `remark-gfm` + `rehype-katex` + KaTeX CSS + a syntax highlighter + custom components for tables, images, code blocks. This package is one import.
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- **Math rendering** — KaTeX with automatic delimiter normalization (`$`, `$$`, `\(`, `\[`)
|
|
18
|
+
- **Dollar sign disambiguation** — currency vs. math, with CJK/Devanagari/fullwidth support
|
|
19
|
+
- **Syntax highlighting** — Shiki with `github-light` theme and copy-to-clipboard
|
|
20
|
+
- **Tables** — GFM-style with column alignment (left, center, right)
|
|
21
|
+
- **Executable code blocks** — optional `onRunCode` callback for running Python, R, etc.
|
|
22
|
+
- **Inline images** — `` works inside paragraphs, not just as standalone blocks
|
|
23
|
+
- **Semantic color tags** — `{color:important}text{/color}` for highlighting (important, definition, example, note, formula)
|
|
24
|
+
- **Auto-scaling brackets** — `($x + y$)` automatically uses `\left(` and `\right)` for proper sizing
|
|
25
|
+
- **Prompt appendix** — exported `MATH_MARKDOWN_RULES_APPENDIX` string to append to your LLM system prompt, steering models toward consistent delimiter usage
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install @affanhamid/markdown-renderer
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Peer dependency: `react >= 18`
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
### React component
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
import { MarkdownRenderer } from "@affanhamid/markdown-renderer";
|
|
41
|
+
|
|
42
|
+
function ChatMessage({ content }: { content: string }) {
|
|
43
|
+
return <MarkdownRenderer markdown={content} />;
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### With executable code blocks
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
import { MarkdownRenderer } from "@affanhamid/markdown-renderer";
|
|
51
|
+
|
|
52
|
+
function Notebook({ content }: { content: string }) {
|
|
53
|
+
const handleRunCode = async (code: string, language: string) => {
|
|
54
|
+
const result = await executeOnServer(code, language);
|
|
55
|
+
return {
|
|
56
|
+
output: result.stdout,
|
|
57
|
+
error: result.stderr,
|
|
58
|
+
images: result.plots, // base64 data URIs
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<MarkdownRenderer
|
|
64
|
+
markdown={content}
|
|
65
|
+
onRunCode={handleRunCode}
|
|
66
|
+
executableLanguages={["python", "r"]}
|
|
67
|
+
/>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Server-side HTML (no React)
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { renderMarkdownToHtml } from "@affanhamid/markdown-renderer";
|
|
76
|
+
|
|
77
|
+
const html = renderMarkdownToHtml(markdownString);
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Normalize delimiters only
|
|
81
|
+
|
|
82
|
+
If you want to preprocess markdown before passing it to your own renderer:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { normalizeMathMarkdownDelimiters } from "@affanhamid/markdown-renderer";
|
|
86
|
+
|
|
87
|
+
// Converts \(...\) -> $...$, \[...\] -> $$...$$, inline $$...$$ -> $...$
|
|
88
|
+
const normalized = normalizeMathMarkdownDelimiters(rawMarkdown);
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Prompt engineering helper
|
|
92
|
+
|
|
93
|
+
Append this to your LLM system prompt to reduce delimiter inconsistency at the source:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { MATH_MARKDOWN_RULES_APPENDIX } from "@affanhamid/markdown-renderer";
|
|
97
|
+
|
|
98
|
+
const systemPrompt = `You are a helpful assistant.\n\n${MATH_MARKDOWN_RULES_APPENDIX}`;
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## API
|
|
102
|
+
|
|
103
|
+
### `<MarkdownRenderer />` (default export)
|
|
104
|
+
|
|
105
|
+
| Prop | Type | Default | Description |
|
|
106
|
+
|------|------|---------|-------------|
|
|
107
|
+
| `markdown` | `string` | required | Markdown content to render |
|
|
108
|
+
| `onRunCode` | `(code: string, language: string) => Promise<CodeExecutionResult>` | `undefined` | Callback for executing code blocks |
|
|
109
|
+
| `executableLanguages` | `string[]` | `["python", "r"]` | Languages that get a "Run" button |
|
|
110
|
+
|
|
111
|
+
### `CodeExecutionResult`
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
interface CodeExecutionResult {
|
|
115
|
+
output: string;
|
|
116
|
+
error?: string;
|
|
117
|
+
images?: string[]; // data URIs or URLs
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### `renderMarkdownToHtml(markdown: string, options?: { executableLanguages?: string[] }): string`
|
|
122
|
+
|
|
123
|
+
Renders markdown to an HTML string. Works without React (server-side, emails, PDFs).
|
|
124
|
+
|
|
125
|
+
### `normalizeMathMarkdownDelimiters(markdown: string): string`
|
|
126
|
+
|
|
127
|
+
Normalizes `\(...\)` to `$...$` and `\[...\]` to `$$...$$`. Converts inline `$$...$$` to `$...$`. Leaves code fences untouched.
|
|
128
|
+
|
|
129
|
+
### `MATH_MARKDOWN_RULES_APPENDIX: string`
|
|
130
|
+
|
|
131
|
+
A plain-text string with math formatting rules to append to LLM system prompts.
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT
|
package/dist/index.cjs
CHANGED
|
@@ -820,6 +820,13 @@ function renderMarkdownToHtml(markdown, options) {
|
|
|
820
820
|
const isExecutable = options?.executableLanguages && language && options.executableLanguages.includes(language.toLowerCase());
|
|
821
821
|
const currentIndex = codeBlockIndex;
|
|
822
822
|
codeBlockIndex++;
|
|
823
|
+
if (language === "mermaid") {
|
|
824
|
+
parts.push(
|
|
825
|
+
`<div class="md-mermaid" data-mermaid-code="${escapeHtml(codeContent)}"><pre style="overflow-x:auto;background:#f7f7f7;padding:0.75rem;border-radius:0.375rem;font-size:0.8rem;color:#666"><code>${escapedCode}</code></pre></div>`
|
|
826
|
+
);
|
|
827
|
+
i++;
|
|
828
|
+
break;
|
|
829
|
+
}
|
|
823
830
|
if (isExecutable) {
|
|
824
831
|
parts.push(
|
|
825
832
|
`<div class="md-code-block" data-language="${escapedLang}" data-code-index="${currentIndex}" data-executable="true"><div class="md-code-block-header" style="display:flex;align-items:center;justify-content:space-between;padding:0.25rem 0.75rem;background:#f0f0f0;border-radius:0.375rem 0.375rem 0 0;border:1px solid #e0e0e0;border-bottom:none"><span style="font-size:0.75rem;color:#666;font-family:monospace">${escapedLang}</span><button class="md-run-btn" data-code-index="${currentIndex}" style="padding:0.2rem 0.6rem;font-size:0.75rem;border-radius:0.25rem;border:1px solid #ccc;background:#fff;cursor:pointer;font-family:inherit">Run</button></div><pre style="overflow-x:auto;border-radius:0 0 0.375rem 0.375rem;background:#f7f7f7;color:#1f2937;padding:0.75rem;font-size:0.875rem;margin:0;border:1px solid #e0e0e0;border-top:none"><code class="language-${escapedLang}" data-executable="true">${escapedCode}</code></pre><div class="md-code-output" data-output-for="${currentIndex}" style="display:none"></div></div>`
|
|
@@ -847,6 +854,28 @@ function renderMarkdownToHtml(markdown, options) {
|
|
|
847
854
|
i++;
|
|
848
855
|
continue;
|
|
849
856
|
}
|
|
857
|
+
const calloutMatch = trimmed.match(/^\\begin\{callout\}\{(\w+)\}$/);
|
|
858
|
+
if (calloutMatch && calloutMatch[1]) {
|
|
859
|
+
const color = calloutMatch[1];
|
|
860
|
+
const contentLines = [];
|
|
861
|
+
i++;
|
|
862
|
+
while (i < lines.length) {
|
|
863
|
+
const calloutLine = (lines[i] || "").trim();
|
|
864
|
+
if (calloutLine === "\\end{callout}") {
|
|
865
|
+
i++;
|
|
866
|
+
break;
|
|
867
|
+
}
|
|
868
|
+
contentLines.push(lines[i] || "");
|
|
869
|
+
i++;
|
|
870
|
+
}
|
|
871
|
+
const innerHtml = renderMarkdownToHtml(contentLines.join("\n"), options);
|
|
872
|
+
const innerMatch = innerHtml.match(/<div class="prose[^"]*">(.*)<\/div>/s);
|
|
873
|
+
const innerContent = innerMatch?.[1] ?? escapeHtml(contentLines.join("\n"));
|
|
874
|
+
parts.push(
|
|
875
|
+
`<div class="md-callout border-${color}-200 bg-${color}-50 text-${color}-900 dark:border-${color}-700/40 dark:bg-${color}-900/10 dark:text-${color}-200 my-4 rounded-lg border px-4 py-3 text-sm leading-relaxed [&>p]:mb-0 [&>p:last-child]:mb-0">${innerContent}</div>`
|
|
876
|
+
);
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
850
879
|
const imageMatch = trimmed.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
|
|
851
880
|
if (imageMatch && imageMatch[2]) {
|
|
852
881
|
const alt = escapeHtml(imageMatch[1] ?? "");
|
|
@@ -1038,6 +1067,42 @@ var MarkdownRenderer = ({
|
|
|
1038
1067
|
);
|
|
1039
1068
|
};
|
|
1040
1069
|
}, [html, handleRun]);
|
|
1070
|
+
(0, import_react.useEffect)(() => {
|
|
1071
|
+
const container = containerRef.current;
|
|
1072
|
+
if (!container) return;
|
|
1073
|
+
const mermaidBlocks = container.querySelectorAll(".md-mermaid");
|
|
1074
|
+
if (mermaidBlocks.length === 0) return;
|
|
1075
|
+
let alive = true;
|
|
1076
|
+
void (async () => {
|
|
1077
|
+
try {
|
|
1078
|
+
const mermaid = (await import("mermaid")).default;
|
|
1079
|
+
mermaid.initialize({
|
|
1080
|
+
startOnLoad: false,
|
|
1081
|
+
securityLevel: "antiscript",
|
|
1082
|
+
theme: "neutral",
|
|
1083
|
+
fontFamily: "ui-sans-serif, system-ui, sans-serif",
|
|
1084
|
+
fontSize: 13,
|
|
1085
|
+
htmlLabels: false,
|
|
1086
|
+
flowchart: { useMaxWidth: true, htmlLabels: false },
|
|
1087
|
+
sequence: { useMaxWidth: true }
|
|
1088
|
+
});
|
|
1089
|
+
for (const block of Array.from(mermaidBlocks)) {
|
|
1090
|
+
if (!alive) break;
|
|
1091
|
+
const code = block.getAttribute("data-mermaid-code") || "";
|
|
1092
|
+
const id = `mermaid-${Math.random().toString(36).slice(2, 9)}`;
|
|
1093
|
+
try {
|
|
1094
|
+
const { svg } = await mermaid.render(id, code.trim());
|
|
1095
|
+
block.innerHTML = `<div style="display:flex;justify-content:center;overflow:auto;padding:1rem">${svg}</div>`;
|
|
1096
|
+
} catch {
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
} catch {
|
|
1100
|
+
}
|
|
1101
|
+
})();
|
|
1102
|
+
return () => {
|
|
1103
|
+
alive = false;
|
|
1104
|
+
};
|
|
1105
|
+
}, [html]);
|
|
1041
1106
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { ref: containerRef, dangerouslySetInnerHTML: { __html: html } });
|
|
1042
1107
|
};
|
|
1043
1108
|
var markdown_renderer_default = MarkdownRenderer;
|
package/dist/index.js
CHANGED
|
@@ -781,6 +781,13 @@ function renderMarkdownToHtml(markdown, options) {
|
|
|
781
781
|
const isExecutable = options?.executableLanguages && language && options.executableLanguages.includes(language.toLowerCase());
|
|
782
782
|
const currentIndex = codeBlockIndex;
|
|
783
783
|
codeBlockIndex++;
|
|
784
|
+
if (language === "mermaid") {
|
|
785
|
+
parts.push(
|
|
786
|
+
`<div class="md-mermaid" data-mermaid-code="${escapeHtml(codeContent)}"><pre style="overflow-x:auto;background:#f7f7f7;padding:0.75rem;border-radius:0.375rem;font-size:0.8rem;color:#666"><code>${escapedCode}</code></pre></div>`
|
|
787
|
+
);
|
|
788
|
+
i++;
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
784
791
|
if (isExecutable) {
|
|
785
792
|
parts.push(
|
|
786
793
|
`<div class="md-code-block" data-language="${escapedLang}" data-code-index="${currentIndex}" data-executable="true"><div class="md-code-block-header" style="display:flex;align-items:center;justify-content:space-between;padding:0.25rem 0.75rem;background:#f0f0f0;border-radius:0.375rem 0.375rem 0 0;border:1px solid #e0e0e0;border-bottom:none"><span style="font-size:0.75rem;color:#666;font-family:monospace">${escapedLang}</span><button class="md-run-btn" data-code-index="${currentIndex}" style="padding:0.2rem 0.6rem;font-size:0.75rem;border-radius:0.25rem;border:1px solid #ccc;background:#fff;cursor:pointer;font-family:inherit">Run</button></div><pre style="overflow-x:auto;border-radius:0 0 0.375rem 0.375rem;background:#f7f7f7;color:#1f2937;padding:0.75rem;font-size:0.875rem;margin:0;border:1px solid #e0e0e0;border-top:none"><code class="language-${escapedLang}" data-executable="true">${escapedCode}</code></pre><div class="md-code-output" data-output-for="${currentIndex}" style="display:none"></div></div>`
|
|
@@ -808,6 +815,28 @@ function renderMarkdownToHtml(markdown, options) {
|
|
|
808
815
|
i++;
|
|
809
816
|
continue;
|
|
810
817
|
}
|
|
818
|
+
const calloutMatch = trimmed.match(/^\\begin\{callout\}\{(\w+)\}$/);
|
|
819
|
+
if (calloutMatch && calloutMatch[1]) {
|
|
820
|
+
const color = calloutMatch[1];
|
|
821
|
+
const contentLines = [];
|
|
822
|
+
i++;
|
|
823
|
+
while (i < lines.length) {
|
|
824
|
+
const calloutLine = (lines[i] || "").trim();
|
|
825
|
+
if (calloutLine === "\\end{callout}") {
|
|
826
|
+
i++;
|
|
827
|
+
break;
|
|
828
|
+
}
|
|
829
|
+
contentLines.push(lines[i] || "");
|
|
830
|
+
i++;
|
|
831
|
+
}
|
|
832
|
+
const innerHtml = renderMarkdownToHtml(contentLines.join("\n"), options);
|
|
833
|
+
const innerMatch = innerHtml.match(/<div class="prose[^"]*">(.*)<\/div>/s);
|
|
834
|
+
const innerContent = innerMatch?.[1] ?? escapeHtml(contentLines.join("\n"));
|
|
835
|
+
parts.push(
|
|
836
|
+
`<div class="md-callout border-${color}-200 bg-${color}-50 text-${color}-900 dark:border-${color}-700/40 dark:bg-${color}-900/10 dark:text-${color}-200 my-4 rounded-lg border px-4 py-3 text-sm leading-relaxed [&>p]:mb-0 [&>p:last-child]:mb-0">${innerContent}</div>`
|
|
837
|
+
);
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
811
840
|
const imageMatch = trimmed.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
|
|
812
841
|
if (imageMatch && imageMatch[2]) {
|
|
813
842
|
const alt = escapeHtml(imageMatch[1] ?? "");
|
|
@@ -999,6 +1028,42 @@ var MarkdownRenderer = ({
|
|
|
999
1028
|
);
|
|
1000
1029
|
};
|
|
1001
1030
|
}, [html, handleRun]);
|
|
1031
|
+
useEffect(() => {
|
|
1032
|
+
const container = containerRef.current;
|
|
1033
|
+
if (!container) return;
|
|
1034
|
+
const mermaidBlocks = container.querySelectorAll(".md-mermaid");
|
|
1035
|
+
if (mermaidBlocks.length === 0) return;
|
|
1036
|
+
let alive = true;
|
|
1037
|
+
void (async () => {
|
|
1038
|
+
try {
|
|
1039
|
+
const mermaid = (await import("mermaid")).default;
|
|
1040
|
+
mermaid.initialize({
|
|
1041
|
+
startOnLoad: false,
|
|
1042
|
+
securityLevel: "antiscript",
|
|
1043
|
+
theme: "neutral",
|
|
1044
|
+
fontFamily: "ui-sans-serif, system-ui, sans-serif",
|
|
1045
|
+
fontSize: 13,
|
|
1046
|
+
htmlLabels: false,
|
|
1047
|
+
flowchart: { useMaxWidth: true, htmlLabels: false },
|
|
1048
|
+
sequence: { useMaxWidth: true }
|
|
1049
|
+
});
|
|
1050
|
+
for (const block of Array.from(mermaidBlocks)) {
|
|
1051
|
+
if (!alive) break;
|
|
1052
|
+
const code = block.getAttribute("data-mermaid-code") || "";
|
|
1053
|
+
const id = `mermaid-${Math.random().toString(36).slice(2, 9)}`;
|
|
1054
|
+
try {
|
|
1055
|
+
const { svg } = await mermaid.render(id, code.trim());
|
|
1056
|
+
block.innerHTML = `<div style="display:flex;justify-content:center;overflow:auto;padding:1rem">${svg}</div>`;
|
|
1057
|
+
} catch {
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
} catch {
|
|
1061
|
+
}
|
|
1062
|
+
})();
|
|
1063
|
+
return () => {
|
|
1064
|
+
alive = false;
|
|
1065
|
+
};
|
|
1066
|
+
}, [html]);
|
|
1002
1067
|
return /* @__PURE__ */ jsx("div", { ref: containerRef, dangerouslySetInnerHTML: { __html: html } });
|
|
1003
1068
|
};
|
|
1004
1069
|
var markdown_renderer_default = MarkdownRenderer;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@affanhamid/markdown-renderer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Custom markdown renderer with KaTeX support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
}
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
|
-
"build": "tsup src/index.ts --format cjs,esm --dts --external react",
|
|
17
|
+
"build": "tsup src/index.ts --format cjs,esm --dts --external react --external mermaid",
|
|
18
18
|
"test": "jest"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
@@ -22,19 +22,27 @@
|
|
|
22
22
|
"shiki": "^4.0.1"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
|
-
"react": ">=18"
|
|
25
|
+
"react": ">=18",
|
|
26
|
+
"mermaid": ">=10"
|
|
27
|
+
},
|
|
28
|
+
"peerDependenciesMeta": {
|
|
29
|
+
"mermaid": {
|
|
30
|
+
"optional": true
|
|
31
|
+
}
|
|
26
32
|
},
|
|
27
33
|
"devDependencies": {
|
|
28
34
|
"@types/jest": "^30.0.0",
|
|
29
35
|
"@types/katex": "^0.16.7",
|
|
30
36
|
"@types/react": "^19.0.0",
|
|
31
37
|
"jest": "^30.2.0",
|
|
38
|
+
"mermaid": "^11.12.3",
|
|
32
39
|
"ts-jest": "^29.4.6",
|
|
33
40
|
"tsup": "^8.0.0",
|
|
34
41
|
"typescript": "^5.8.0"
|
|
35
42
|
},
|
|
36
43
|
"files": [
|
|
37
|
-
"dist"
|
|
44
|
+
"dist",
|
|
45
|
+
"README.md"
|
|
38
46
|
],
|
|
39
47
|
"license": "MIT"
|
|
40
48
|
}
|