@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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +217 -0
  3. package/dist/ErrorBoundary.d.ts +25 -0
  4. package/dist/ErrorBoundary.d.ts.map +1 -0
  5. package/dist/ErrorBoundary.js +29 -0
  6. package/dist/ErrorBoundary.js.map +1 -0
  7. package/dist/MarkdownViewer.d.ts +41 -0
  8. package/dist/MarkdownViewer.d.ts.map +1 -0
  9. package/dist/MarkdownViewer.js +69 -0
  10. package/dist/MarkdownViewer.js.map +1 -0
  11. package/dist/index.d.ts +17 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +21 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/lib/cache-manager.d.ts +59 -0
  16. package/dist/lib/cache-manager.d.ts.map +1 -0
  17. package/dist/lib/cache-manager.js +160 -0
  18. package/dist/lib/cache-manager.js.map +1 -0
  19. package/dist/lib/cursor-controller.d.ts +13 -0
  20. package/dist/lib/cursor-controller.d.ts.map +1 -0
  21. package/dist/lib/cursor-controller.js +93 -0
  22. package/dist/lib/cursor-controller.js.map +1 -0
  23. package/dist/lib/defaults/code-block.d.ts +71 -0
  24. package/dist/lib/defaults/code-block.d.ts.map +1 -0
  25. package/dist/lib/defaults/code-block.js +104 -0
  26. package/dist/lib/defaults/code-block.js.map +1 -0
  27. package/dist/lib/defaults/image.d.ts +41 -0
  28. package/dist/lib/defaults/image.d.ts.map +1 -0
  29. package/dist/lib/defaults/image.js +45 -0
  30. package/dist/lib/defaults/image.js.map +1 -0
  31. package/dist/lib/defaults/link.d.ts +45 -0
  32. package/dist/lib/defaults/link.d.ts.map +1 -0
  33. package/dist/lib/defaults/link.js +76 -0
  34. package/dist/lib/defaults/link.js.map +1 -0
  35. package/dist/lib/defaults/math.d.ts +51 -0
  36. package/dist/lib/defaults/math.d.ts.map +1 -0
  37. package/dist/lib/defaults/math.js +119 -0
  38. package/dist/lib/defaults/math.js.map +1 -0
  39. package/dist/lib/defaults/table.d.ts +18 -0
  40. package/dist/lib/defaults/table.d.ts.map +1 -0
  41. package/dist/lib/defaults/table.js +19 -0
  42. package/dist/lib/defaults/table.js.map +1 -0
  43. package/dist/lib/highlighter.d.ts +81 -0
  44. package/dist/lib/highlighter.d.ts.map +1 -0
  45. package/dist/lib/highlighter.js +421 -0
  46. package/dist/lib/highlighter.js.map +1 -0
  47. package/dist/lib/hook-utils.d.ts +32 -0
  48. package/dist/lib/hook-utils.d.ts.map +1 -0
  49. package/dist/lib/hook-utils.js +42 -0
  50. package/dist/lib/hook-utils.js.map +1 -0
  51. package/dist/lib/html.d.ts +2 -0
  52. package/dist/lib/html.d.ts.map +1 -0
  53. package/dist/lib/html.js +12 -0
  54. package/dist/lib/html.js.map +1 -0
  55. package/dist/lib/morph.d.ts +57 -0
  56. package/dist/lib/morph.d.ts.map +1 -0
  57. package/dist/lib/morph.js +204 -0
  58. package/dist/lib/morph.js.map +1 -0
  59. package/dist/lib/parser.d.ts +32 -0
  60. package/dist/lib/parser.d.ts.map +1 -0
  61. package/dist/lib/parser.js +645 -0
  62. package/dist/lib/parser.js.map +1 -0
  63. package/dist/lib/wasm-init.d.ts +33 -0
  64. package/dist/lib/wasm-init.d.ts.map +1 -0
  65. package/dist/lib/wasm-init.js +69 -0
  66. package/dist/lib/wasm-init.js.map +1 -0
  67. package/dist/styles/alerts.css +294 -0
  68. package/dist/styles/dotted.svg +3 -0
  69. package/dist/styles/hljs.css +332 -0
  70. package/dist/styles/index.css +17 -0
  71. package/dist/styles/katex.css +74 -0
  72. package/dist/styles/viewer.css +975 -0
  73. package/dist/types/hooks.d.ts +207 -0
  74. package/dist/types/hooks.d.ts.map +1 -0
  75. package/dist/types/hooks.js +7 -0
  76. package/dist/types/hooks.js.map +1 -0
  77. package/dist/useMarkdownViewer.d.ts +18 -0
  78. package/dist/useMarkdownViewer.d.ts.map +1 -0
  79. package/dist/useMarkdownViewer.js +403 -0
  80. package/dist/useMarkdownViewer.js.map +1 -0
  81. package/dist/utils.d.ts +20 -0
  82. package/dist/utils.d.ts.map +1 -0
  83. package/dist/utils.js +18 -0
  84. package/dist/utils.js.map +1 -0
  85. 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