@typefm/react-markdown-viewer 0.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/LICENSE +21 -0
- package/README.md +217 -0
- package/dist/ErrorBoundary.d.ts +25 -0
- package/dist/ErrorBoundary.d.ts.map +1 -0
- package/dist/ErrorBoundary.js +29 -0
- package/dist/ErrorBoundary.js.map +1 -0
- package/dist/MarkdownViewer.d.ts +41 -0
- package/dist/MarkdownViewer.d.ts.map +1 -0
- package/dist/MarkdownViewer.js +69 -0
- package/dist/MarkdownViewer.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/cache-manager.d.ts +59 -0
- package/dist/lib/cache-manager.d.ts.map +1 -0
- package/dist/lib/cache-manager.js +160 -0
- package/dist/lib/cache-manager.js.map +1 -0
- package/dist/lib/cursor-controller.d.ts +13 -0
- package/dist/lib/cursor-controller.d.ts.map +1 -0
- package/dist/lib/cursor-controller.js +93 -0
- package/dist/lib/cursor-controller.js.map +1 -0
- package/dist/lib/defaults/code-block.d.ts +71 -0
- package/dist/lib/defaults/code-block.d.ts.map +1 -0
- package/dist/lib/defaults/code-block.js +104 -0
- package/dist/lib/defaults/code-block.js.map +1 -0
- package/dist/lib/defaults/image.d.ts +41 -0
- package/dist/lib/defaults/image.d.ts.map +1 -0
- package/dist/lib/defaults/image.js +45 -0
- package/dist/lib/defaults/image.js.map +1 -0
- package/dist/lib/defaults/link.d.ts +45 -0
- package/dist/lib/defaults/link.d.ts.map +1 -0
- package/dist/lib/defaults/link.js +76 -0
- package/dist/lib/defaults/link.js.map +1 -0
- package/dist/lib/defaults/math.d.ts +51 -0
- package/dist/lib/defaults/math.d.ts.map +1 -0
- package/dist/lib/defaults/math.js +119 -0
- package/dist/lib/defaults/math.js.map +1 -0
- package/dist/lib/defaults/table.d.ts +18 -0
- package/dist/lib/defaults/table.d.ts.map +1 -0
- package/dist/lib/defaults/table.js +19 -0
- package/dist/lib/defaults/table.js.map +1 -0
- package/dist/lib/highlighter.d.ts +81 -0
- package/dist/lib/highlighter.d.ts.map +1 -0
- package/dist/lib/highlighter.js +421 -0
- package/dist/lib/highlighter.js.map +1 -0
- package/dist/lib/hook-utils.d.ts +32 -0
- package/dist/lib/hook-utils.d.ts.map +1 -0
- package/dist/lib/hook-utils.js +42 -0
- package/dist/lib/hook-utils.js.map +1 -0
- package/dist/lib/html.d.ts +2 -0
- package/dist/lib/html.d.ts.map +1 -0
- package/dist/lib/html.js +12 -0
- package/dist/lib/html.js.map +1 -0
- package/dist/lib/morph.d.ts +57 -0
- package/dist/lib/morph.d.ts.map +1 -0
- package/dist/lib/morph.js +204 -0
- package/dist/lib/morph.js.map +1 -0
- package/dist/lib/parser.d.ts +32 -0
- package/dist/lib/parser.d.ts.map +1 -0
- package/dist/lib/parser.js +645 -0
- package/dist/lib/parser.js.map +1 -0
- package/dist/lib/wasm-init.d.ts +33 -0
- package/dist/lib/wasm-init.d.ts.map +1 -0
- package/dist/lib/wasm-init.js +69 -0
- package/dist/lib/wasm-init.js.map +1 -0
- package/dist/styles/alerts.css +294 -0
- package/dist/styles/dotted.svg +3 -0
- package/dist/styles/hljs.css +332 -0
- package/dist/styles/index.css +17 -0
- package/dist/styles/katex.css +74 -0
- package/dist/styles/viewer.css +975 -0
- package/dist/types/hooks.d.ts +207 -0
- package/dist/types/hooks.d.ts.map +1 -0
- package/dist/types/hooks.js +7 -0
- package/dist/types/hooks.js.map +1 -0
- package/dist/useMarkdownViewer.d.ts +18 -0
- package/dist/useMarkdownViewer.d.ts.map +1 -0
- package/dist/useMarkdownViewer.js +403 -0
- package/dist/useMarkdownViewer.js.map +1 -0
- package/dist/utils.d.ts +20 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +18 -0
- package/dist/utils.js.map +1 -0
- package/package.json +78 -0
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
import { decodeHtml } from "./html";
|
|
2
|
+
import { mdToHtml, healMarkdown } from "@typefm/comrak-wasm";
|
|
3
|
+
import { isWasmReady } from "./wasm-init";
|
|
4
|
+
import { cacheManager } from "./cache-manager";
|
|
5
|
+
import { highlight, onLanguageLoaded, offLanguageLoaded, getNotificationGeneration } from "./highlighter";
|
|
6
|
+
// Import default processors
|
|
7
|
+
import { injectColorPreviews, COPY_ICON, CHECK_ICON } from "./defaults/code-block";
|
|
8
|
+
import { processMathBlock as defaultProcessMathBlock, preloadKaTeX as defaultPreloadKaTeX, isKaTeXReady as defaultIsKaTeXReady, ensureKaTeXLoading, } from "./defaults/math";
|
|
9
|
+
import { processTable as defaultProcessTable } from "./defaults/table";
|
|
10
|
+
// Re-export for useMarkdownViewer
|
|
11
|
+
export { onLanguageLoaded, offLanguageLoaded, getNotificationGeneration };
|
|
12
|
+
// Re-export KaTeX utilities
|
|
13
|
+
export const preloadKaTeX = defaultPreloadKaTeX;
|
|
14
|
+
export const isKaTeXReady = defaultIsKaTeXReady;
|
|
15
|
+
/**
|
|
16
|
+
* Cursor marker string and HTML replacement
|
|
17
|
+
* Word Joiner (U+2060) - invisible character used to mark cursor position.
|
|
18
|
+
* We use WJ instead of ZWSP (U+200B) because source markdown may contain ZWSP
|
|
19
|
+
* which gets escaped to HTML entities. WJ is rarely used in source text.
|
|
20
|
+
*/
|
|
21
|
+
export const CURSOR_MARKER = "\u2060";
|
|
22
|
+
export const CURSOR_HTML = "<span class='cursor' data-cursor></span>";
|
|
23
|
+
// --------------------------------------------------------------------------
|
|
24
|
+
// HTML Sanitizer
|
|
25
|
+
// --------------------------------------------------------------------------
|
|
26
|
+
/**
|
|
27
|
+
* Tags to completely remove (with content).
|
|
28
|
+
* These are dangerous tags not covered by comrak's tagfilter.
|
|
29
|
+
* Note: 'input' is NOT included because task list checkboxes use <input type="checkbox">
|
|
30
|
+
*/
|
|
31
|
+
const DANGEROUS_TAGS_REMOVE = new Set([
|
|
32
|
+
"object",
|
|
33
|
+
"embed",
|
|
34
|
+
"form",
|
|
35
|
+
"button",
|
|
36
|
+
"select",
|
|
37
|
+
"meta",
|
|
38
|
+
"link",
|
|
39
|
+
"base",
|
|
40
|
+
"applet",
|
|
41
|
+
"frame",
|
|
42
|
+
"frameset",
|
|
43
|
+
"layer",
|
|
44
|
+
"ilayer",
|
|
45
|
+
"bgsound",
|
|
46
|
+
"xml",
|
|
47
|
+
"blink",
|
|
48
|
+
"marquee",
|
|
49
|
+
]);
|
|
50
|
+
/**
|
|
51
|
+
* Regex to match dangerous input types (not checkbox which is used for task lists)
|
|
52
|
+
*/
|
|
53
|
+
const DANGEROUS_INPUT_RE = /<input\s+(?![^>]*type\s*=\s*["']?checkbox["']?)[^>]*>/gi;
|
|
54
|
+
/**
|
|
55
|
+
* Tags to unwrap (keep content, remove tag).
|
|
56
|
+
* These can contain malicious attributes but their content is usually safe.
|
|
57
|
+
*/
|
|
58
|
+
const DANGEROUS_TAGS_UNWRAP = new Set(["noscript", "template"]);
|
|
59
|
+
/** Regex to match opening tags with attributes: <tagname ...> */
|
|
60
|
+
const TAG_WITH_ATTRS_RE = /<([a-zA-Z][a-zA-Z0-9]*)\s+([^>]*)>/gi;
|
|
61
|
+
/** Regex to match self-closing tags: <tagname ... /> */
|
|
62
|
+
const SELF_CLOSING_TAG_RE = /<([a-zA-Z][a-zA-Z0-9]*)\s+([^>]*)\s*\/>/gi;
|
|
63
|
+
/** Regex to match event handler attributes (on*) */
|
|
64
|
+
const EVENT_HANDLER_RE = /(?:^|\s+)on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi;
|
|
65
|
+
/** Regex to match dangerous URL schemes in href/src/srcset/action/formaction/data/xlink:href */
|
|
66
|
+
const DANGEROUS_URL_ATTR_RE = /(?:^|\s+)(href|src|srcset|action|formaction|data|xlink:href)\s*=\s*(?:"[^"]*(?:javascript|vbscript|data|file):[^"]*"|'[^']*(?:javascript|vbscript|data|file):[^']*'|[^\s>]*(?:javascript|vbscript|data|file):[^\s>]*)/gi;
|
|
67
|
+
/** Regex to match style attributes with dangerous content */
|
|
68
|
+
const DANGEROUS_STYLE_RE = /(?:^|\s+)style\s*=\s*["'][^"']*(?:expression|javascript|behavior|binding|@import|url\s*\()[^"']*["']/gi;
|
|
69
|
+
/** Regex to match DOM clobbering via name/id attributes with dangerous values */
|
|
70
|
+
const DOM_CLOBBERING_RE = /(?:^|\s+)(?:name|id)\s*=\s*["']?(document|window|location|self|top|parent|frames|opener|navigator|history|screen|alert|confirm|prompt|eval|Function|constructor|prototype|__proto__|hasOwnProperty|toString|valueOf|href|src|cookie|domain)["']?/gi;
|
|
71
|
+
/** Regex to match SVG elements with dangerous content */
|
|
72
|
+
const SVG_DANGEROUS_RE = /<svg[^>]*>[\s\S]*?(?:<script|on[a-z]+=|javascript:|attributeName\s*=\s*["']on)[\s\S]*?<\/svg>/gi;
|
|
73
|
+
/** Pre-compiled regexes for dangerous tag removal */
|
|
74
|
+
const DANGEROUS_TAG_REMOVAL_REGEXES = [...DANGEROUS_TAGS_REMOVE].map((tag) => new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>|<${tag}\\b[^>]*\\/?>`, "gi"));
|
|
75
|
+
/** Pre-compiled regexes for dangerous tag unwrapping (keep content, remove tag) */
|
|
76
|
+
const DANGEROUS_TAG_UNWRAP_REGEXES = [...DANGEROUS_TAGS_UNWRAP].map((tag) => new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, "gi"));
|
|
77
|
+
/**
|
|
78
|
+
* Sanitize HTML to remove XSS vectors while preserving safe content.
|
|
79
|
+
*/
|
|
80
|
+
export function sanitizeHtml(html) {
|
|
81
|
+
if (!html)
|
|
82
|
+
return html;
|
|
83
|
+
let result = html;
|
|
84
|
+
// 1. Remove dangerous SVG elements with scripts
|
|
85
|
+
result = result.replace(SVG_DANGEROUS_RE, "");
|
|
86
|
+
// 2. Remove dangerous tags completely (with content)
|
|
87
|
+
for (const regex of DANGEROUS_TAG_REMOVAL_REGEXES) {
|
|
88
|
+
regex.lastIndex = 0;
|
|
89
|
+
result = result.replace(regex, "");
|
|
90
|
+
}
|
|
91
|
+
// 3. Unwrap dangerous tags (keep content)
|
|
92
|
+
for (const regex of DANGEROUS_TAG_UNWRAP_REGEXES) {
|
|
93
|
+
regex.lastIndex = 0;
|
|
94
|
+
result = result.replace(regex, "$1");
|
|
95
|
+
}
|
|
96
|
+
// 3.5. Remove dangerous input types (but preserve checkbox for task lists)
|
|
97
|
+
result = result.replace(DANGEROUS_INPUT_RE, "");
|
|
98
|
+
// 4. Clean attributes on opening and self-closing tags
|
|
99
|
+
const cleanAttrs = (attrs) => attrs
|
|
100
|
+
.replace(EVENT_HANDLER_RE, "")
|
|
101
|
+
.replace(DANGEROUS_URL_ATTR_RE, ' $1="#"')
|
|
102
|
+
.replace(DANGEROUS_STYLE_RE, "")
|
|
103
|
+
.replace(DOM_CLOBBERING_RE, "")
|
|
104
|
+
.trim();
|
|
105
|
+
result = result.replace(TAG_WITH_ATTRS_RE, (_match, tagName, attrs) => {
|
|
106
|
+
const clean = cleanAttrs(attrs);
|
|
107
|
+
return clean ? `<${tagName} ${clean}>` : `<${tagName}>`;
|
|
108
|
+
});
|
|
109
|
+
result = result.replace(SELF_CLOSING_TAG_RE, (_match, tagName, attrs) => {
|
|
110
|
+
const clean = cleanAttrs(attrs);
|
|
111
|
+
return clean ? `<${tagName} ${clean} />` : `<${tagName} />`;
|
|
112
|
+
});
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
// --------------------------------------------------------------------------
|
|
116
|
+
// Hoisted Regex Patterns
|
|
117
|
+
// --------------------------------------------------------------------------
|
|
118
|
+
/** Matches <pre><code>...</code></pre> and inline <code>...</code> */
|
|
119
|
+
const CODE_AND_FENCE_RE = /(<pre[^>]*>)?(<code[^>]*>)([\s\S]*?)(<\/code>)(<\/pre>)?/g;
|
|
120
|
+
/** Matches <span data-math-style="inline|display">...</span> */
|
|
121
|
+
const MATH_SPAN_RE = /<span data-math-style="(inline|display)">([\s\S]*?)<\/span>/g;
|
|
122
|
+
/** Matches <table>...</table> */
|
|
123
|
+
const TABLE_RE = /(<table>[\s\S]*?<\/table>)/g;
|
|
124
|
+
/** Matches <a ...> tags (opening tag only) */
|
|
125
|
+
const ANCHOR_TAG_RE = /<a\s+([^>]+)>/gi;
|
|
126
|
+
/** Matches full anchor tags: <a href="...">text</a> */
|
|
127
|
+
const FULL_ANCHOR_RE = /<a\s+([^>]+)>([\s\S]*?)<\/a>/gi;
|
|
128
|
+
/** Matches <img ...> tags */
|
|
129
|
+
const IMG_TAG_RE = /<img\s+([^>]*)>/gi;
|
|
130
|
+
/** Extracts title attribute value */
|
|
131
|
+
const TITLE_ATTR_RE = /title\s*=\s*["']([^"']*)["']/i;
|
|
132
|
+
/** Regex to extract language from code tag class attribute */
|
|
133
|
+
const LANGUAGE_CLASS_RE = /class="[^"]*language-(\w+)[^"]*"/;
|
|
134
|
+
/** Matches heading tags h1-h6 */
|
|
135
|
+
const HEADING_RE = /<h([1-6])([^>]*)>([\s\S]*?)<\/h\1>/gi;
|
|
136
|
+
/** Matches blockquote tags */
|
|
137
|
+
const BLOCKQUOTE_RE = /<blockquote>([\s\S]*?)<\/blockquote>/gi;
|
|
138
|
+
/** Matches GitHub-style alert divs */
|
|
139
|
+
const ALERT_RE = /<div class="markdown-alert markdown-alert-(\w+)"[^>]*>\s*<p class="markdown-alert-title"[^>]*>([^<]*)<\/p>([\s\S]*?)<\/div>/gi;
|
|
140
|
+
/** Matches ordered and unordered lists */
|
|
141
|
+
const LIST_RE = /<(ul|ol)>([\s\S]*?)<\/\1>/gi;
|
|
142
|
+
/** Matches horizontal rule tags */
|
|
143
|
+
const HR_RE = /<hr\s*\/?>/gi;
|
|
144
|
+
/** Matches footnote references */
|
|
145
|
+
const FOOTNOTE_REF_RE = /<sup class="footnote-ref"><a href="#([^"]+)"[^>]*>(\d+)<\/a><\/sup>/gi;
|
|
146
|
+
/** Matches footnote definitions in the footnotes section */
|
|
147
|
+
const FOOTNOTE_DEF_RE = /<li id="([^"]+)">([\s\S]*?)<\/li>/gi;
|
|
148
|
+
/** Zero-width characters that can interfere with cursor marker (U+2060) */
|
|
149
|
+
const ZERO_WIDTH_CHARS_RE = /[\u200B\u200C\u200E\u200F\uFEFF]/g;
|
|
150
|
+
/** Comrak options — hoisted to avoid re-allocation on every renderMarkdown call. */
|
|
151
|
+
const COMRAK_OPTIONS = {
|
|
152
|
+
extension: {
|
|
153
|
+
strikethrough: true,
|
|
154
|
+
tagfilter: true,
|
|
155
|
+
table: true,
|
|
156
|
+
autolink: true,
|
|
157
|
+
tasklist: true,
|
|
158
|
+
superscript: true,
|
|
159
|
+
subscript: false,
|
|
160
|
+
alerts: true,
|
|
161
|
+
mathDollars: true,
|
|
162
|
+
underline: false,
|
|
163
|
+
headerIdPrefix: "",
|
|
164
|
+
shortcodes: true,
|
|
165
|
+
descriptionLists: true,
|
|
166
|
+
footnotes: true,
|
|
167
|
+
},
|
|
168
|
+
parse: {
|
|
169
|
+
smart: true,
|
|
170
|
+
ignoreSetext: true,
|
|
171
|
+
},
|
|
172
|
+
render: {
|
|
173
|
+
unsafe: true,
|
|
174
|
+
escape: false,
|
|
175
|
+
hardbreaks: true,
|
|
176
|
+
tasklistClasses: true,
|
|
177
|
+
ignoreEmptyLinks: true,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
// --------------------------------------------------------------------------
|
|
181
|
+
// Cursor + healMarkdown Integration
|
|
182
|
+
// --------------------------------------------------------------------------
|
|
183
|
+
/**
|
|
184
|
+
* Insert cursor marker into healed markdown at the correct position.
|
|
185
|
+
*
|
|
186
|
+
* healMarkdown may append closing delimiters (e.g., "**", "\n```", "$$").
|
|
187
|
+
* The cursor must appear at the user's writing position (end of original text),
|
|
188
|
+
* without breaking block-level closers that require their own line.
|
|
189
|
+
*/
|
|
190
|
+
function insertCursorIntoHealed(original, healed) {
|
|
191
|
+
const suffix = healed.slice(original.length);
|
|
192
|
+
if (suffix.length === 0) {
|
|
193
|
+
// No healing — append cursor.
|
|
194
|
+
// Some block constructs break if cursor is appended inline:
|
|
195
|
+
// - closing fences (```, $$) must be on their own line
|
|
196
|
+
if (original.endsWith("```") || original.endsWith("$$")) {
|
|
197
|
+
return original + "\n" + CURSOR_MARKER;
|
|
198
|
+
}
|
|
199
|
+
// Table separator row: cursor inline breaks table parsing.
|
|
200
|
+
// Double newline places cursor after the table as a block element.
|
|
201
|
+
if (/\|\s*[-:]+\s*[-:| ]*$/.test(original)) {
|
|
202
|
+
return original + "\n\n" + CURSOR_MARKER;
|
|
203
|
+
}
|
|
204
|
+
// Trailing inline closing delimiters: cursor directly after them
|
|
205
|
+
// can break comrak's parser (e.g., ~~**bold**~~ fails).
|
|
206
|
+
// Insert cursor before the trailing delimiters instead.
|
|
207
|
+
// Only match actual delimiter sequences (**, ***, ~~, __, not single chars).
|
|
208
|
+
const trailingDelim = /(\*{2,3}|~{2}|_{2,3}|`+)$/.exec(original);
|
|
209
|
+
if (trailingDelim) {
|
|
210
|
+
const delimStart = original.length - trailingDelim[1].length;
|
|
211
|
+
return (original.slice(0, delimStart) +
|
|
212
|
+
CURSOR_MARKER +
|
|
213
|
+
original.slice(delimStart));
|
|
214
|
+
}
|
|
215
|
+
return original + CURSOR_MARKER;
|
|
216
|
+
}
|
|
217
|
+
// Block-level closers (```, $$) must be on their own line.
|
|
218
|
+
// Suffix patterns from healMarkdown:
|
|
219
|
+
// "\n```" — newline + closing fence (content didn't end with \n)
|
|
220
|
+
// "```" — closing fence only (content already ended with \n)
|
|
221
|
+
// "\n$$" — newline + closing math
|
|
222
|
+
// "$$" — closing math only
|
|
223
|
+
// "**", "*", "`", "~~", etc. — inline closers
|
|
224
|
+
if (suffix.startsWith("\n```") || suffix.startsWith("\n$$")) {
|
|
225
|
+
// Cursor right after original content. If original has content after
|
|
226
|
+
// the opening fence (contains \n), cursor goes inline. If it's just
|
|
227
|
+
// the opening fence with no content, cursor needs its own line inside.
|
|
228
|
+
if (original.includes("\n")) {
|
|
229
|
+
return original + CURSOR_MARKER + suffix;
|
|
230
|
+
}
|
|
231
|
+
// Bare opening fence (e.g., "```") — cursor on new line inside block
|
|
232
|
+
return original + "\n" + CURSOR_MARKER + suffix;
|
|
233
|
+
}
|
|
234
|
+
if (suffix.startsWith("```") || suffix.startsWith("$$")) {
|
|
235
|
+
// Original ends with \n already. Add cursor, then newline + fence.
|
|
236
|
+
return original + CURSOR_MARKER + "\n" + suffix;
|
|
237
|
+
}
|
|
238
|
+
// Link/image closers: cursor goes after (inside URL would break it)
|
|
239
|
+
if (suffix === ")") {
|
|
240
|
+
return original + suffix + CURSOR_MARKER;
|
|
241
|
+
}
|
|
242
|
+
// Combine the end of original with suffix to find the full closing
|
|
243
|
+
// delimiter sequence (e.g., original ends "~", suffix "~" → "~~").
|
|
244
|
+
// Cursor goes before the entire delimiter to avoid breaking comrak.
|
|
245
|
+
const fullEnd = original + suffix;
|
|
246
|
+
const trailingDelim = /(\*{2,3}|~{2}|_{2,3}|`+)$/.exec(fullEnd);
|
|
247
|
+
if (trailingDelim) {
|
|
248
|
+
const pos = fullEnd.length - trailingDelim[1].length;
|
|
249
|
+
return fullEnd.slice(0, pos) + CURSOR_MARKER + fullEnd.slice(pos);
|
|
250
|
+
}
|
|
251
|
+
// Simple inline closers — cursor goes before suffix
|
|
252
|
+
return original + CURSOR_MARKER + suffix;
|
|
253
|
+
}
|
|
254
|
+
// --------------------------------------------------------------------------
|
|
255
|
+
// Main Render Function
|
|
256
|
+
// --------------------------------------------------------------------------
|
|
257
|
+
/**
|
|
258
|
+
* Render Markdown to HTML using @typefm/comrak-wasm + Post-processing.
|
|
259
|
+
*
|
|
260
|
+
* @param src Markdown source
|
|
261
|
+
* @param useSyncStrategy If true, uses sync morph path (streaming).
|
|
262
|
+
* @param colorOptions Options for color preview injection.
|
|
263
|
+
* @param hooks Optional hooks for customizing rendering.
|
|
264
|
+
*/
|
|
265
|
+
export function renderMarkdown(src, useSyncStrategy, colorOptions = { fences: true, inline: true }, hooks) {
|
|
266
|
+
if (!src)
|
|
267
|
+
return "";
|
|
268
|
+
// Guard: WASM must be initialized
|
|
269
|
+
if (!isWasmReady())
|
|
270
|
+
return "";
|
|
271
|
+
// Check global cache for repeated content
|
|
272
|
+
const renderCache = useSyncStrategy
|
|
273
|
+
? cacheManager.renderCacheSync
|
|
274
|
+
: cacheManager.renderCacheAsync;
|
|
275
|
+
if (!hooks) {
|
|
276
|
+
const cached = renderCache.get(src);
|
|
277
|
+
if (cached !== undefined) {
|
|
278
|
+
return cached;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// 1. Pre-process: Use healMarkdown to close unclosed delimiters during streaming.
|
|
282
|
+
// Strip cursor marker, heal, then insert cursor at the user's writing position.
|
|
283
|
+
// Cursor must not break closing delimiters (e.g., ``` must be on its own line).
|
|
284
|
+
const hasCursorMarker = src.includes(CURSOR_MARKER);
|
|
285
|
+
let source;
|
|
286
|
+
if (hasCursorMarker) {
|
|
287
|
+
const stripped = src.replace(CURSOR_MARKER, "");
|
|
288
|
+
const healed = healMarkdown(stripped);
|
|
289
|
+
// healMarkdown may strip a trailing single space before healing.
|
|
290
|
+
// Match that here so insertCursorIntoHealed's suffix calculation
|
|
291
|
+
// uses the same base length as the healed string.
|
|
292
|
+
const base = stripped.endsWith(" ") && !stripped.endsWith(" ")
|
|
293
|
+
? stripped.slice(0, -1)
|
|
294
|
+
: stripped;
|
|
295
|
+
source = insertCursorIntoHealed(base, healed);
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
source = src;
|
|
299
|
+
}
|
|
300
|
+
// 2. Comrak Render
|
|
301
|
+
let html = mdToHtml(source, COMRAK_OPTIONS);
|
|
302
|
+
// 3. Escape zero-width characters in HTML output
|
|
303
|
+
ZERO_WIDTH_CHARS_RE.lastIndex = 0;
|
|
304
|
+
html = html.replace(ZERO_WIDTH_CHARS_RE, (char) => `&#${char.charCodeAt(0)};`);
|
|
305
|
+
// 4. Sanitize HTML (XSS prevention)
|
|
306
|
+
html = sanitizeHtml(html);
|
|
307
|
+
// 5. Post-process Pipeline
|
|
308
|
+
// Math (KaTeX)
|
|
309
|
+
html = processMath(html, hooks?.onMath);
|
|
310
|
+
// Unwrap display math from <p> wrapper
|
|
311
|
+
html = html.replace(/<p>(\s*<span class="math-placeholder" data-math-style="display">[\s\S]*?<\/span>\s*)(\u2060)?<\/p>/g, "$1$2");
|
|
312
|
+
html = html.replace(/<p>(\s*<span class="katex-display">[\s\S]*?<\/span>\s*)(\u2060)?<\/p>/g, "$1$2");
|
|
313
|
+
// Code & Colors
|
|
314
|
+
html = processCodeAndColors(html, useSyncStrategy, colorOptions, hooks?.onCodeBlock, hooks?.onInlineCode);
|
|
315
|
+
// Tables
|
|
316
|
+
html = processTables(html, hooks?.onTable);
|
|
317
|
+
// Links
|
|
318
|
+
html = processLinks(html, hooks?.onLink);
|
|
319
|
+
// Images
|
|
320
|
+
html = processImages(html, hooks?.onImage);
|
|
321
|
+
// Headings
|
|
322
|
+
html = processHeadings(html, hooks?.onHeading);
|
|
323
|
+
// Blockquotes
|
|
324
|
+
html = processBlockquotes(html, hooks?.onBlockquote);
|
|
325
|
+
// Alerts
|
|
326
|
+
html = processAlerts(html, hooks?.onAlert);
|
|
327
|
+
// Lists
|
|
328
|
+
html = processLists(html, hooks?.onList);
|
|
329
|
+
// Horizontal rules
|
|
330
|
+
html = processHorizontalRules(html, hooks?.onHorizontalRule);
|
|
331
|
+
// Footnotes
|
|
332
|
+
html = processFootnoteRefs(html, hooks?.onFootnoteRef);
|
|
333
|
+
html = processFootnoteDefs(html, hooks?.onFootnoteDef);
|
|
334
|
+
// Final transformation hook
|
|
335
|
+
if (hooks?.onRender) {
|
|
336
|
+
html = hooks.onRender(html);
|
|
337
|
+
}
|
|
338
|
+
// 6. Inject Cursor
|
|
339
|
+
if (html.includes(CURSOR_MARKER)) {
|
|
340
|
+
html = html.replaceAll(CURSOR_MARKER, CURSOR_HTML);
|
|
341
|
+
// Fix empty paragraph wrapper around cursor
|
|
342
|
+
html = html.replace(/\n?<p>\s*(<span[^>]*data-cursor[^>]*><\/span>)\s*<\/p>\n?/g, "$1");
|
|
343
|
+
}
|
|
344
|
+
// Cache the result (skip transient content)
|
|
345
|
+
if (!hooks &&
|
|
346
|
+
!html.includes(CURSOR_HTML) &&
|
|
347
|
+
!html.includes("math-placeholder")) {
|
|
348
|
+
renderCache.set(src, html);
|
|
349
|
+
}
|
|
350
|
+
return html;
|
|
351
|
+
}
|
|
352
|
+
// --------------------------------------------------------------------------
|
|
353
|
+
// Post-Processors
|
|
354
|
+
// --------------------------------------------------------------------------
|
|
355
|
+
function processCodeAndColors(html, useSyncStrategy, options, onCodeBlock, onInlineCode) {
|
|
356
|
+
if (!html.includes("<code"))
|
|
357
|
+
return html;
|
|
358
|
+
return html.replace(CODE_AND_FENCE_RE, (_match, preOpen, codeOpen, content, codeClose, preClose) => {
|
|
359
|
+
const isBlock = !!preOpen && !!preClose;
|
|
360
|
+
const doColors = (isBlock && options.fences) || (!isBlock && options.inline);
|
|
361
|
+
const hasCursor = content.includes(CURSOR_MARKER);
|
|
362
|
+
const cleanContent = hasCursor
|
|
363
|
+
? content.replaceAll(CURSOR_MARKER, "")
|
|
364
|
+
: content;
|
|
365
|
+
// 1. Block Code Processing
|
|
366
|
+
if (isBlock) {
|
|
367
|
+
const langMatch = LANGUAGE_CLASS_RE.exec(codeOpen);
|
|
368
|
+
const language = langMatch?.[1];
|
|
369
|
+
const decodedContent = decodeHtml(cleanContent);
|
|
370
|
+
let code = decodedContent;
|
|
371
|
+
if (code.endsWith("\n")) {
|
|
372
|
+
code = code.slice(0, -1);
|
|
373
|
+
}
|
|
374
|
+
if (onCodeBlock) {
|
|
375
|
+
const hookData = { code, language };
|
|
376
|
+
const hookResult = (onCodeBlock(hookData));
|
|
377
|
+
if (hookResult !== null) {
|
|
378
|
+
return hasCursor ? hookResult + CURSOR_MARKER : hookResult;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
let highlightedContent = highlight(code, language);
|
|
382
|
+
if (doColors) {
|
|
383
|
+
highlightedContent = injectColorPreviews(highlightedContent);
|
|
384
|
+
}
|
|
385
|
+
if (useSyncStrategy && highlightedContent) {
|
|
386
|
+
highlightedContent = highlightedContent.replace(/^(.*)$/gm, '<span class="code-line">$1</span>');
|
|
387
|
+
}
|
|
388
|
+
if (hasCursor) {
|
|
389
|
+
highlightedContent += CURSOR_MARKER;
|
|
390
|
+
}
|
|
391
|
+
const codeBlock = `${preOpen}${codeOpen}${highlightedContent}${codeClose}${preClose}`;
|
|
392
|
+
return `<div class="code-block-wrapper"><button type="button" class="copy-btn" aria-label="Copy code">${COPY_ICON}${CHECK_ICON}</button>${codeBlock}</div>`;
|
|
393
|
+
}
|
|
394
|
+
// 2. Inline Code Processing
|
|
395
|
+
if (onInlineCode) {
|
|
396
|
+
const hookData = { code: cleanContent };
|
|
397
|
+
const hookResult = (onInlineCode(hookData));
|
|
398
|
+
if (hookResult !== null) {
|
|
399
|
+
return hasCursor ? hookResult + CURSOR_MARKER : hookResult;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
let finalContent = cleanContent;
|
|
403
|
+
if (doColors) {
|
|
404
|
+
finalContent = injectColorPreviews(finalContent);
|
|
405
|
+
}
|
|
406
|
+
if (hasCursor) {
|
|
407
|
+
finalContent += CURSOR_MARKER;
|
|
408
|
+
}
|
|
409
|
+
return `${preOpen || ""}${codeOpen}${finalContent}${codeClose}${preClose || ""}`;
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
function processMath(html, onMath) {
|
|
413
|
+
if (!html.includes("data-math-style"))
|
|
414
|
+
return html;
|
|
415
|
+
ensureKaTeXLoading();
|
|
416
|
+
return html.replace(MATH_SPAN_RE, (_match, style, content) => {
|
|
417
|
+
const displayMode = style === "display";
|
|
418
|
+
const hasCursor = content.includes(CURSOR_MARKER);
|
|
419
|
+
const cleanContent = hasCursor
|
|
420
|
+
? content.replaceAll(CURSOR_MARKER, "")
|
|
421
|
+
: content;
|
|
422
|
+
const tex = decodeHtml(cleanContent);
|
|
423
|
+
if (onMath) {
|
|
424
|
+
const hookData = { tex, displayMode };
|
|
425
|
+
const hookResult = (onMath(hookData));
|
|
426
|
+
if (hookResult !== null) {
|
|
427
|
+
return hasCursor ? hookResult + CURSOR_MARKER : hookResult;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const result = defaultProcessMathBlock({
|
|
431
|
+
tex: cleanContent,
|
|
432
|
+
displayMode,
|
|
433
|
+
});
|
|
434
|
+
return hasCursor ? result + CURSOR_MARKER : result;
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
function parseTableData(tableHtml) {
|
|
438
|
+
const headerMatches = tableHtml.match(/<th[^>]*>([\s\S]*?)<\/th>/gi);
|
|
439
|
+
const headers = headerMatches?.map((th) => th.replace(/<[^>]+>/g, "").trim());
|
|
440
|
+
const tbodyMatch = tableHtml.match(/<tbody[^>]*>([\s\S]*?)<\/tbody>/i);
|
|
441
|
+
if (!tbodyMatch)
|
|
442
|
+
return { headers };
|
|
443
|
+
const rowMatches = tbodyMatch[1].match(/<tr[^>]*>([\s\S]*?)<\/tr>/gi);
|
|
444
|
+
const rows = rowMatches?.map((tr) => {
|
|
445
|
+
const cellMatches = tr.match(/<td[^>]*>([\s\S]*?)<\/td>/gi);
|
|
446
|
+
return cellMatches?.map((td) => td.replace(/<[^>]+>/g, "").trim()) || [];
|
|
447
|
+
});
|
|
448
|
+
return { headers, rows };
|
|
449
|
+
}
|
|
450
|
+
function processTables(html, onTable) {
|
|
451
|
+
if (!html.includes("<table"))
|
|
452
|
+
return html;
|
|
453
|
+
return html.replace(TABLE_RE, (_match, tableHtml) => {
|
|
454
|
+
if (onTable) {
|
|
455
|
+
const { headers, rows } = parseTableData(tableHtml);
|
|
456
|
+
const hookData = { html: tableHtml, headers, rows };
|
|
457
|
+
const hookResult = (onTable(hookData));
|
|
458
|
+
if (hookResult !== null) {
|
|
459
|
+
return hookResult;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return defaultProcessTable({ html: tableHtml });
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
/** Matches javascript: hrefs for sanitization */
|
|
466
|
+
const JAVASCRIPT_HREF_RE = /href\s*=\s*["']\s*javascript:[^"']*["']/gi;
|
|
467
|
+
/** Extracts href value from attributes */
|
|
468
|
+
const HREF_VALUE_RE = /href\s*=\s*["']([^"']*)["']/i;
|
|
469
|
+
function processLinks(html, onLink) {
|
|
470
|
+
if (!html.includes("<a"))
|
|
471
|
+
return html;
|
|
472
|
+
let result = html;
|
|
473
|
+
if (onLink) {
|
|
474
|
+
result = result.replace(FULL_ANCHOR_RE, (_match, attributes, content) => {
|
|
475
|
+
const hrefMatch = HREF_VALUE_RE.exec(attributes);
|
|
476
|
+
const href = hrefMatch ? hrefMatch[1] : "";
|
|
477
|
+
const titleMatch = TITLE_ATTR_RE.exec(attributes);
|
|
478
|
+
const title = titleMatch ? titleMatch[1] : undefined;
|
|
479
|
+
const text = content.replace(/<[^>]*>/g, "");
|
|
480
|
+
const hookData = { href, text, title };
|
|
481
|
+
const hookResult = (onLink(hookData));
|
|
482
|
+
if (hookResult !== null) {
|
|
483
|
+
return hookResult;
|
|
484
|
+
}
|
|
485
|
+
return _match;
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
// Default processing: sanitize and add security attributes
|
|
489
|
+
return result.replace(ANCHOR_TAG_RE, (_match, attributes) => {
|
|
490
|
+
let newAttributes = attributes.replace(JAVASCRIPT_HREF_RE, 'href="#"');
|
|
491
|
+
const hrefMatch = HREF_VALUE_RE.exec(newAttributes);
|
|
492
|
+
if (hrefMatch) {
|
|
493
|
+
const href = hrefMatch[1];
|
|
494
|
+
if (href && !href.startsWith("#") && !href.startsWith("/")) {
|
|
495
|
+
if (!newAttributes.includes("target=")) {
|
|
496
|
+
newAttributes += ' target="_blank" rel="noopener noreferrer"';
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return `<a ${newAttributes}>`;
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
/** Extracts src attribute value */
|
|
504
|
+
const SRC_VALUE_RE = /src\s*=\s*["']([^"']*)["']/i;
|
|
505
|
+
/** Extracts alt attribute value */
|
|
506
|
+
const ALT_VALUE_RE = /alt\s*=\s*["']([^"']*)["']/i;
|
|
507
|
+
function processImages(html, onImage) {
|
|
508
|
+
if (!onImage || !html.includes("<img"))
|
|
509
|
+
return html;
|
|
510
|
+
return html.replace(IMG_TAG_RE, (match, attributes) => {
|
|
511
|
+
const srcMatch = SRC_VALUE_RE.exec(attributes);
|
|
512
|
+
const src = srcMatch ? srcMatch[1] : "";
|
|
513
|
+
const altMatch = ALT_VALUE_RE.exec(attributes);
|
|
514
|
+
const alt = altMatch ? altMatch[1] : "";
|
|
515
|
+
const titleMatch = TITLE_ATTR_RE.exec(attributes);
|
|
516
|
+
const title = titleMatch ? titleMatch[1] : undefined;
|
|
517
|
+
const hookData = { src, alt, title };
|
|
518
|
+
const hookResult = (onImage(hookData));
|
|
519
|
+
if (hookResult !== null) {
|
|
520
|
+
return hookResult;
|
|
521
|
+
}
|
|
522
|
+
return match;
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
function slugify(text) {
|
|
526
|
+
return text
|
|
527
|
+
.toLowerCase()
|
|
528
|
+
.replace(/<[^>]*>/g, "")
|
|
529
|
+
.replace(/[^\w\s-]/g, "")
|
|
530
|
+
.replace(/\s+/g, "-")
|
|
531
|
+
.replace(/--+/g, "-")
|
|
532
|
+
.replace(/^-+/, "")
|
|
533
|
+
.replace(/-+$/, "");
|
|
534
|
+
}
|
|
535
|
+
function processHeadings(html, onHeading) {
|
|
536
|
+
if (!onHeading || !/<h[1-6]/i.test(html))
|
|
537
|
+
return html;
|
|
538
|
+
return html.replace(HEADING_RE, (match, level, _attrs, content) => {
|
|
539
|
+
const levelNum = parseInt(level, 10);
|
|
540
|
+
const text = content.replace(/<[^>]*>/g, "").trim();
|
|
541
|
+
const id = slugify(text);
|
|
542
|
+
const hookData = {
|
|
543
|
+
level: levelNum,
|
|
544
|
+
text,
|
|
545
|
+
id,
|
|
546
|
+
html: content,
|
|
547
|
+
};
|
|
548
|
+
const hookResult = (onHeading(hookData));
|
|
549
|
+
if (hookResult !== null) {
|
|
550
|
+
return hookResult;
|
|
551
|
+
}
|
|
552
|
+
return match;
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
function processBlockquotes(html, onBlockquote) {
|
|
556
|
+
if (!onBlockquote || !html.includes("<blockquote>"))
|
|
557
|
+
return html;
|
|
558
|
+
return html.replace(BLOCKQUOTE_RE, (match, content) => {
|
|
559
|
+
const hookData = { content: content.trim() };
|
|
560
|
+
const hookResult = (onBlockquote(hookData));
|
|
561
|
+
if (hookResult !== null) {
|
|
562
|
+
return hookResult;
|
|
563
|
+
}
|
|
564
|
+
return match;
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
function processAlerts(html, onAlert) {
|
|
568
|
+
if (!onAlert || !html.includes("markdown-alert"))
|
|
569
|
+
return html;
|
|
570
|
+
return html.replace(ALERT_RE, (match, type, title, content) => {
|
|
571
|
+
const alertType = type.toLowerCase();
|
|
572
|
+
const hookData = {
|
|
573
|
+
type: alertType,
|
|
574
|
+
title: title.trim(),
|
|
575
|
+
content: content.trim(),
|
|
576
|
+
};
|
|
577
|
+
const hookResult = (onAlert(hookData));
|
|
578
|
+
if (hookResult !== null) {
|
|
579
|
+
return hookResult;
|
|
580
|
+
}
|
|
581
|
+
return match;
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
function processLists(html, onList) {
|
|
585
|
+
if (!onList || (!html.includes("<ul>") && !html.includes("<ol>")))
|
|
586
|
+
return html;
|
|
587
|
+
return html.replace(LIST_RE, (match, tag, content) => {
|
|
588
|
+
const type = tag.toLowerCase() === "ol" ? "ordered" : "unordered";
|
|
589
|
+
const items = [];
|
|
590
|
+
const liRegex = /<li[^>]*>([\s\S]*?)<\/li>/gi;
|
|
591
|
+
let liMatch;
|
|
592
|
+
while ((liMatch = liRegex.exec(content)) !== null) {
|
|
593
|
+
items.push(liMatch[1].replace(/<[^>]*>/g, "").trim());
|
|
594
|
+
}
|
|
595
|
+
const hookData = { type, html: match, items };
|
|
596
|
+
const hookResult = (onList(hookData));
|
|
597
|
+
if (hookResult !== null) {
|
|
598
|
+
return hookResult;
|
|
599
|
+
}
|
|
600
|
+
return match;
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
function processHorizontalRules(html, onHorizontalRule) {
|
|
604
|
+
if (!onHorizontalRule || !html.includes("<hr"))
|
|
605
|
+
return html;
|
|
606
|
+
return html.replace(HR_RE, (match) => {
|
|
607
|
+
const hookData = {};
|
|
608
|
+
const hookResult = (onHorizontalRule(hookData));
|
|
609
|
+
if (hookResult !== null) {
|
|
610
|
+
return hookResult;
|
|
611
|
+
}
|
|
612
|
+
return match;
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
function processFootnoteRefs(html, onFootnoteRef) {
|
|
616
|
+
if (!onFootnoteRef || !html.includes("footnote-ref"))
|
|
617
|
+
return html;
|
|
618
|
+
return html.replace(FOOTNOTE_REF_RE, (match, id, index) => {
|
|
619
|
+
const hookData = { id, index: parseInt(index, 10) };
|
|
620
|
+
const hookResult = (onFootnoteRef(hookData));
|
|
621
|
+
if (hookResult !== null) {
|
|
622
|
+
return hookResult;
|
|
623
|
+
}
|
|
624
|
+
return match;
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
function processFootnoteDefs(html, onFootnoteDef) {
|
|
628
|
+
if (!onFootnoteDef || !html.includes('class="footnotes"'))
|
|
629
|
+
return html;
|
|
630
|
+
let index = 0;
|
|
631
|
+
return html.replace(FOOTNOTE_DEF_RE, (match, id, content) => {
|
|
632
|
+
index++;
|
|
633
|
+
const hookData = {
|
|
634
|
+
id,
|
|
635
|
+
index,
|
|
636
|
+
content: content.trim(),
|
|
637
|
+
};
|
|
638
|
+
const hookResult = (onFootnoteDef(hookData));
|
|
639
|
+
if (hookResult !== null) {
|
|
640
|
+
return hookResult;
|
|
641
|
+
}
|
|
642
|
+
return match;
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
//# sourceMappingURL=parser.js.map
|