Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: add full markdown support (import, export, source toggle)' (#39) from feat/full-markdown into main

scott 8c70e678 754591bc

+1136 -36
+24
package-lock.json
··· 37 37 "html2pdf.js": "^0.14.0", 38 38 "lib0": "^0.2.99", 39 39 "mammoth": "^1.12.0", 40 + "markdown-it": "^14.1.1", 41 + "turndown": "^7.2.2", 42 + "turndown-plugin-gfm": "^1.0.2", 40 43 "ws": "^8.18.0", 41 44 "xlsx": "^0.18.5", 42 45 "y-prosemirror": "^1.2.15", ··· 727 730 "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", 728 731 "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", 729 732 "license": "MIT" 733 + }, 734 + "node_modules/@mixmark-io/domino": { 735 + "version": "2.2.0", 736 + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", 737 + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", 738 + "license": "BSD-2-Clause" 730 739 }, 731 740 "node_modules/@remirror/core-constants": { 732 741 "version": "3.0.0", ··· 4700 4709 "engines": { 4701 4710 "node": "*" 4702 4711 } 4712 + }, 4713 + "node_modules/turndown": { 4714 + "version": "7.2.2", 4715 + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz", 4716 + "integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==", 4717 + "license": "MIT", 4718 + "dependencies": { 4719 + "@mixmark-io/domino": "^2.2.0" 4720 + } 4721 + }, 4722 + "node_modules/turndown-plugin-gfm": { 4723 + "version": "1.0.2", 4724 + "resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz", 4725 + "integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==", 4726 + "license": "MIT" 4703 4727 }, 4704 4728 "node_modules/type-is": { 4705 4729 "version": "1.6.18",
+3
package.json
··· 40 40 "html2pdf.js": "^0.14.0", 41 41 "lib0": "^0.2.99", 42 42 "mammoth": "^1.12.0", 43 + "markdown-it": "^14.1.1", 44 + "turndown": "^7.2.2", 45 + "turndown-plugin-gfm": "^1.0.2", 43 46 "ws": "^8.18.0", 44 47 "xlsx": "^0.18.5", 45 48 "y-prosemirror": "^1.2.15",
+48
src/css/app.css
··· 4196 4196 td[data-id].has-note { 4197 4197 position: relative; 4198 4198 } 4199 + 4200 + /* --- Markdown Source View --- */ 4201 + .markdown-source-textarea { 4202 + width: 100%; 4203 + max-width: 48rem; 4204 + min-height: 60vh; 4205 + margin: 0 auto; 4206 + padding: var(--space-lg); 4207 + font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', 'Menlo', 'Consolas', monospace; 4208 + font-size: 0.95rem; 4209 + line-height: 1.7; 4210 + color: var(--color-text); 4211 + background: var(--color-surface); 4212 + border: 1px solid var(--color-border); 4213 + border-radius: var(--radius-sm); 4214 + resize: vertical; 4215 + outline: none; 4216 + tab-size: 4; 4217 + box-sizing: border-box; 4218 + } 4219 + 4220 + .markdown-source-textarea:focus { 4221 + border-color: var(--color-teal); 4222 + } 4223 + 4224 + [data-theme="dark"] .markdown-source-textarea { 4225 + background: var(--color-surface-alt); 4226 + color: var(--color-text); 4227 + border-color: var(--color-border); 4228 + } 4229 + 4230 + [data-theme="dark"] .markdown-source-textarea:focus { 4231 + border-color: var(--color-teal); 4232 + } 4233 + 4234 + /* Active state for the MD toggle button */ 4235 + #btn-md-toggle.active { 4236 + background: var(--color-teal); 4237 + color: #fff; 4238 + } 4239 + 4240 + /* Markdown mode indicator in the status area */ 4241 + .md-mode-indicator { 4242 + font-size: 0.75rem; 4243 + color: var(--color-text-secondary); 4244 + opacity: 0.7; 4245 + margin-left: var(--space-sm); 4246 + }
+3
src/docs/index.html
··· 37 37 <span class="suggesting-toggle-label" id="suggesting-label">Editing</span> 38 38 </button> 39 39 </div> 40 + <!-- Markdown source toggle --> 41 + <button class="btn-icon" id="btn-md-toggle" title="Toggle markdown source (Cmd+Shift+M)" aria-label="Toggle markdown source view">MD</button> 40 42 <!-- Outline toggle --> 41 43 <button class="btn-icon" id="btn-outline" title="Document outline"> 42 44 <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px"><line x1="2" y1="3" x2="14" y2="3"/><line x1="4" y1="6.5" x2="14" y2="6.5"/><line x1="6" y1="10" x2="14" y2="10"/><line x1="2" y1="13.5" x2="14" y2="13.5"/></svg> ··· 366 368 367 369 <div class="editor-container"> 368 370 <div class="editor-wrapper" id="editor"></div> 371 + <textarea class="markdown-source-textarea" id="markdown-source" style="display:none" spellcheck="false" aria-label="Markdown source editor"></textarea> 369 372 </div> 370 373 371 374 <!-- Version history sidebar -->
+52 -36
src/docs/main.js
··· 36 36 import { MarkdownAutoformat } from './extensions/markdown-autoformat.js'; 37 37 import { exportPdf } from './pdf-export.js'; 38 38 import { importDocx, isValidDocx } from './docx-import.js'; 39 + import { markdownToHtml } from './markdown-parser.js'; 40 + import { htmlToMarkdown as turndownHtmlToMarkdown } from './markdown-export.js'; 41 + import { createMarkdownToggle, TOGGLE_MODE } from './markdown-toggle.js'; 39 42 import { VersionManager, computeWordCount } from '../lib/version-history.js'; 40 43 import { SuggestionManager, createSuggestionAttrs } from '../lib/suggesting.js'; 41 44 import { OfflineManager } from '../lib/offline.js'; ··· 603 606 downloadFile(fullDoc, getDocFilename() + '.html', 'text/html'); 604 607 } 605 608 606 - function htmlToMarkdown(html) { 607 - let md = html; 608 - md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n'); 609 - md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n'); 610 - md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n'); 611 - md = md.replace(/<h4[^>]*>(.*?)<\/h4>/gi, '#### $1\n\n'); 612 - md = md.replace(/<h5[^>]*>(.*?)<\/h5>/gi, '##### $1\n\n'); 613 - md = md.replace(/<h6[^>]*>(.*?)<\/h6>/gi, '###### $1\n\n'); 614 - md = md.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**'); 615 - md = md.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**'); 616 - md = md.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*'); 617 - md = md.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*'); 618 - md = md.replace(/<s[^>]*>(.*?)<\/s>/gi, '~~$1~~'); 619 - md = md.replace(/<del[^>]*>(.*?)<\/del>/gi, '~~$1~~'); 620 - md = md.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`'); 621 - md = md.replace(/<pre[^>]*><code[^>]*>(.*?)<\/code><\/pre>/gis, '```\n$1\n```\n\n'); 622 - md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)'); 623 - md = md.replace(/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*\/?>/gi, '![$2]($1)'); 624 - md = md.replace(/<img[^>]*src="([^"]*)"[^>]*\/?>/gi, '![]($1)'); 625 - md = md.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gis, (_, content) => { 626 - return content.replace(/<p[^>]*>(.*?)<\/p>/gi, '> $1\n').trim() + '\n\n'; 627 - }); 628 - md = md.replace(/<hr\s*\/?>/gi, '---\n\n'); 629 - md = md.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n'); 630 - md = md.replace(/<\/?[ou]l[^>]*>/gi, '\n'); 631 - md = md.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n'); 632 - md = md.replace(/<br\s*\/?>/gi, '\n'); 633 - md = md.replace(/<[^>]+>/g, ''); 634 - md = md.replace(/&amp;/g, '&'); 635 - md = md.replace(/&lt;/g, '<'); 636 - md = md.replace(/&gt;/g, '>'); 637 - md = md.replace(/&quot;/g, '"'); 638 - md = md.replace(/&nbsp;/g, ' '); 639 - md = md.replace(/\n{3,}/g, '\n\n'); 640 - return md.trim() + '\n'; 641 - } 609 + // Use turndown-based converter from markdown-export.js (replaces regex-based approach) 610 + const htmlToMarkdown = turndownHtmlToMarkdown; 642 611 643 612 function exportMarkdown() { 644 613 const html = editor.getHTML(); ··· 688 657 const content = e.target.result; 689 658 if (ext === 'html' || ext === 'htm') { 690 659 editor.commands.setContent(content); 660 + } else if (ext === 'md') { 661 + // Parse markdown to HTML via markdown-it before inserting 662 + const html = markdownToHtml(content); 663 + editor.commands.setContent(html); 691 664 } else { 692 665 editor.commands.insertContent(content); 693 666 } ··· 846 819 { keys: ['\u2318', 'S'], label: 'Save snapshot' }, 847 820 { keys: ['\u2318', '\u21e7', 'S'], label: 'Export HTML' }, 848 821 { keys: ['\u2318', '\u21e7', 'P'], label: 'Export PDF' }, 822 + { keys: ['\u2318', '\u21e7', 'M'], label: 'Toggle markdown source' }, 849 823 { keys: ['\u2318', 'P'], label: 'Print' }, 850 824 { keys: ['\u2318', '/'], label: 'Keyboard shortcuts' }, 851 825 ]}, ··· 1028 1002 } else if (mod && e.key.toLowerCase() === 'p') { 1029 1003 e.preventDefault(); 1030 1004 printDocument(); 1005 + } else if (mod && e.shiftKey && e.key.toLowerCase() === 'm') { 1006 + e.preventDefault(); 1007 + mdToggle.toggle(); 1031 1008 } 1009 + }); 1010 + 1011 + // --- Markdown Source Toggle --- 1012 + const mdToggleBtn = $('btn-md-toggle'); 1013 + const markdownTextarea = $('markdown-source'); 1014 + const editorWrapper = $('editor'); 1015 + 1016 + const mdToggle = createMarkdownToggle({ 1017 + getEditorHtml: () => editor.getHTML(), 1018 + setEditorHtml: (html) => editor.commands.setContent(html), 1019 + htmlToMarkdown: htmlToMarkdown, 1020 + markdownToHtml: markdownToHtml, 1021 + onModeChange: (mode) => { 1022 + const isMd = mode === TOGGLE_MODE.MARKDOWN; 1023 + // Show/hide editor vs textarea 1024 + editorWrapper.style.display = isMd ? 'none' : ''; 1025 + markdownTextarea.style.display = isMd ? '' : 'none'; 1026 + // Toggle active state on button 1027 + mdToggleBtn.classList.toggle('active', isMd); 1028 + // Disable toolbar in markdown mode 1029 + const toolbar = $('toolbar'); 1030 + if (toolbar) { 1031 + toolbar.style.pointerEvents = isMd ? 'none' : ''; 1032 + toolbar.style.opacity = isMd ? '0.5' : ''; 1033 + } 1034 + if (isMd) { 1035 + markdownTextarea.value = mdToggle.getMarkdownContent(); 1036 + markdownTextarea.focus(); 1037 + } 1038 + }, 1039 + }); 1040 + 1041 + // Sync textarea edits back to toggle state 1042 + markdownTextarea.addEventListener('input', () => { 1043 + mdToggle.setMarkdownContent(markdownTextarea.value); 1044 + }); 1045 + 1046 + mdToggleBtn.addEventListener('click', () => { 1047 + mdToggle.toggle(); 1032 1048 }); 1033 1049 1034 1050 // --- Collaboration avatars ---
+96
src/docs/markdown-export.js
··· 1 + /** 2 + * Markdown Export 3 + * 4 + * Uses turndown + turndown-plugin-gfm for proper HTML-to-Markdown conversion. 5 + * Replaces the regex-based htmlToMarkdown() with a robust AST-based converter. 6 + * 7 + * Supports: GFM tables, nested lists with proper indentation, code block 8 + * language specifiers, task list checkboxes, images with alt text, 9 + * strikethrough. 10 + */ 11 + 12 + import TurndownService from 'turndown'; 13 + import { gfm } from 'turndown-plugin-gfm'; 14 + 15 + /** 16 + * Create and configure a TurndownService instance. 17 + * Extracted as a function so it can be called once at module load. 18 + */ 19 + function createTurndownService() { 20 + const td = new TurndownService({ 21 + headingStyle: 'atx', // # style headings 22 + hr: '---', // Horizontal rules 23 + bulletListMarker: '-', // Unordered list marker 24 + codeBlockStyle: 'fenced', // ``` style code blocks 25 + fence: '```', // Fence character 26 + emDelimiter: '*', // Italic delimiter 27 + strongDelimiter: '**', // Bold delimiter 28 + linkStyle: 'inlined', // [text](url) style 29 + }); 30 + 31 + // Enable GFM extensions: tables, strikethrough, task lists 32 + td.use(gfm); 33 + 34 + // Override strikethrough rule to always use ~~ (GFM standard) 35 + // The default GFM plugin may use single ~ for <s>/<del> — we want ~~ 36 + td.addRule('strikethrough', { 37 + filter: ['del', 's'], 38 + replacement: function (content) { 39 + return '~~' + content + '~~'; 40 + }, 41 + }); 42 + 43 + // Custom rule for TipTap-style task lists (data-type="taskList") 44 + td.addRule('tiptapTaskList', { 45 + filter: function (node) { 46 + return ( 47 + node.nodeName === 'LI' && 48 + node.getAttribute('data-type') === 'taskItem' 49 + ); 50 + }, 51 + replacement: function (content, node) { 52 + const checked = node.getAttribute('data-checked') === 'true'; 53 + const prefix = checked ? '[x] ' : '[ ] '; 54 + // Clean up the content — remove label/checkbox artifacts 55 + const cleanContent = content 56 + .replace(/^\s*\n/, '') // Leading newline 57 + .replace(/\n\s*$/, '') // Trailing newline 58 + .trim(); 59 + return prefix + cleanContent + '\n'; 60 + }, 61 + }); 62 + 63 + // Custom rule: preserve language class on code blocks 64 + td.addRule('fencedCodeWithLang', { 65 + filter: function (node) { 66 + return ( 67 + node.nodeName === 'PRE' && 68 + node.firstChild && 69 + node.firstChild.nodeName === 'CODE' 70 + ); 71 + }, 72 + replacement: function (_content, node) { 73 + const code = node.firstChild; 74 + const className = code.getAttribute('class') || ''; 75 + const langMatch = className.match(/language-(\S+)/); 76 + const lang = langMatch ? langMatch[1] : ''; 77 + const text = code.textContent || ''; 78 + return '\n\n```' + lang + '\n' + text + '\n```\n\n'; 79 + }, 80 + }); 81 + 82 + return td; 83 + } 84 + 85 + const turndownService = createTurndownService(); 86 + 87 + /** 88 + * Convert HTML to Markdown. 89 + * 90 + * @param {string} html - HTML string (e.g., from editor.getHTML()) 91 + * @returns {string} Markdown string 92 + */ 93 + export function htmlToMarkdown(html) { 94 + if (!html) return ''; 95 + return turndownService.turndown(html); 96 + }
+73
src/docs/markdown-parser.js
··· 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 + 12 + import MarkdownIt from 'markdown-it'; 13 + 14 + // Initialize markdown-it with GFM-like defaults 15 + const md = new MarkdownIt({ 16 + html: false, // Disable raw HTML passthrough for security 17 + linkify: true, // Auto-convert URLs to links 18 + typographer: false, // Keep raw characters (don't smart-quote) 19 + breaks: false, // Standard CommonMark line break behavior 20 + }); 21 + 22 + // Enable strikethrough (~~text~~) via built-in plugin 23 + md.enable('strikethrough'); 24 + 25 + // Enable tables via built-in plugin 26 + md.enable('table'); 27 + 28 + /** 29 + * Custom plugin: task list checkboxes (GFM style) 30 + * Converts `- [ ] text` and `- [x] text` into checkbox list items. 31 + */ 32 + function taskListPlugin(md) { 33 + const defaultRender = md.renderer.rules.list_item_open || 34 + function (tokens, idx, options, env, self) { 35 + return self.renderToken(tokens, idx, options); 36 + }; 37 + 38 + md.renderer.rules.list_item_open = function (tokens, idx, options, env, self) { 39 + // Look at the inline content of this list item 40 + const contentToken = tokens[idx + 2]; // list_item_open -> paragraph_open -> inline 41 + if (contentToken && contentToken.type === 'inline' && contentToken.content) { 42 + const match = contentToken.content.match(/^\[([ xX])\]\s*/); 43 + if (match) { 44 + const checked = match[1].toLowerCase() === 'x'; 45 + // Remove the checkbox syntax from the content 46 + contentToken.content = contentToken.content.replace(/^\[([ xX])\]\s*/, ''); 47 + // Also update children if present 48 + if (contentToken.children && contentToken.children.length > 0) { 49 + const firstChild = contentToken.children[0]; 50 + if (firstChild.type === 'text') { 51 + firstChild.content = firstChild.content.replace(/^\[([ xX])\]\s*/, ''); 52 + } 53 + } 54 + const checkedAttr = checked ? ' checked=""' : ''; 55 + return '<li class="task-list-item"><input type="checkbox"' + checkedAttr + ' disabled="">' ; 56 + } 57 + } 58 + return defaultRender(tokens, idx, options, env, self); 59 + }; 60 + } 61 + 62 + md.use(taskListPlugin); 63 + 64 + /** 65 + * Convert a markdown string to HTML. 66 + * 67 + * @param {string} mdString - Raw markdown content 68 + * @returns {string} HTML string suitable for TipTap editor 69 + */ 70 + export function markdownToHtml(mdString) { 71 + if (!mdString) return ''; 72 + return md.render(mdString); 73 + }
+87
src/docs/markdown-toggle.js
··· 1 + /** 2 + * Markdown Toggle (Source View) 3 + * 4 + * Manages toggling between WYSIWYG (TipTap) and raw markdown editing modes. 5 + * Pure state management — no DOM manipulation. The caller (main.js) handles 6 + * the actual UI show/hide of editor vs textarea. 7 + * 8 + * Usage: 9 + * const toggle = createMarkdownToggle({ 10 + * getEditorHtml: () => editor.getHTML(), 11 + * setEditorHtml: (html) => editor.commands.setContent(html), 12 + * htmlToMarkdown: (html) => htmlToMarkdown(html), 13 + * markdownToHtml: (md) => markdownToHtml(md), 14 + * onModeChange: (mode) => { ... }, // optional callback 15 + * }); 16 + * 17 + * toggle.toggle(); // Switch modes 18 + * toggle.getMode(); // Current mode 19 + */ 20 + 21 + export const TOGGLE_MODE = Object.freeze({ 22 + WYSIWYG: 'wysiwyg', 23 + MARKDOWN: 'markdown', 24 + }); 25 + 26 + /** 27 + * Create a markdown toggle state manager. 28 + * 29 + * @param {Object} opts 30 + * @param {() => string} opts.getEditorHtml - Get current TipTap HTML content 31 + * @param {(html: string) => void} opts.setEditorHtml - Set TipTap HTML content 32 + * @param {(html: string) => string} opts.htmlToMarkdown - Convert HTML to markdown 33 + * @param {(md: string) => string} opts.markdownToHtml - Convert markdown to HTML 34 + * @param {(mode: string) => void} [opts.onModeChange] - Optional callback on mode change 35 + * @returns {Object} Toggle API 36 + */ 37 + export function createMarkdownToggle(opts) { 38 + const { getEditorHtml, setEditorHtml, htmlToMarkdown, markdownToHtml, onModeChange } = opts; 39 + 40 + let mode = TOGGLE_MODE.WYSIWYG; 41 + let markdownContent = ''; 42 + 43 + function toggle() { 44 + if (mode === TOGGLE_MODE.WYSIWYG) { 45 + // Switching TO markdown mode: convert current editor HTML to markdown 46 + markdownContent = htmlToMarkdown(getEditorHtml()); 47 + mode = TOGGLE_MODE.MARKDOWN; 48 + } else { 49 + // Switching BACK to WYSIWYG: parse markdown to HTML and load into editor 50 + const html = markdownToHtml(markdownContent); 51 + setEditorHtml(html); 52 + markdownContent = ''; 53 + mode = TOGGLE_MODE.WYSIWYG; 54 + } 55 + 56 + if (onModeChange) { 57 + onModeChange(mode); 58 + } 59 + } 60 + 61 + function getMode() { 62 + return mode; 63 + } 64 + 65 + function isMarkdownMode() { 66 + return mode === TOGGLE_MODE.MARKDOWN; 67 + } 68 + 69 + function getMarkdownContent() { 70 + return markdownContent; 71 + } 72 + 73 + function setMarkdownContent(content) { 74 + if (mode === TOGGLE_MODE.MARKDOWN) { 75 + markdownContent = content; 76 + } 77 + // Ignored in WYSIWYG mode 78 + } 79 + 80 + return { 81 + toggle, 82 + getMode, 83 + isMarkdownMode, 84 + getMarkdownContent, 85 + setMarkdownContent, 86 + }; 87 + }
+259
tests/markdown-export.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { htmlToMarkdown } from '../src/docs/markdown-export.js'; 3 + 4 + describe('Markdown Export (turndown)', () => { 5 + describe('headings', () => { 6 + it('converts h1 to #', () => { 7 + const md = htmlToMarkdown('<h1>Title</h1>'); 8 + expect(md).toContain('# Title'); 9 + }); 10 + 11 + it('converts h2 to ##', () => { 12 + const md = htmlToMarkdown('<h2>Section</h2>'); 13 + expect(md).toContain('## Section'); 14 + }); 15 + 16 + it('converts h3 to ###', () => { 17 + const md = htmlToMarkdown('<h3>Sub-section</h3>'); 18 + expect(md).toContain('### Sub-section'); 19 + }); 20 + 21 + it('converts h4-h6', () => { 22 + expect(htmlToMarkdown('<h4>H4</h4>')).toContain('#### H4'); 23 + expect(htmlToMarkdown('<h5>H5</h5>')).toContain('##### H5'); 24 + expect(htmlToMarkdown('<h6>H6</h6>')).toContain('###### H6'); 25 + }); 26 + }); 27 + 28 + describe('inline formatting', () => { 29 + it('converts strong to **bold**', () => { 30 + const md = htmlToMarkdown('<p>This is <strong>bold</strong> text</p>'); 31 + expect(md).toContain('**bold**'); 32 + }); 33 + 34 + it('converts b to **bold**', () => { 35 + const md = htmlToMarkdown('<p>This is <b>bold</b> text</p>'); 36 + expect(md).toContain('**bold**'); 37 + }); 38 + 39 + it('converts em to *italic*', () => { 40 + const md = htmlToMarkdown('<p>This is <em>italic</em> text</p>'); 41 + expect(md).toContain('*italic*'); 42 + }); 43 + 44 + it('converts i to *italic*', () => { 45 + const md = htmlToMarkdown('<p>This is <i>italic</i> text</p>'); 46 + expect(md).toContain('*italic*'); 47 + }); 48 + 49 + it('converts s/del to ~~strikethrough~~', () => { 50 + const md = htmlToMarkdown('<p>This is <s>deleted</s> text</p>'); 51 + expect(md).toContain('~~deleted~~'); 52 + }); 53 + 54 + it('converts del to ~~strikethrough~~', () => { 55 + const md = htmlToMarkdown('<p>This is <del>removed</del> text</p>'); 56 + expect(md).toContain('~~removed~~'); 57 + }); 58 + 59 + it('converts inline code', () => { 60 + const md = htmlToMarkdown('<p>Use <code>console.log()</code> here</p>'); 61 + expect(md).toContain('`console.log()`'); 62 + }); 63 + }); 64 + 65 + describe('code blocks', () => { 66 + it('converts pre>code to fenced code block', () => { 67 + const md = htmlToMarkdown('<pre><code>const x = 1;\nconst y = 2;</code></pre>'); 68 + expect(md).toContain('```'); 69 + expect(md).toContain('const x = 1;'); 70 + }); 71 + 72 + it('preserves language specifier from class', () => { 73 + const md = htmlToMarkdown('<pre><code class="language-javascript">const x = 1;</code></pre>'); 74 + expect(md).toContain('```javascript'); 75 + }); 76 + 77 + it('handles code blocks without language', () => { 78 + const md = htmlToMarkdown('<pre><code>plain code</code></pre>'); 79 + expect(md).toContain('```'); 80 + expect(md).toContain('plain code'); 81 + }); 82 + }); 83 + 84 + describe('links', () => { 85 + it('converts anchor tags to [text](url)', () => { 86 + const md = htmlToMarkdown('<a href="https://example.com">Example</a>'); 87 + expect(md).toContain('[Example](https://example.com)'); 88 + }); 89 + 90 + it('handles links with title attributes', () => { 91 + const md = htmlToMarkdown('<a href="https://example.com" title="Tip">Example</a>'); 92 + expect(md).toContain('[Example](https://example.com'); 93 + }); 94 + }); 95 + 96 + describe('images', () => { 97 + it('converts img to ![alt](src)', () => { 98 + const md = htmlToMarkdown('<img src="photo.jpg" alt="A photo">'); 99 + expect(md).toContain('![A photo](photo.jpg)'); 100 + }); 101 + 102 + it('handles images with empty alt', () => { 103 + const md = htmlToMarkdown('<img src="photo.jpg" alt="">'); 104 + expect(md).toContain('![](photo.jpg)'); 105 + }); 106 + 107 + it('handles images without alt attribute', () => { 108 + const md = htmlToMarkdown('<img src="photo.jpg">'); 109 + expect(md).toContain('photo.jpg'); 110 + }); 111 + }); 112 + 113 + describe('blockquotes', () => { 114 + it('converts blockquote to >', () => { 115 + const md = htmlToMarkdown('<blockquote><p>Quoted text</p></blockquote>'); 116 + expect(md).toContain('> Quoted text'); 117 + }); 118 + 119 + it('handles multi-paragraph blockquotes', () => { 120 + const md = htmlToMarkdown('<blockquote><p>Line one</p><p>Line two</p></blockquote>'); 121 + expect(md).toContain('> Line one'); 122 + expect(md).toContain('> Line two'); 123 + }); 124 + }); 125 + 126 + describe('lists', () => { 127 + it('converts unordered lists to - items', () => { 128 + const md = htmlToMarkdown('<ul><li>Alpha</li><li>Beta</li></ul>'); 129 + expect(md).toMatch(/[-*]\s+Alpha/); 130 + expect(md).toMatch(/[-*]\s+Beta/); 131 + }); 132 + 133 + it('converts ordered lists to numbered items', () => { 134 + const md = htmlToMarkdown('<ol><li>First</li><li>Second</li></ol>'); 135 + expect(md).toContain('1.'); 136 + expect(md).toContain('First'); 137 + expect(md).toContain('2.'); 138 + expect(md).toContain('Second'); 139 + }); 140 + 141 + it('handles nested lists with proper indentation', () => { 142 + const md = htmlToMarkdown('<ul><li>Parent<ul><li>Child</li></ul></li></ul>'); 143 + expect(md).toContain('Parent'); 144 + expect(md).toContain('Child'); 145 + // Child should be indented relative to parent 146 + const lines = md.split('\n').filter(l => l.includes('Child')); 147 + expect(lines.length).toBeGreaterThan(0); 148 + // Nested items should have leading whitespace 149 + expect(lines[0]).toMatch(/^\s+/); 150 + }); 151 + }); 152 + 153 + describe('task lists (GFM)', () => { 154 + it('converts unchecked task items', () => { 155 + const md = htmlToMarkdown( 156 + '<ul><li><input type="checkbox" disabled>Todo item</li></ul>' 157 + ); 158 + expect(md).toContain('[ ]'); 159 + expect(md).toContain('Todo item'); 160 + }); 161 + 162 + it('converts checked task items', () => { 163 + const md = htmlToMarkdown( 164 + '<ul><li><input type="checkbox" checked disabled>Done item</li></ul>' 165 + ); 166 + expect(md).toContain('[x]'); 167 + expect(md).toContain('Done item'); 168 + }); 169 + 170 + it('converts TipTap-style task lists', () => { 171 + const md = htmlToMarkdown( 172 + '<ul data-type="taskList"><li data-type="taskItem" data-checked="true"><label><input type="checkbox" checked></label><div>Completed</div></li>' + 173 + '<li data-type="taskItem" data-checked="false"><label><input type="checkbox"></label><div>Pending</div></li></ul>' 174 + ); 175 + expect(md).toContain('[x]'); 176 + expect(md).toContain('Completed'); 177 + expect(md).toContain('[ ]'); 178 + expect(md).toContain('Pending'); 179 + }); 180 + }); 181 + 182 + describe('tables (GFM)', () => { 183 + it('converts simple table', () => { 184 + const md = htmlToMarkdown( 185 + '<table><thead><tr><th>Name</th><th>Age</th></tr></thead>' + 186 + '<tbody><tr><td>Alice</td><td>30</td></tr></tbody></table>' 187 + ); 188 + expect(md).toContain('Name'); 189 + expect(md).toContain('Age'); 190 + expect(md).toContain('Alice'); 191 + expect(md).toContain('30'); 192 + // Should have table separator 193 + expect(md).toContain('---'); 194 + }); 195 + 196 + it('handles multi-row tables', () => { 197 + const md = htmlToMarkdown( 198 + '<table><thead><tr><th>A</th><th>B</th></tr></thead>' + 199 + '<tbody><tr><td>1</td><td>2</td></tr><tr><td>3</td><td>4</td></tr></tbody></table>' 200 + ); 201 + expect(md).toContain('1'); 202 + expect(md).toContain('3'); 203 + // Should have pipe characters for table formatting 204 + expect(md).toContain('|'); 205 + }); 206 + }); 207 + 208 + describe('horizontal rules', () => { 209 + it('converts hr to ---', () => { 210 + const md = htmlToMarkdown('<hr>'); 211 + expect(md).toMatch(/---/); 212 + }); 213 + }); 214 + 215 + describe('paragraphs', () => { 216 + it('preserves paragraph structure', () => { 217 + const md = htmlToMarkdown('<p>Paragraph one</p><p>Paragraph two</p>'); 218 + expect(md).toContain('Paragraph one'); 219 + expect(md).toContain('Paragraph two'); 220 + // Should have blank line between paragraphs 221 + expect(md).toMatch(/Paragraph one\n\nParagraph two/); 222 + }); 223 + }); 224 + 225 + describe('edge cases', () => { 226 + it('handles empty input', () => { 227 + const md = htmlToMarkdown(''); 228 + expect(md).toBeDefined(); 229 + expect(typeof md).toBe('string'); 230 + }); 231 + 232 + it('handles plain text (no HTML)', () => { 233 + const md = htmlToMarkdown('Just plain text'); 234 + expect(md).toContain('Just plain text'); 235 + }); 236 + 237 + it('handles HTML entities', () => { 238 + const md = htmlToMarkdown('<p>5 &gt; 3 &amp; 2 &lt; 4</p>'); 239 + expect(md).toContain('>'); 240 + expect(md).toContain('&'); 241 + expect(md).toContain('<'); 242 + }); 243 + 244 + it('handles complex mixed content', () => { 245 + const html = '<h1>Title</h1>' + 246 + '<p>Some <strong>bold</strong> and <em>italic</em> text.</p>' + 247 + '<ul><li>Item 1</li><li>Item 2</li></ul>' + 248 + '<blockquote><p>A quote</p></blockquote>' + 249 + '<pre><code class="language-js">const x = 1;</code></pre>' + 250 + '<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table>'; 251 + const md = htmlToMarkdown(html); 252 + expect(md).toContain('# Title'); 253 + expect(md).toContain('**bold**'); 254 + expect(md).toContain('*italic*'); 255 + expect(md).toContain('> A quote'); 256 + expect(md).toContain('```'); 257 + }); 258 + }); 259 + });
+286
tests/markdown-parser.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { markdownToHtml } from '../src/docs/markdown-parser.js'; 3 + 4 + describe('Markdown Parser (markdown-it)', () => { 5 + describe('headings', () => { 6 + it('converts # to h1', () => { 7 + const html = markdownToHtml('# Hello World'); 8 + expect(html).toContain('<h1>Hello World</h1>'); 9 + }); 10 + 11 + it('converts ## to h2', () => { 12 + const html = markdownToHtml('## Sub Heading'); 13 + expect(html).toContain('<h2>Sub Heading</h2>'); 14 + }); 15 + 16 + it('converts ### to h3', () => { 17 + const html = markdownToHtml('### Third Level'); 18 + expect(html).toContain('<h3>Third Level</h3>'); 19 + }); 20 + 21 + it('converts #### to h6 levels', () => { 22 + expect(markdownToHtml('#### H4')).toContain('<h4>H4</h4>'); 23 + expect(markdownToHtml('##### H5')).toContain('<h5>H5</h5>'); 24 + expect(markdownToHtml('###### H6')).toContain('<h6>H6</h6>'); 25 + }); 26 + }); 27 + 28 + describe('inline formatting', () => { 29 + it('converts **text** to bold', () => { 30 + const html = markdownToHtml('This is **bold** text'); 31 + expect(html).toContain('<strong>bold</strong>'); 32 + }); 33 + 34 + it('converts *text* to italic', () => { 35 + const html = markdownToHtml('This is *italic* text'); 36 + expect(html).toContain('<em>italic</em>'); 37 + }); 38 + 39 + it('converts ~~text~~ to strikethrough', () => { 40 + const html = markdownToHtml('This is ~~deleted~~ text'); 41 + expect(html).toContain('<s>deleted</s>'); 42 + }); 43 + 44 + it('converts `text` to inline code', () => { 45 + const html = markdownToHtml('Use `console.log()` for debugging'); 46 + expect(html).toContain('<code>console.log()</code>'); 47 + }); 48 + 49 + it('handles nested inline formatting (bold + italic)', () => { 50 + const html = markdownToHtml('This is ***bold italic*** text'); 51 + expect(html).toMatch(/<(strong|em)>.*<(strong|em)>bold italic<\/(strong|em)>.*<\/(strong|em)>/); 52 + }); 53 + }); 54 + 55 + describe('code blocks', () => { 56 + it('converts fenced code blocks', () => { 57 + const md = '```\nconsole.log("hello");\n```'; 58 + const html = markdownToHtml(md); 59 + expect(html).toContain('<pre>'); 60 + expect(html).toContain('<code>'); 61 + expect(html).toContain('console.log'); 62 + }); 63 + 64 + it('preserves language specifier on code blocks', () => { 65 + const md = '```javascript\nconst x = 1;\n```'; 66 + const html = markdownToHtml(md); 67 + expect(html).toContain('<code'); 68 + expect(html).toMatch(/class="[^"]*language-javascript[^"]*"/); 69 + }); 70 + 71 + it('escapes HTML entities inside code blocks', () => { 72 + const md = '```\n<div class="test">&amp;</div>\n```'; 73 + const html = markdownToHtml(md); 74 + expect(html).toContain('&lt;div'); 75 + }); 76 + }); 77 + 78 + describe('links', () => { 79 + it('converts [text](url) to anchor tags', () => { 80 + const html = markdownToHtml('[Example](https://example.com)'); 81 + expect(html).toContain('<a href="https://example.com"'); 82 + expect(html).toContain('>Example</a>'); 83 + }); 84 + 85 + it('handles links with titles', () => { 86 + const html = markdownToHtml('[Example](https://example.com "Title")'); 87 + expect(html).toContain('href="https://example.com"'); 88 + expect(html).toContain('title="Title"'); 89 + }); 90 + }); 91 + 92 + describe('images', () => { 93 + it('converts ![alt](url) to img tags', () => { 94 + const html = markdownToHtml('![Alt text](image.png)'); 95 + expect(html).toContain('<img'); 96 + expect(html).toContain('src="image.png"'); 97 + expect(html).toContain('alt="Alt text"'); 98 + }); 99 + 100 + it('handles images with empty alt text', () => { 101 + const html = markdownToHtml('![](photo.jpg)'); 102 + expect(html).toContain('<img'); 103 + expect(html).toContain('src="photo.jpg"'); 104 + }); 105 + }); 106 + 107 + describe('blockquotes', () => { 108 + it('converts > to blockquote', () => { 109 + const html = markdownToHtml('> This is a quote'); 110 + expect(html).toContain('<blockquote>'); 111 + expect(html).toContain('This is a quote'); 112 + }); 113 + 114 + it('handles multi-line blockquotes', () => { 115 + const md = '> Line one\n> Line two'; 116 + const html = markdownToHtml(md); 117 + expect(html).toContain('<blockquote>'); 118 + expect(html).toContain('Line one'); 119 + expect(html).toContain('Line two'); 120 + }); 121 + 122 + it('handles nested blockquotes', () => { 123 + const md = '> Outer\n>> Inner'; 124 + const html = markdownToHtml(md); 125 + // Should have nested blockquote elements 126 + const blockquoteCount = (html.match(/<blockquote>/g) || []).length; 127 + expect(blockquoteCount).toBeGreaterThanOrEqual(2); 128 + }); 129 + }); 130 + 131 + describe('lists', () => { 132 + it('converts bullet lists with -', () => { 133 + const md = '- Item one\n- Item two\n- Item three'; 134 + const html = markdownToHtml(md); 135 + expect(html).toContain('<ul>'); 136 + expect(html).toContain('<li>'); 137 + expect(html).toContain('Item one'); 138 + expect(html).toContain('Item two'); 139 + }); 140 + 141 + it('converts bullet lists with *', () => { 142 + const md = '* Alpha\n* Beta'; 143 + const html = markdownToHtml(md); 144 + expect(html).toContain('<ul>'); 145 + expect(html).toContain('Alpha'); 146 + }); 147 + 148 + it('converts ordered lists', () => { 149 + const md = '1. First\n2. Second\n3. Third'; 150 + const html = markdownToHtml(md); 151 + expect(html).toContain('<ol>'); 152 + expect(html).toContain('<li>'); 153 + expect(html).toContain('First'); 154 + }); 155 + 156 + it('handles nested lists', () => { 157 + const md = '- Parent\n - Child\n - Grandchild'; 158 + const html = markdownToHtml(md); 159 + // Should have nested ul 160 + const ulCount = (html.match(/<ul>/g) || []).length; 161 + expect(ulCount).toBeGreaterThanOrEqual(2); 162 + }); 163 + }); 164 + 165 + describe('task lists (GFM)', () => { 166 + it('converts unchecked task items - [ ]', () => { 167 + const md = '- [ ] Todo item'; 168 + const html = markdownToHtml(md); 169 + expect(html).toContain('type="checkbox"'); 170 + expect(html).not.toMatch(/checked/i); 171 + }); 172 + 173 + it('converts checked task items - [x]', () => { 174 + const md = '- [x] Done item'; 175 + const html = markdownToHtml(md); 176 + expect(html).toContain('type="checkbox"'); 177 + expect(html).toContain('checked'); 178 + }); 179 + 180 + it('handles mixed task items', () => { 181 + const md = '- [x] Done\n- [ ] Pending\n- [x] Also done'; 182 + const html = markdownToHtml(md); 183 + const checkboxCount = (html.match(/type="checkbox"/g) || []).length; 184 + expect(checkboxCount).toBe(3); 185 + }); 186 + }); 187 + 188 + describe('horizontal rules', () => { 189 + it('converts --- to hr', () => { 190 + const html = markdownToHtml('---'); 191 + expect(html).toContain('<hr>'); 192 + }); 193 + 194 + it('converts *** to hr', () => { 195 + const html = markdownToHtml('***'); 196 + expect(html).toContain('<hr>'); 197 + }); 198 + 199 + it('converts ___ to hr', () => { 200 + const html = markdownToHtml('___'); 201 + expect(html).toContain('<hr>'); 202 + }); 203 + }); 204 + 205 + describe('tables (GFM)', () => { 206 + it('converts simple table', () => { 207 + const md = '| Name | Age |\n|------|-----|\n| Alice | 30 |\n| Bob | 25 |'; 208 + const html = markdownToHtml(md); 209 + expect(html).toContain('<table>'); 210 + expect(html).toContain('<th>'); 211 + expect(html).toContain('Name'); 212 + expect(html).toContain('Alice'); 213 + }); 214 + 215 + it('renders header and body rows', () => { 216 + const md = '| Col A | Col B |\n|-------|-------|\n| 1 | 2 |'; 217 + const html = markdownToHtml(md); 218 + expect(html).toContain('<thead>'); 219 + expect(html).toContain('<tbody>'); 220 + }); 221 + 222 + it('handles alignment markers', () => { 223 + const md = '| Left | Center | Right |\n|:-----|:------:|------:|\n| L | C | R |'; 224 + const html = markdownToHtml(md); 225 + expect(html).toContain('<table>'); 226 + // Alignment should be present via style attributes 227 + expect(html).toContain('Left'); 228 + expect(html).toContain('Center'); 229 + expect(html).toContain('Right'); 230 + }); 231 + }); 232 + 233 + describe('hard line breaks', () => { 234 + it('converts trailing double spaces to br', () => { 235 + const md = 'Line one \nLine two'; 236 + const html = markdownToHtml(md); 237 + expect(html).toContain('<br'); 238 + }); 239 + 240 + it('converts backslash line breaks', () => { 241 + const md = 'Line one\\\nLine two'; 242 + const html = markdownToHtml(md); 243 + expect(html).toContain('<br'); 244 + }); 245 + }); 246 + 247 + describe('edge cases', () => { 248 + it('handles empty input', () => { 249 + const html = markdownToHtml(''); 250 + expect(html).toBeDefined(); 251 + expect(typeof html).toBe('string'); 252 + }); 253 + 254 + it('handles plain text without markdown', () => { 255 + const html = markdownToHtml('Just some plain text'); 256 + expect(html).toContain('Just some plain text'); 257 + }); 258 + 259 + it('handles complex mixed content', () => { 260 + const md = `# Title 261 + 262 + Some **bold** and *italic* text. 263 + 264 + - List item with \`code\` 265 + - Another item 266 + 267 + > A blockquote 268 + 269 + \`\`\`js 270 + const x = 1; 271 + \`\`\` 272 + 273 + | A | B | 274 + |---|---| 275 + | 1 | 2 |`; 276 + const html = markdownToHtml(md); 277 + expect(html).toContain('<h1>'); 278 + expect(html).toContain('<strong>'); 279 + expect(html).toContain('<em>'); 280 + expect(html).toContain('<ul>'); 281 + expect(html).toContain('<blockquote>'); 282 + expect(html).toContain('<pre>'); 283 + expect(html).toContain('<table>'); 284 + }); 285 + }); 286 + });
+205
tests/markdown-toggle.test.js
··· 1 + import { describe, it, expect, beforeEach } from 'vitest'; 2 + import { 3 + createMarkdownToggle, 4 + TOGGLE_MODE, 5 + } from '../src/docs/markdown-toggle.js'; 6 + 7 + describe('Markdown Toggle (Source View)', () => { 8 + describe('createMarkdownToggle', () => { 9 + it('returns an object with expected API', () => { 10 + const toggle = createMarkdownToggle({ 11 + getEditorHtml: () => '<p>Hello</p>', 12 + setEditorHtml: () => {}, 13 + htmlToMarkdown: (h) => h, 14 + markdownToHtml: (m) => m, 15 + }); 16 + expect(toggle).toBeDefined(); 17 + expect(typeof toggle.getMode).toBe('function'); 18 + expect(typeof toggle.toggle).toBe('function'); 19 + expect(typeof toggle.isMarkdownMode).toBe('function'); 20 + expect(typeof toggle.getMarkdownContent).toBe('function'); 21 + expect(typeof toggle.setMarkdownContent).toBe('function'); 22 + }); 23 + 24 + it('starts in WYSIWYG mode', () => { 25 + const toggle = createMarkdownToggle({ 26 + getEditorHtml: () => '<p>Hello</p>', 27 + setEditorHtml: () => {}, 28 + htmlToMarkdown: (h) => h, 29 + markdownToHtml: (m) => m, 30 + }); 31 + expect(toggle.getMode()).toBe(TOGGLE_MODE.WYSIWYG); 32 + expect(toggle.isMarkdownMode()).toBe(false); 33 + }); 34 + }); 35 + 36 + describe('TOGGLE_MODE constants', () => { 37 + it('has WYSIWYG and MARKDOWN modes', () => { 38 + expect(TOGGLE_MODE.WYSIWYG).toBeDefined(); 39 + expect(TOGGLE_MODE.MARKDOWN).toBeDefined(); 40 + expect(TOGGLE_MODE.WYSIWYG).not.toBe(TOGGLE_MODE.MARKDOWN); 41 + }); 42 + }); 43 + 44 + describe('toggling between modes', () => { 45 + let toggle; 46 + let currentHtml; 47 + let convertedMarkdown; 48 + 49 + beforeEach(() => { 50 + currentHtml = '<p><strong>Hello</strong> World</p>'; 51 + convertedMarkdown = '**Hello** World\n'; 52 + 53 + toggle = createMarkdownToggle({ 54 + getEditorHtml: () => currentHtml, 55 + setEditorHtml: (html) => { currentHtml = html; }, 56 + htmlToMarkdown: () => convertedMarkdown, 57 + markdownToHtml: (md) => '<p>' + md.replace(/\n/g, '') + '</p>', 58 + }); 59 + }); 60 + 61 + it('toggles from WYSIWYG to markdown mode', () => { 62 + toggle.toggle(); 63 + expect(toggle.getMode()).toBe(TOGGLE_MODE.MARKDOWN); 64 + expect(toggle.isMarkdownMode()).toBe(true); 65 + }); 66 + 67 + it('converts HTML to markdown when entering markdown mode', () => { 68 + toggle.toggle(); 69 + expect(toggle.getMarkdownContent()).toBe(convertedMarkdown); 70 + }); 71 + 72 + it('toggles back from markdown to WYSIWYG mode', () => { 73 + toggle.toggle(); // to markdown 74 + toggle.toggle(); // back to wysiwyg 75 + expect(toggle.getMode()).toBe(TOGGLE_MODE.WYSIWYG); 76 + expect(toggle.isMarkdownMode()).toBe(false); 77 + }); 78 + 79 + it('converts markdown back to HTML when returning to WYSIWYG', () => { 80 + toggle.toggle(); // to markdown 81 + toggle.toggle(); // back to wysiwyg 82 + // setEditorHtml should have been called with parsed HTML 83 + expect(currentHtml).toContain('<p>'); 84 + }); 85 + 86 + it('preserves user edits in markdown mode', () => { 87 + toggle.toggle(); // to markdown 88 + 89 + // Simulate user editing the markdown content 90 + toggle.setMarkdownContent('# New Title\n\nUpdated content'); 91 + 92 + // Toggle back 93 + toggle.toggle(); 94 + 95 + // The conversion function should have been called with the updated content 96 + expect(toggle.isMarkdownMode()).toBe(false); 97 + }); 98 + }); 99 + 100 + describe('content preservation', () => { 101 + it('does not lose content during round-trip toggle', () => { 102 + const originalHtml = '<h1>Title</h1><p>Content here</p>'; 103 + let stored = originalHtml; 104 + let lastMd = ''; 105 + 106 + const toggle = createMarkdownToggle({ 107 + getEditorHtml: () => stored, 108 + setEditorHtml: (html) => { stored = html; }, 109 + htmlToMarkdown: (html) => { 110 + lastMd = '# Title\n\nContent here\n'; 111 + return lastMd; 112 + }, 113 + markdownToHtml: (md) => { 114 + return '<h1>Title</h1><p>Content here</p>'; 115 + }, 116 + }); 117 + 118 + toggle.toggle(); // to markdown 119 + expect(toggle.getMarkdownContent()).toBe('# Title\n\nContent here\n'); 120 + 121 + toggle.toggle(); // back to wysiwyg 122 + expect(stored).toContain('Title'); 123 + expect(stored).toContain('Content here'); 124 + }); 125 + 126 + it('handles empty content gracefully', () => { 127 + const toggle = createMarkdownToggle({ 128 + getEditorHtml: () => '', 129 + setEditorHtml: () => {}, 130 + htmlToMarkdown: () => '', 131 + markdownToHtml: () => '', 132 + }); 133 + 134 + toggle.toggle(); // to markdown 135 + expect(toggle.getMarkdownContent()).toBe(''); 136 + 137 + toggle.toggle(); // back 138 + expect(toggle.getMode()).toBe(TOGGLE_MODE.WYSIWYG); 139 + }); 140 + }); 141 + 142 + describe('callbacks', () => { 143 + it('calls onModeChange callback when toggling', () => { 144 + const modes = []; 145 + const toggle = createMarkdownToggle({ 146 + getEditorHtml: () => '<p>Test</p>', 147 + setEditorHtml: () => {}, 148 + htmlToMarkdown: () => 'Test\n', 149 + markdownToHtml: (md) => '<p>' + md + '</p>', 150 + onModeChange: (mode) => modes.push(mode), 151 + }); 152 + 153 + toggle.toggle(); 154 + expect(modes).toEqual([TOGGLE_MODE.MARKDOWN]); 155 + 156 + toggle.toggle(); 157 + expect(modes).toEqual([TOGGLE_MODE.MARKDOWN, TOGGLE_MODE.WYSIWYG]); 158 + }); 159 + 160 + it('works without onModeChange callback', () => { 161 + const toggle = createMarkdownToggle({ 162 + getEditorHtml: () => '<p>Test</p>', 163 + setEditorHtml: () => {}, 164 + htmlToMarkdown: () => 'Test\n', 165 + markdownToHtml: (md) => '<p>' + md + '</p>', 166 + }); 167 + 168 + // Should not throw 169 + expect(() => toggle.toggle()).not.toThrow(); 170 + expect(() => toggle.toggle()).not.toThrow(); 171 + }); 172 + }); 173 + 174 + describe('getMarkdownContent / setMarkdownContent', () => { 175 + it('returns empty string in WYSIWYG mode', () => { 176 + const toggle = createMarkdownToggle({ 177 + getEditorHtml: () => '<p>Test</p>', 178 + setEditorHtml: () => {}, 179 + htmlToMarkdown: () => 'Test\n', 180 + markdownToHtml: (md) => '<p>' + md + '</p>', 181 + }); 182 + 183 + // In WYSIWYG mode, markdown content is not available 184 + expect(toggle.getMarkdownContent()).toBe(''); 185 + }); 186 + 187 + it('setMarkdownContent only works in markdown mode', () => { 188 + const toggle = createMarkdownToggle({ 189 + getEditorHtml: () => '<p>Test</p>', 190 + setEditorHtml: () => {}, 191 + htmlToMarkdown: () => 'Test\n', 192 + markdownToHtml: (md) => '<p>' + md + '</p>', 193 + }); 194 + 195 + // Setting in WYSIWYG mode should be ignored 196 + toggle.setMarkdownContent('# New'); 197 + expect(toggle.getMarkdownContent()).toBe(''); 198 + 199 + // Setting in markdown mode should work 200 + toggle.toggle(); 201 + toggle.setMarkdownContent('# Updated'); 202 + expect(toggle.getMarkdownContent()).toBe('# Updated'); 203 + }); 204 + }); 205 + });