feat: remove HTML-Markdown conversion features (htmlToMarkdown & markdownToHtml)
Removed both conversion tools and all related code: - Deleted page components: HtmlToMarkdown, MarkdownToHtml - Deleted utility functions: htmlToMarkdown.ts, markdownToHtml.ts - Deleted unit tests for both utilities - Removed feature configs and PageType entries from storage.d.ts - Removed preview mode types and StorageSchema keys - Removed all i18n translation keys from messages.json - Removed vendor-markdown chunk from wxt.config.ts - Removed marked dependency from package.json - Updated feature count in tests (11 -> 9, page order 10 -> 8) - Updated README, AGENTS.md, and copilot-instructions.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,265 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { htmlToMarkdown, downloadMarkdownFile, SAMPLE_HTML } from '../htmlToMarkdown';
|
||||
|
||||
describe('htmlToMarkdown', () => {
|
||||
it('returns empty result for empty string', () => {
|
||||
const result = htmlToMarkdown('');
|
||||
expect(result.markdown).toBe('');
|
||||
expect(result.originalLength).toBe(0);
|
||||
expect(result.markdownLength).toBe(0);
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('returns empty result for whitespace-only string', () => {
|
||||
const result = htmlToMarkdown(' \n ');
|
||||
expect(result.markdown).toBe('');
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('converts h1 heading', () => {
|
||||
const result = htmlToMarkdown('<h1>Hello World</h1>');
|
||||
expect(result.markdown).toContain('# Hello World');
|
||||
});
|
||||
|
||||
it('converts h2-h6 headings', () => {
|
||||
const result = htmlToMarkdown(
|
||||
'<h2>Two</h2><h3>Three</h3><h4>Four</h4><h5>Five</h5><h6>Six</h6>',
|
||||
);
|
||||
expect(result.markdown).toContain('## Two');
|
||||
expect(result.markdown).toContain('### Three');
|
||||
expect(result.markdown).toContain('#### Four');
|
||||
expect(result.markdown).toContain('##### Five');
|
||||
expect(result.markdown).toContain('###### Six');
|
||||
});
|
||||
|
||||
it('converts paragraph', () => {
|
||||
const result = htmlToMarkdown('<p>This is a paragraph.</p>');
|
||||
expect(result.markdown).toContain('This is a paragraph.');
|
||||
});
|
||||
|
||||
it('converts strong and b tags', () => {
|
||||
const result = htmlToMarkdown('<strong>bold</strong> and <b>also bold</b>');
|
||||
expect(result.markdown).toContain('**bold**');
|
||||
expect(result.markdown).toContain('**also bold**');
|
||||
});
|
||||
|
||||
it('converts em and i tags', () => {
|
||||
const result = htmlToMarkdown('<em>italic</em> and <i>also italic</i>');
|
||||
expect(result.markdown).toContain('*italic*');
|
||||
expect(result.markdown).toContain('*also italic*');
|
||||
});
|
||||
|
||||
it('converts del and s tags', () => {
|
||||
const result = htmlToMarkdown('<del>deleted</del> and <s>strikethrough</s>');
|
||||
expect(result.markdown).toContain('~~deleted~~');
|
||||
expect(result.markdown).toContain('~~strikethrough~~');
|
||||
});
|
||||
|
||||
it('converts inline code', () => {
|
||||
const result = htmlToMarkdown('<code>const x = 1;</code>');
|
||||
expect(result.markdown).toContain('`const x = 1;`');
|
||||
});
|
||||
|
||||
it('converts pre code block', () => {
|
||||
const result = htmlToMarkdown('<pre><code>line1\nline2</code></pre>');
|
||||
expect(result.markdown).toContain('```');
|
||||
expect(result.markdown).toContain('line1');
|
||||
expect(result.markdown).toContain('line2');
|
||||
});
|
||||
|
||||
it('converts pre code block with language', () => {
|
||||
const result = htmlToMarkdown(
|
||||
'<pre><code class="language-javascript">const x = 1;</code></pre>',
|
||||
);
|
||||
expect(result.markdown).toContain('```javascript');
|
||||
expect(result.markdown).toContain('const x = 1;');
|
||||
});
|
||||
|
||||
it('converts anchor links', () => {
|
||||
const result = htmlToMarkdown('<a href="https://example.com">Link text</a>');
|
||||
expect(result.markdown).toContain('[Link text](https://example.com)');
|
||||
});
|
||||
|
||||
it('converts anchor links with title', () => {
|
||||
const result = htmlToMarkdown('<a href="https://example.com" title="Title">Link</a>');
|
||||
expect(result.markdown).toContain('[Link](https://example.com "Title")');
|
||||
});
|
||||
|
||||
it('converts images', () => {
|
||||
const result = htmlToMarkdown('<img src="image.png" alt="desc" />');
|
||||
expect(result.markdown).toContain('');
|
||||
});
|
||||
|
||||
it('converts images with title', () => {
|
||||
const result = htmlToMarkdown('<img src="image.png" alt="desc" title="Title" />');
|
||||
expect(result.markdown).toContain('');
|
||||
});
|
||||
|
||||
it('converts unordered list', () => {
|
||||
const result = htmlToMarkdown('<ul><li>Item 1</li><li>Item 2</li></ul>');
|
||||
expect(result.markdown).toContain('- Item 1');
|
||||
expect(result.markdown).toContain('- Item 2');
|
||||
});
|
||||
|
||||
it('converts ordered list', () => {
|
||||
const result = htmlToMarkdown('<ol><li>First</li><li>Second</li></ol>');
|
||||
expect(result.markdown).toContain('1. First');
|
||||
expect(result.markdown).toContain('2. Second');
|
||||
});
|
||||
|
||||
it('converts ordered list with start attribute', () => {
|
||||
const result = htmlToMarkdown('<ol start="5"><li>Item</li></ol>');
|
||||
expect(result.markdown).toContain('5. Item');
|
||||
});
|
||||
|
||||
it('converts blockquote', () => {
|
||||
const result = htmlToMarkdown('<blockquote><p>Quote text</p></blockquote>');
|
||||
expect(result.markdown).toContain('> Quote text');
|
||||
});
|
||||
|
||||
it('converts horizontal rule', () => {
|
||||
const result = htmlToMarkdown('<hr />');
|
||||
expect(result.markdown).toContain('---');
|
||||
});
|
||||
|
||||
it('converts line break', () => {
|
||||
const result = htmlToMarkdown('Line 1<br />Line 2');
|
||||
expect(result.markdown).toContain('\n');
|
||||
});
|
||||
|
||||
it('converts table', () => {
|
||||
const html =
|
||||
'<table><tr><th>Name</th><th>Type</th></tr><tr><td>John</td><td>User</td></tr></table>';
|
||||
const result = htmlToMarkdown(html);
|
||||
expect(result.markdown).toContain('| Name | Type |');
|
||||
expect(result.markdown).toContain('| --- | --- |');
|
||||
expect(result.markdown).toContain('| John | User |');
|
||||
});
|
||||
|
||||
it('ignores script and style tags', () => {
|
||||
const result = htmlToMarkdown('<script>alert(1)</script><style>.x{}</style><p>text</p>');
|
||||
expect(result.markdown).not.toContain('alert');
|
||||
expect(result.markdown).not.toContain('.x{}');
|
||||
expect(result.markdown).toContain('text');
|
||||
});
|
||||
|
||||
it('handles nested elements', () => {
|
||||
const result = htmlToMarkdown('<p><strong>bold</strong> and <em>italic</em></p>');
|
||||
expect(result.markdown).toContain('**bold**');
|
||||
expect(result.markdown).toContain('*italic*');
|
||||
});
|
||||
|
||||
it('returns hasError false for valid HTML', () => {
|
||||
const result = htmlToMarkdown('<p>Valid</p>');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns correct length stats', () => {
|
||||
const html = '<p>Hello</p>';
|
||||
const result = htmlToMarkdown(html);
|
||||
expect(result.originalLength).toBe(html.length);
|
||||
expect(result.markdownLength).toBe(result.markdown.length);
|
||||
});
|
||||
|
||||
it('handles full HTML document', () => {
|
||||
const result = htmlToMarkdown(SAMPLE_HTML);
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.markdown).toContain('# 欢迎使用 HTML 转 Markdown');
|
||||
expect(result.markdown).toContain('**HTML**');
|
||||
expect(result.markdown).toContain('*Markdown*');
|
||||
expect(result.markdown).toContain('```javascript');
|
||||
expect(result.markdown).toContain('| 名称 | 类型 |');
|
||||
expect(result.markdown).toContain('> 这是一段引用文本');
|
||||
});
|
||||
|
||||
it('handles plain text without tags', () => {
|
||||
const result = htmlToMarkdown('Just plain text');
|
||||
expect(result.markdown).toContain('Just plain text');
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('handles task list items', () => {
|
||||
const result = htmlToMarkdown(
|
||||
'<ul><li><input type="checkbox" checked /> Done</li><li><input type="checkbox" /> Todo</li></ul>',
|
||||
);
|
||||
expect(result.markdown).toContain('- [x] Done');
|
||||
expect(result.markdown).toContain('- [ ] Todo');
|
||||
});
|
||||
|
||||
it('handles div and span wrappers', () => {
|
||||
const result = htmlToMarkdown('<div><span><p>Content</p></span></div>');
|
||||
expect(result.markdown).toContain('Content');
|
||||
});
|
||||
|
||||
it('handles empty anchor with no text', () => {
|
||||
const result = htmlToMarkdown('<a href="https://example.com"></a>');
|
||||
expect(result.markdown).not.toContain('[');
|
||||
});
|
||||
|
||||
it('handles unknown tags gracefully', () => {
|
||||
const result = htmlToMarkdown('<custom-tag>Content</custom-tag>');
|
||||
expect(result.markdown).toContain('Content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadMarkdownFile', () => {
|
||||
const originalURL = globalThis.URL;
|
||||
let createObjectURLSpy: ReturnType<typeof vi.fn>;
|
||||
let revokeObjectURLSpy: ReturnType<typeof vi.fn>;
|
||||
let clickSpy: ReturnType<typeof vi.fn>;
|
||||
let appendChildSpy: ReturnType<typeof vi.fn>;
|
||||
let removeChildSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
createObjectURLSpy = vi.fn().mockReturnValue('blob:test-url');
|
||||
revokeObjectURLSpy = vi.fn();
|
||||
(globalThis as any).URL = {
|
||||
createObjectURL: createObjectURLSpy,
|
||||
revokeObjectURL: revokeObjectURLSpy,
|
||||
};
|
||||
|
||||
clickSpy = vi.fn();
|
||||
appendChildSpy = vi.fn();
|
||||
removeChildSpy = vi.fn();
|
||||
|
||||
const mockLink = {
|
||||
href: '',
|
||||
download: '',
|
||||
click: clickSpy,
|
||||
};
|
||||
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(mockLink as any);
|
||||
vi.spyOn(document.body, 'appendChild').mockImplementation(appendChildSpy as any);
|
||||
vi.spyOn(document.body, 'removeChild').mockImplementation(removeChildSpy as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
(globalThis as any).URL = originalURL;
|
||||
});
|
||||
|
||||
it('should create a blob and trigger download', () => {
|
||||
downloadMarkdownFile('# Hello', 'test.md');
|
||||
|
||||
expect(createObjectURLSpy).toHaveBeenCalledOnce();
|
||||
expect(clickSpy).toHaveBeenCalledOnce();
|
||||
expect(appendChildSpy).toHaveBeenCalledOnce();
|
||||
expect(removeChildSpy).toHaveBeenCalledOnce();
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should use default filename when not provided', () => {
|
||||
downloadMarkdownFile('content');
|
||||
|
||||
const mockLink = (document.createElement as any).mock.results[0].value;
|
||||
expect(mockLink.download).toBe('export.md');
|
||||
});
|
||||
|
||||
it('should set correct MIME type', () => {
|
||||
downloadMarkdownFile('content');
|
||||
|
||||
const blobArg = createObjectURLSpy.mock.calls[0][0] as Blob;
|
||||
expect(blobArg.type).toBe('text/markdown;charset=utf-8');
|
||||
});
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
markdownToHtml,
|
||||
wrapHtmlDocument,
|
||||
downloadHtmlFile,
|
||||
SAMPLE_MARKDOWN,
|
||||
} from '@/utils/markdownToHtml';
|
||||
|
||||
describe('markdownToHtml', () => {
|
||||
it('应该转换基础 Markdown 标题', () => {
|
||||
const result = markdownToHtml('# Hello World');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<h1');
|
||||
expect(result.html).toContain('Hello World');
|
||||
expect(result.originalLength).toBe(13);
|
||||
expect(result.htmlLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('应该转换粗体和斜体', () => {
|
||||
const result = markdownToHtml('**bold** and *italic*');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<strong>bold</strong>');
|
||||
expect(result.html).toContain('<em>italic</em>');
|
||||
});
|
||||
|
||||
it('应该转换链接', () => {
|
||||
const result = markdownToHtml('[Google](https://google.com)');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<a');
|
||||
expect(result.html).toContain('href="https://google.com"');
|
||||
expect(result.html).toContain('Google');
|
||||
});
|
||||
|
||||
it('应该转换无序列表', () => {
|
||||
const result = markdownToHtml('- item 1\n- item 2');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<ul>');
|
||||
expect(result.html).toContain('<li>item 1</li>');
|
||||
});
|
||||
|
||||
it('应该转换有序列表', () => {
|
||||
const result = markdownToHtml('1. first\n2. second');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<ol>');
|
||||
expect(result.html).toContain('<li>first</li>');
|
||||
});
|
||||
|
||||
it('应该转换代码块', () => {
|
||||
const result = markdownToHtml('```js\nconst x = 1;\n```');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<pre>');
|
||||
expect(result.html).toContain('<code');
|
||||
expect(result.html).toContain('const x = 1;');
|
||||
});
|
||||
|
||||
it('应该转换行内代码', () => {
|
||||
const result = markdownToHtml('use `npm install` command');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<code>npm install</code>');
|
||||
});
|
||||
|
||||
it('应该转换引用块', () => {
|
||||
const result = markdownToHtml('> This is a quote');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<blockquote>');
|
||||
expect(result.html).toContain('This is a quote');
|
||||
});
|
||||
|
||||
it('应该转换表格', () => {
|
||||
const md = '| A | B |\n|---|---|\n| 1 | 2 |';
|
||||
const result = markdownToHtml(md);
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<table>');
|
||||
expect(result.html).toContain('<th>A</th>');
|
||||
expect(result.html).toContain('<td>1</td>');
|
||||
});
|
||||
|
||||
it('应该转换任务列表', () => {
|
||||
const result = markdownToHtml('- [x] done\n- [ ] todo');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<input');
|
||||
expect(result.html).toContain('checked');
|
||||
});
|
||||
|
||||
it('应该转换删除线', () => {
|
||||
const result = markdownToHtml('~~deleted~~');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<del>deleted</del>');
|
||||
});
|
||||
|
||||
it('应该处理空字符串', () => {
|
||||
const result = markdownToHtml('');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toBe('');
|
||||
expect(result.originalLength).toBe(0);
|
||||
expect(result.htmlLength).toBe(0);
|
||||
});
|
||||
|
||||
it('应该处理空白字符串', () => {
|
||||
const result = markdownToHtml(' \n ');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toBe('');
|
||||
});
|
||||
|
||||
it('应该处理中文内容', () => {
|
||||
const result = markdownToHtml('# 你好世界\n\n这是**中文**内容。');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('你好世界');
|
||||
expect(result.html).toContain('<strong>中文</strong>');
|
||||
});
|
||||
|
||||
it('应该转换示例 Markdown', () => {
|
||||
const result = markdownToHtml(SAMPLE_MARKDOWN);
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<h1');
|
||||
expect(result.html).toContain('<h2');
|
||||
expect(result.html).toContain('<table>');
|
||||
expect(result.html).toContain('<code>');
|
||||
expect(result.originalLength).toBe(SAMPLE_MARKDOWN.length);
|
||||
expect(result.htmlLength).toBeGreaterThan(result.originalLength);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrapHtmlDocument', () => {
|
||||
it('应该生成完整的 HTML 文档', () => {
|
||||
const doc = wrapHtmlDocument('<p>Hello</p>', 'Test Title');
|
||||
expect(doc).toContain('<!DOCTYPE html>');
|
||||
expect(doc).toContain('<html');
|
||||
expect(doc).toContain('<head>');
|
||||
expect(doc).toContain('<title>Test Title</title>');
|
||||
expect(doc).toContain('<body>');
|
||||
expect(doc).toContain('<p>Hello</p>');
|
||||
expect(doc).toContain('</html>');
|
||||
});
|
||||
|
||||
it('应该转义标题中的特殊字符', () => {
|
||||
const doc = wrapHtmlDocument('<p>test</p>', 'Title <script>');
|
||||
expect(doc).toContain('Title <script>');
|
||||
expect(doc).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('应该使用默认标题', () => {
|
||||
const doc = wrapHtmlDocument('<p>test</p>');
|
||||
expect(doc).toContain('<title>Markdown Export</title>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadHtmlFile', () => {
|
||||
it('应该创建下载链接并触发下载', () => {
|
||||
const createObjectURLSpy = vi.fn(() => 'blob:test');
|
||||
const revokeObjectURLSpy = vi.fn();
|
||||
(globalThis as any).URL = {
|
||||
createObjectURL: createObjectURLSpy,
|
||||
revokeObjectURL: revokeObjectURLSpy,
|
||||
};
|
||||
|
||||
const clickSpy = vi.fn();
|
||||
const mockAnchor = document.createElement('a');
|
||||
mockAnchor.click = clickSpy;
|
||||
|
||||
const createElementSpy = vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor);
|
||||
const appendChildSpy = vi.spyOn(document.body, 'appendChild').mockReturnValue(mockAnchor);
|
||||
const removeChildSpy = vi.spyOn(document.body, 'removeChild').mockReturnValue(mockAnchor);
|
||||
|
||||
downloadHtmlFile('<p>test</p>', 'test.html');
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
expect(appendChildSpy).toHaveBeenCalled();
|
||||
expect(removeChildSpy).toHaveBeenCalled();
|
||||
expect(createObjectURLSpy).toHaveBeenCalled();
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalled();
|
||||
|
||||
createElementSpy.mockRestore();
|
||||
appendChildSpy.mockRestore();
|
||||
removeChildSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,315 +0,0 @@
|
||||
/**
|
||||
* HTML 转 Markdown 转换器工具函数
|
||||
*
|
||||
* 基于 DOM 解析实现,支持常见 HTML 标签到 Markdown 的转换。
|
||||
*/
|
||||
|
||||
/**
|
||||
* HTML 转 Markdown 转换结果
|
||||
*/
|
||||
export interface HtmlToMarkdownResult {
|
||||
/** 转换后的 Markdown 字符串 */
|
||||
markdown: string;
|
||||
/** 原始 HTML 文本长度 */
|
||||
originalLength: number;
|
||||
/** 生成的 Markdown 长度 */
|
||||
markdownLength: number;
|
||||
/** 是否包含错误 */
|
||||
hasError: boolean;
|
||||
/** 错误信息(如果有) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 HTML 文本转换为 Markdown
|
||||
*
|
||||
* @param html - HTML 源文本
|
||||
* @returns HtmlToMarkdownResult 转换结果
|
||||
*/
|
||||
export function htmlToMarkdown(html: string): HtmlToMarkdownResult {
|
||||
try {
|
||||
const trimmed = html.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
markdown: '',
|
||||
originalLength: 0,
|
||||
markdownLength: 0,
|
||||
hasError: false,
|
||||
};
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(trimmed, 'text/html');
|
||||
|
||||
const parserError = doc.querySelector('parsererror');
|
||||
if (parserError) {
|
||||
return {
|
||||
markdown: '',
|
||||
originalLength: html.length,
|
||||
markdownLength: 0,
|
||||
hasError: true,
|
||||
error: 'HTML 解析失败:无效的 HTML 结构',
|
||||
};
|
||||
}
|
||||
|
||||
const markdown = convertNode(doc.body).trim();
|
||||
|
||||
return {
|
||||
markdown,
|
||||
originalLength: html.length,
|
||||
markdownLength: markdown.length,
|
||||
hasError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
markdown: '',
|
||||
originalLength: html.length,
|
||||
markdownLength: 0,
|
||||
hasError: true,
|
||||
error: error instanceof Error ? error.message : 'HTML 转换失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归转换 DOM 节点为 Markdown
|
||||
*/
|
||||
function convertNode(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return escapeMarkdownChars(node.textContent ?? '');
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const element = node as HTMLElement;
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
const children = Array.from(element.childNodes);
|
||||
const inner = children.map(convertNode).join('');
|
||||
|
||||
switch (tagName) {
|
||||
case 'h1':
|
||||
return `\n# ${inner.trim()}\n\n`;
|
||||
case 'h2':
|
||||
return `\n## ${inner.trim()}\n\n`;
|
||||
case 'h3':
|
||||
return `\n### ${inner.trim()}\n\n`;
|
||||
case 'h4':
|
||||
return `\n#### ${inner.trim()}\n\n`;
|
||||
case 'h5':
|
||||
return `\n##### ${inner.trim()}\n\n`;
|
||||
case 'h6':
|
||||
return `\n###### ${inner.trim()}\n\n`;
|
||||
case 'p':
|
||||
return `\n${inner.trim()}\n\n`;
|
||||
case 'br':
|
||||
return '\n';
|
||||
case 'hr':
|
||||
return '\n---\n\n';
|
||||
case 'strong':
|
||||
case 'b':
|
||||
return `**${inner.trim()}**`;
|
||||
case 'em':
|
||||
case 'i':
|
||||
return `*${inner.trim()}*`;
|
||||
case 'del':
|
||||
case 's':
|
||||
case 'strike':
|
||||
return `~~${inner.trim()}~~`;
|
||||
case 'code':
|
||||
return `\`${inner.trim()}\``;
|
||||
case 'pre': {
|
||||
const code = element.querySelector('code');
|
||||
if (code) {
|
||||
const lang = code.getAttribute('class')?.replace(/^language-/, '') ?? '';
|
||||
return `\n\`\`\`${lang}\n${code.textContent?.trim() ?? ''}\n\`\`\`\n\n`;
|
||||
}
|
||||
return `\n\`\`\`\n${inner.trim()}\n\`\`\`\n\n`;
|
||||
}
|
||||
case 'a': {
|
||||
const href = element.getAttribute('href') ?? '';
|
||||
const title = element.getAttribute('title');
|
||||
const text = inner.trim();
|
||||
if (!text) return '';
|
||||
if (title) {
|
||||
return `[${text}](${href} "${title}")`;
|
||||
}
|
||||
return `[${text}](${href})`;
|
||||
}
|
||||
case 'img': {
|
||||
const src = element.getAttribute('src') ?? '';
|
||||
const alt = element.getAttribute('alt') ?? '';
|
||||
const imgTitle = element.getAttribute('title');
|
||||
if (imgTitle) {
|
||||
return ``;
|
||||
}
|
||||
return ``;
|
||||
}
|
||||
case 'blockquote':
|
||||
return `\n${inner
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => (line.trim() ? `> ${line}` : line))
|
||||
.join('\n')}\n\n`;
|
||||
case 'ul': {
|
||||
const items = children
|
||||
.filter((child) => (child as HTMLElement).tagName?.toLowerCase() === 'li')
|
||||
.map((li) => {
|
||||
const liElement = li as HTMLElement;
|
||||
const task = liElement.querySelector('input[type="checkbox"]');
|
||||
const liText = convertNode(li).trim();
|
||||
if (task) {
|
||||
const checked = (task as HTMLInputElement).checked;
|
||||
return `- [${checked ? 'x' : ' '}] ${liText.replace(/^\[?[ x]\]?\s*/, '')}`;
|
||||
}
|
||||
return `- ${liText}`;
|
||||
})
|
||||
.join('\n');
|
||||
return `\n${items}\n\n`;
|
||||
}
|
||||
case 'ol': {
|
||||
let index = 1;
|
||||
const start = element.getAttribute('start');
|
||||
if (start) {
|
||||
const parsed = parseInt(start, 10);
|
||||
if (!isNaN(parsed)) index = parsed;
|
||||
}
|
||||
const items = children
|
||||
.filter((child) => (child as HTMLElement).tagName?.toLowerCase() === 'li')
|
||||
.map((li) => {
|
||||
const liElement = li as HTMLElement;
|
||||
const task = liElement.querySelector('input[type="checkbox"]');
|
||||
const liText = convertNode(li).trim();
|
||||
if (task) {
|
||||
const checked = (task as HTMLInputElement).checked;
|
||||
return `${index++}. [${checked ? 'x' : ' '}] ${liText.replace(/^\[?[ x]\]?\s*/, '')}`;
|
||||
}
|
||||
return `${index++}. ${liText}`;
|
||||
})
|
||||
.join('\n');
|
||||
return `\n${items}\n\n`;
|
||||
}
|
||||
case 'li': {
|
||||
// li 内容由 ul/ol 处理,这里只返回内部文本
|
||||
return inner.trim();
|
||||
}
|
||||
case 'table': {
|
||||
return convertTable(element);
|
||||
}
|
||||
case 'div':
|
||||
case 'span':
|
||||
case 'section':
|
||||
case 'article':
|
||||
case 'main':
|
||||
case 'header':
|
||||
case 'footer':
|
||||
case 'aside':
|
||||
return inner;
|
||||
case 'script':
|
||||
case 'style':
|
||||
case 'noscript':
|
||||
return '';
|
||||
default:
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换表格元素为 Markdown
|
||||
*/
|
||||
function convertTable(table: HTMLElement): string {
|
||||
const rows = Array.from(table.querySelectorAll('tr'));
|
||||
if (rows.length === 0) return '';
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
const headerRow = rows[0];
|
||||
const headerCells = Array.from(headerRow.querySelectorAll('th, td'));
|
||||
const headers = headerCells.map((cell) => (cell.textContent ?? '').trim());
|
||||
lines.push('| ' + headers.join(' | ') + ' |');
|
||||
|
||||
// 分隔行
|
||||
const aligns = headerCells.map((cell) => {
|
||||
const style = (cell as HTMLElement).style.textAlign;
|
||||
if (style === 'center') return ':---:';
|
||||
if (style === 'right') return '---:';
|
||||
return '---';
|
||||
});
|
||||
lines.push('| ' + aligns.join(' | ') + ' |');
|
||||
|
||||
// 数据行(从第二行开始)
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const cells = Array.from(rows[i].querySelectorAll('td, th'));
|
||||
const values = cells.map((cell) => (cell.textContent ?? '').trim());
|
||||
lines.push('| ' + values.join(' | ') + ' |');
|
||||
}
|
||||
|
||||
return '\n' + lines.join('\n') + '\n\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义 Markdown 特殊字符(在行内文本中)
|
||||
*/
|
||||
function escapeMarkdownChars(text: string): string {
|
||||
// 仅在特定上下文中需要转义,这里简单处理
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例 HTML 文本(用于占位提示)
|
||||
*/
|
||||
export const SAMPLE_HTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>示例</title></head>
|
||||
<body>
|
||||
<h1>欢迎使用 HTML 转 Markdown</h1>
|
||||
<p>这是一个 <strong>HTML</strong> 到 <em>Markdown</em> 转换器。</p>
|
||||
|
||||
<h2>支持的标签</h2>
|
||||
<ul>
|
||||
<li>标题:h1-h6</li>
|
||||
<li>文本格式:strong、em、del、code</li>
|
||||
<li>链接和图像</li>
|
||||
<li>列表:ul、ol</li>
|
||||
<li>表格:table</li>
|
||||
<li>引用:blockquote</li>
|
||||
</ul>
|
||||
|
||||
<h3>代码示例</h3>
|
||||
<pre><code class="language-javascript">function hello() {
|
||||
console.log('Hello, World!');
|
||||
}</code></pre>
|
||||
|
||||
<h3>表格示例</h3>
|
||||
<table>
|
||||
<tr><th>名称</th><th>类型</th></tr>
|
||||
<tr><td>name</td><td>string</td></tr>
|
||||
<tr><td>age</td><td>number</td></tr>
|
||||
</table>
|
||||
|
||||
<blockquote>
|
||||
<p>这是一段引用文本。</p>
|
||||
</blockquote>
|
||||
|
||||
<p>开始转换你的 HTML 内容吧!</p>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
/**
|
||||
* 下载 Markdown 文件
|
||||
*
|
||||
* @param content - Markdown 内容
|
||||
* @param filename - 下载文件名
|
||||
*/
|
||||
export function downloadMarkdownFile(content: string, filename: string = 'export.md'): void {
|
||||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
import { marked, type MarkedOptions } from 'marked';
|
||||
|
||||
/**
|
||||
* Markdown 转 HTML 转换结果
|
||||
*/
|
||||
export interface MarkdownToHtmlResult {
|
||||
/** 转换后的 HTML 字符串 */
|
||||
html: string;
|
||||
/** 原始 Markdown 文本长度 */
|
||||
originalLength: number;
|
||||
/** 生成的 HTML 长度 */
|
||||
htmlLength: number;
|
||||
/** 是否包含错误 */
|
||||
hasError: boolean;
|
||||
/** 错误信息(如果有) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认的 Markdown 渲染选项配置
|
||||
*/
|
||||
const defaultMarkedOptions: MarkedOptions = {
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* 将 Markdown 文本转换为 HTML
|
||||
*
|
||||
* @param markdown - Markdown 源文本
|
||||
* @returns MarkdownToHtmlResult 转换结果
|
||||
*/
|
||||
export function markdownToHtml(markdown: string): MarkdownToHtmlResult {
|
||||
try {
|
||||
const trimmed = markdown.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
html: '',
|
||||
originalLength: 0,
|
||||
htmlLength: 0,
|
||||
hasError: false,
|
||||
};
|
||||
}
|
||||
|
||||
const html = marked.parse(trimmed, { ...defaultMarkedOptions, async: false });
|
||||
|
||||
return {
|
||||
html: html.trim(),
|
||||
originalLength: markdown.length,
|
||||
htmlLength: html.length,
|
||||
hasError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
html: '',
|
||||
originalLength: markdown.length,
|
||||
htmlLength: 0,
|
||||
hasError: true,
|
||||
error: error instanceof Error ? error.message : 'Markdown 解析失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 HTML 内容包装完整的文档结构(用于打印/下载)
|
||||
*
|
||||
* @param html - 主体 HTML 内容
|
||||
* @param title - 文档标题
|
||||
* @returns 完整的 HTML 文档字符串
|
||||
*/
|
||||
export function wrapHtmlDocument(html: string, title: string = 'Markdown Export'): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
h1 { font-size: 2em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
|
||||
h2 { font-size: 1.5em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
|
||||
h3 { font-size: 1.25em; }
|
||||
p { margin-top: 0; margin-bottom: 16px; }
|
||||
a { color: #0366d6; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
code {
|
||||
background-color: rgba(27, 31, 35, 0.05);
|
||||
border-radius: 3px;
|
||||
font-size: 85%;
|
||||
margin: 0;
|
||||
padding: 0.2em 0.4em;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
}
|
||||
pre {
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
font-size: 85%;
|
||||
line-height: 1.45;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
pre code {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
display: inline;
|
||||
line-height: inherit;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
word-wrap: normal;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 0.25em solid #dfe2e5;
|
||||
color: #6a737d;
|
||||
margin: 0;
|
||||
padding: 0 1em;
|
||||
}
|
||||
ul, ol { margin-top: 0; margin-bottom: 16px; padding-left: 2em; }
|
||||
li + li { margin-top: 0.25em; }
|
||||
img { max-width: 100%; box-sizing: content-box; }
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
display: block;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
}
|
||||
table th, table td {
|
||||
border: 1px solid #dfe2e5;
|
||||
padding: 6px 13px;
|
||||
}
|
||||
table tr:nth-child(2n) { background-color: #f6f8fa; }
|
||||
table th { font-weight: 600; background-color: #f6f8fa; }
|
||||
hr {
|
||||
background-color: #e1e4e8;
|
||||
border: 0;
|
||||
height: 0.25em;
|
||||
margin: 24px 0;
|
||||
padding: 0;
|
||||
}
|
||||
input[type="checkbox"] { margin-right: 0.5em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${html}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义 HTML 特殊字符
|
||||
*/
|
||||
function escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 HTML 文件
|
||||
*
|
||||
* @param content - 文件内容
|
||||
* @param filename - 下载文件名
|
||||
*/
|
||||
export function downloadHtmlFile(content: string, filename: string = 'export.html'): void {
|
||||
const blob = new Blob([content], { type: 'text/html;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印 HTML 内容
|
||||
*
|
||||
* @param html - 要打印的 HTML 内容
|
||||
* @param title - 打印窗口标题
|
||||
*/
|
||||
export function printHtml(html: string, title: string = 'Markdown Preview'): void {
|
||||
const doc = wrapHtmlDocument(html, title);
|
||||
const blob = new Blob([doc], { type: 'text/html;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const printWindow = window.open(url, '_blank', 'width=800,height=600');
|
||||
if (!printWindow) {
|
||||
URL.revokeObjectURL(url);
|
||||
console.error('无法打开打印窗口,请检查浏览器弹窗拦截设置');
|
||||
return;
|
||||
}
|
||||
|
||||
// 等待样式加载完成后打印
|
||||
let printed = false;
|
||||
printWindow.onload = () => {
|
||||
if (!printed) {
|
||||
printed = true;
|
||||
printWindow.print();
|
||||
}
|
||||
};
|
||||
// 部分浏览器 onload 不触发,使用延迟回退
|
||||
setTimeout(() => {
|
||||
if (!printed) {
|
||||
printed = true;
|
||||
printWindow.print();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// 打印完成后释放 Blob URL(浏览器标签页关闭后也会自动回收)
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例 Markdown 文本(用于占位提示)
|
||||
*/
|
||||
export const SAMPLE_MARKDOWN = `# 欢迎使用 Markdown 转 HTML
|
||||
|
||||
这是一个 **Markdown** 编辑器,支持实时预览。
|
||||
|
||||
## 基础语法
|
||||
|
||||
### 标题
|
||||
使用 \`#\` 符号表示不同级别的标题。
|
||||
|
||||
### 列表
|
||||
- 无序列表项 1
|
||||
- 无序列表项 2
|
||||
- 嵌套列表项
|
||||
|
||||
1. 有序列表项 1
|
||||
2. 有序列表项 2
|
||||
|
||||
### 文本样式
|
||||
- **粗体文本**
|
||||
- *斜体文本*
|
||||
- ~~删除线文本~~
|
||||
- \`行内代码\`
|
||||
|
||||
### 链接与图片
|
||||
[访问 GitHub](https://github.com)
|
||||
|
||||
### 代码块
|
||||
\`\`\`javascript
|
||||
function hello() {
|
||||
console.log('Hello, World!');
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 扩展语法
|
||||
|
||||
### 表格
|
||||
| 名称 | 类型 | 描述 |
|
||||
|------|------|------|
|
||||
| name | string | 用户名 |
|
||||
| age | number | 年龄 |
|
||||
|
||||
### 任务列表
|
||||
- [x] 已完成任务
|
||||
- [ ] 待办任务
|
||||
|
||||
### 引用
|
||||
> 这是一段引用文本。
|
||||
> 可以有多行。
|
||||
|
||||
### 分割线
|
||||
|
||||
---
|
||||
|
||||
*开始编辑你的 Markdown 内容吧!*`;
|
||||
Reference in New Issue
Block a user