Full document, spreadsheet, slideshow, and diagram tooling
1/**
2 * Markdown Import Parser
3 *
4 * Uses markdown-it to convert markdown strings to HTML for importing
5 * .md files into the TipTap editor with full formatting preserved.
6 *
7 * Supports: headings, bold, italic, strikethrough, code (inline + blocks),
8 * links, images, blockquotes, lists (bullet, ordered, task via GFM),
9 * horizontal rules, tables (GFM), hard line breaks.
10 */
11
12import MarkdownIt from 'markdown-it';
13import type { Token, Renderer, MarkdownItOptions, MarkdownItPlugin } from 'markdown-it';
14
15// Initialize markdown-it with GFM-like defaults
16const md = new MarkdownIt({
17 html: false, // Disable raw HTML passthrough for security
18 linkify: true, // Auto-convert URLs to links
19 typographer: false, // Keep raw characters (don't smart-quote)
20 breaks: false, // Standard CommonMark line break behavior
21});
22
23// Enable strikethrough (~~text~~) via built-in plugin
24md.enable('strikethrough');
25
26// Enable tables via built-in plugin
27md.enable('table');
28
29/**
30 * Custom plugin: task list checkboxes (GFM style)
31 * Converts `- [ ] text` and `- [x] text` into checkbox list items.
32 */
33/**
34 * Scan a bullet_list's children and return true if ANY list_item
35 * starts with a GFM checkbox pattern `[ ]` / `[x]`.
36 */
37function listContainsTaskItems(tokens: Token[], listOpenIdx: number): boolean {
38 let depth = 0;
39 for (let i = listOpenIdx; i < tokens.length; i++) {
40 if (tokens[i].type === 'bullet_list_open') depth++;
41 if (tokens[i].type === 'bullet_list_close') { depth--; if (depth === 0) break; }
42 if (depth === 1 && tokens[i].type === 'list_item_open') {
43 const content = tokens[i + 2]; // list_item_open -> paragraph_open -> inline
44 if (content?.type === 'inline' && /^\[([ xX])\]\s*/.test(content.content)) {
45 return true;
46 }
47 }
48 }
49 return false;
50}
51
52const taskListPlugin: MarkdownItPlugin = function taskListPlugin(md: MarkdownIt): void {
53 // --- Override bullet_list_open: emit <ul data-type="taskList"> when items have checkboxes ---
54 const defaultListOpen = md.renderer.rules.bullet_list_open ||
55 function (tokens: Token[], idx: number, options: MarkdownItOptions, _env: unknown, self: Renderer): string {
56 return self.renderToken(tokens, idx, options);
57 };
58
59 md.renderer.rules.bullet_list_open = function (tokens: Token[], idx: number, options: MarkdownItOptions, env: unknown, self: Renderer): string {
60 if (listContainsTaskItems(tokens, idx)) {
61 return '<ul data-type="taskList">';
62 }
63 return defaultListOpen(tokens, idx, options, env, self);
64 };
65
66 // --- Override list_item_open: emit TipTap-compatible task item attributes ---
67 const defaultItemOpen = md.renderer.rules.list_item_open ||
68 function (tokens: Token[], idx: number, options: MarkdownItOptions, _env: unknown, self: Renderer): string {
69 return self.renderToken(tokens, idx, options);
70 };
71
72 md.renderer.rules.list_item_open = function (tokens: Token[], idx: number, options: MarkdownItOptions, env: unknown, self: Renderer): string {
73 const contentToken = tokens[idx + 2]; // list_item_open -> paragraph_open -> inline
74 if (contentToken && contentToken.type === 'inline' && contentToken.content) {
75 const match = contentToken.content.match(/^\[([ xX])\]\s*/);
76 if (match) {
77 const checked = match[1].toLowerCase() === 'x';
78 // Remove the checkbox syntax from the content
79 contentToken.content = contentToken.content.replace(/^\[([ xX])\]\s*/, '');
80 if (contentToken.children && contentToken.children.length > 0) {
81 const firstChild = contentToken.children[0];
82 if (firstChild.type === 'text') {
83 firstChild.content = firstChild.content.replace(/^\[([ xX])\]\s*/, '');
84 }
85 }
86 return `<li data-type="taskItem" data-checked="${checked}">`;
87 }
88 }
89 return defaultItemOpen(tokens, idx, options, env, self);
90 };
91};
92
93md.use(taskListPlugin);
94
95/**
96 * Convert a markdown string to HTML.
97 */
98export function markdownToHtml(mdString: string): string {
99 if (!mdString) return '';
100 return md.render(mdString);
101}