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:
@@ -102,7 +102,7 @@ pages/FeatureName/
|
|||||||
|
|
||||||
### 代码分割
|
### 代码分割
|
||||||
|
|
||||||
`wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组 vendor 依赖(vendor-react、vendor-i18n、vendor-qr、vendor-dnd、vendor-markdown),无需手动配置。
|
`wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组 vendor 依赖(vendor-react、vendor-i18n、vendor-qr、vendor-dnd),无需手动配置。
|
||||||
|
|
||||||
### 代码风格
|
### 代码风格
|
||||||
|
|
||||||
|
|||||||
@@ -142,7 +142,6 @@ src/pages/FeatureName/
|
|||||||
|
|
||||||
- `@webext-core/messaging` — 扩展消息通信
|
- `@webext-core/messaging` — 扩展消息通信
|
||||||
- `@dnd-kit` — 拖拽排序(用于页面顺序管理)
|
- `@dnd-kit` — 拖拽排序(用于页面顺序管理)
|
||||||
- `marked` — Markdown 解析
|
|
||||||
- `qrious` + `qr-scanner` — 二维码生成与解析
|
- `qrious` + `qr-scanner` — 二维码生成与解析
|
||||||
- `dayjs` — 日期处理(时间戳转换)
|
- `dayjs` — 日期处理(时间戳转换)
|
||||||
- `sonner` — Toast 通知(替代传统 snackbar)
|
- `sonner` — Toast 通知(替代传统 snackbar)
|
||||||
|
|||||||
@@ -63,11 +63,6 @@
|
|||||||
- **文件转换**: 支持文件与 Base64 字符串互转.
|
- **文件转换**: 支持文件与 Base64 字符串互转.
|
||||||
- **图像预览**: 支持图片 Base64 编码与实时预览.
|
- **图像预览**: 支持图片 Base64 编码与实时预览.
|
||||||
|
|
||||||
### 📝 Markdown ↔ HTML 转换
|
|
||||||
|
|
||||||
- **Markdown 转 HTML**: 实时预览 Markdown 渲染结果,支持导出 HTML 源码.
|
|
||||||
- **HTML 转 Markdown**: 将 HTML 内容转换为 Markdown 格式.
|
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
|
- **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
|
||||||
|
|||||||
Generated
-13
@@ -26,7 +26,6 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
"marked": "^18.0.4",
|
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
"qrious": "^4.0.2",
|
"qrious": "^4.0.2",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
@@ -8388,18 +8387,6 @@
|
|||||||
"url": "https://github.com/sponsors/fregante"
|
"url": "https://github.com/sponsors/fregante"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/marked": {
|
|
||||||
"version": "18.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz",
|
|
||||||
"integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"marked": "bin/marked.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/marky": {
|
"node_modules/marky": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz",
|
||||||
|
|||||||
@@ -37,7 +37,6 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
"marked": "^18.0.4",
|
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
"qrious": "^4.0.2",
|
"qrious": "^4.0.2",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
|
|||||||
@@ -131,22 +131,6 @@
|
|||||||
"message": "文本、文件与图像的 Base64 编码转换",
|
"message": "文本、文件与图像的 Base64 编码转换",
|
||||||
"description": "Translation key: base64Converter_description"
|
"description": "Translation key: base64Converter_description"
|
||||||
},
|
},
|
||||||
"markdownToHtml_title": {
|
|
||||||
"message": "Markdown 转 HTML",
|
|
||||||
"description": "Translation key: markdownToHtml_title"
|
|
||||||
},
|
|
||||||
"markdownToHtml_description": {
|
|
||||||
"message": "实时 Markdown 转换与 HTML 预览",
|
|
||||||
"description": "Translation key: markdownToHtml_description"
|
|
||||||
},
|
|
||||||
"htmlToMarkdown_title": {
|
|
||||||
"message": "HTML 转 Markdown",
|
|
||||||
"description": "Translation key: htmlToMarkdown_title"
|
|
||||||
},
|
|
||||||
"htmlToMarkdown_description": {
|
|
||||||
"message": "实时 HTML 转换与 Markdown 预览",
|
|
||||||
"description": "Translation key: htmlToMarkdown_description"
|
|
||||||
},
|
|
||||||
"rightClickRestorer_title": {
|
"rightClickRestorer_title": {
|
||||||
"message": "右键恢复",
|
"message": "右键恢复",
|
||||||
"description": "Translation key: rightClickRestorer_title"
|
"description": "Translation key: rightClickRestorer_title"
|
||||||
@@ -291,54 +275,6 @@
|
|||||||
"message": "解码大小",
|
"message": "解码大小",
|
||||||
"description": "Translation key: base64Converter_decodedSize"
|
"description": "Translation key: base64Converter_decodedSize"
|
||||||
},
|
},
|
||||||
"htmlToMarkdown_pageTitle": {
|
|
||||||
"message": "HTML 转 Markdown",
|
|
||||||
"description": "Translation key: htmlToMarkdown_pageTitle"
|
|
||||||
},
|
|
||||||
"htmlToMarkdown_splitMode": {
|
|
||||||
"message": "分屏",
|
|
||||||
"description": "Translation key: htmlToMarkdown_splitMode"
|
|
||||||
},
|
|
||||||
"htmlToMarkdown_previewMode": {
|
|
||||||
"message": "预览",
|
|
||||||
"description": "Translation key: htmlToMarkdown_previewMode"
|
|
||||||
},
|
|
||||||
"htmlToMarkdown_markdownMode": {
|
|
||||||
"message": "Markdown",
|
|
||||||
"description": "Translation key: htmlToMarkdown_markdownMode"
|
|
||||||
},
|
|
||||||
"htmlToMarkdown_clear": {
|
|
||||||
"message": "清空",
|
|
||||||
"description": "Translation key: htmlToMarkdown_clear"
|
|
||||||
},
|
|
||||||
"htmlToMarkdown_inputLabel": {
|
|
||||||
"message": "HTML 输入",
|
|
||||||
"description": "Translation key: htmlToMarkdown_inputLabel"
|
|
||||||
},
|
|
||||||
"htmlToMarkdown_previewLabel": {
|
|
||||||
"message": "Markdown 预览",
|
|
||||||
"description": "Translation key: htmlToMarkdown_previewLabel"
|
|
||||||
},
|
|
||||||
"htmlToMarkdown_markdownOutputLabel": {
|
|
||||||
"message": "Markdown 输出",
|
|
||||||
"description": "Translation key: htmlToMarkdown_markdownOutputLabel"
|
|
||||||
},
|
|
||||||
"htmlToMarkdown_inputPlaceholder": {
|
|
||||||
"message": "在此输入 HTML 内容...",
|
|
||||||
"description": "Translation key: htmlToMarkdown_inputPlaceholder"
|
|
||||||
},
|
|
||||||
"htmlToMarkdown_charCount": {
|
|
||||||
"message": "{{count}} 字符",
|
|
||||||
"description": "Translation key: htmlToMarkdown_charCount"
|
|
||||||
},
|
|
||||||
"htmlToMarkdown_download": {
|
|
||||||
"message": "下载",
|
|
||||||
"description": "Translation key: htmlToMarkdown_download"
|
|
||||||
},
|
|
||||||
"htmlToMarkdown_emptyHint": {
|
|
||||||
"message": "输入 HTML 内容以查看转换结果",
|
|
||||||
"description": "Translation key: htmlToMarkdown_emptyHint"
|
|
||||||
},
|
|
||||||
"jsonDiff_pageTitle": {
|
"jsonDiff_pageTitle": {
|
||||||
"message": "JSON 差异比较",
|
"message": "JSON 差异比较",
|
||||||
"description": "Translation key: jsonDiff_pageTitle"
|
"description": "Translation key: jsonDiff_pageTitle"
|
||||||
@@ -543,54 +479,6 @@
|
|||||||
"message": "解析 Payload 失败:",
|
"message": "解析 Payload 失败:",
|
||||||
"description": "Translation key: jwt_errors_parsePayloadFailed"
|
"description": "Translation key: jwt_errors_parsePayloadFailed"
|
||||||
},
|
},
|
||||||
"markdownToHtml_pageTitle": {
|
|
||||||
"message": "Markdown 转 HTML",
|
|
||||||
"description": "Translation key: markdownToHtml_pageTitle"
|
|
||||||
},
|
|
||||||
"markdownToHtml_splitMode": {
|
|
||||||
"message": "分屏",
|
|
||||||
"description": "Translation key: markdownToHtml_splitMode"
|
|
||||||
},
|
|
||||||
"markdownToHtml_previewMode": {
|
|
||||||
"message": "预览",
|
|
||||||
"description": "Translation key: markdownToHtml_previewMode"
|
|
||||||
},
|
|
||||||
"markdownToHtml_htmlMode": {
|
|
||||||
"message": "HTML",
|
|
||||||
"description": "Translation key: markdownToHtml_htmlMode"
|
|
||||||
},
|
|
||||||
"markdownToHtml_clear": {
|
|
||||||
"message": "清空",
|
|
||||||
"description": "Translation key: markdownToHtml_clear"
|
|
||||||
},
|
|
||||||
"markdownToHtml_print": {
|
|
||||||
"message": "打印",
|
|
||||||
"description": "Translation key: markdownToHtml_print"
|
|
||||||
},
|
|
||||||
"markdownToHtml_download": {
|
|
||||||
"message": "下载",
|
|
||||||
"description": "Translation key: markdownToHtml_download"
|
|
||||||
},
|
|
||||||
"markdownToHtml_inputLabel": {
|
|
||||||
"message": "Markdown 输入",
|
|
||||||
"description": "Translation key: markdownToHtml_inputLabel"
|
|
||||||
},
|
|
||||||
"markdownToHtml_previewLabel": {
|
|
||||||
"message": "实时预览",
|
|
||||||
"description": "Translation key: markdownToHtml_previewLabel"
|
|
||||||
},
|
|
||||||
"markdownToHtml_htmlOutputLabel": {
|
|
||||||
"message": "HTML 输出",
|
|
||||||
"description": "Translation key: markdownToHtml_htmlOutputLabel"
|
|
||||||
},
|
|
||||||
"markdownToHtml_inputPlaceholder": {
|
|
||||||
"message": "在此输入 Markdown 内容...",
|
|
||||||
"description": "Translation key: markdownToHtml_inputPlaceholder"
|
|
||||||
},
|
|
||||||
"markdownToHtml_charCount": {
|
|
||||||
"message": "{{count}} 字符",
|
|
||||||
"description": "Translation key: markdownToHtml_charCount"
|
|
||||||
},
|
|
||||||
"qrCode_pageTitle": {
|
"qrCode_pageTitle": {
|
||||||
"message": "二维码工具",
|
"message": "二维码工具",
|
||||||
"description": "Translation key: qrCode_pageTitle"
|
"description": "Translation key: qrCode_pageTitle"
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
|
|
||||||
describe('features', () => {
|
describe('features', () => {
|
||||||
describe('FEATURES', () => {
|
describe('FEATURES', () => {
|
||||||
it('should have 11 features defined', () => {
|
it('should have 9 features defined', () => {
|
||||||
expect(FEATURES).toHaveLength(11);
|
expect(FEATURES).toHaveLength(9);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have all required properties for each feature', () => {
|
it('should have all required properties for each feature', () => {
|
||||||
@@ -95,7 +95,7 @@ describe('features', () => {
|
|||||||
describe('getAllFeatureKeys', () => {
|
describe('getAllFeatureKeys', () => {
|
||||||
it('should return all feature keys', () => {
|
it('should return all feature keys', () => {
|
||||||
const allKeys = getAllFeatureKeys();
|
const allKeys = getAllFeatureKeys();
|
||||||
expect(allKeys).toHaveLength(11);
|
expect(allKeys).toHaveLength(9);
|
||||||
expect(allKeys).toContain('dashboard');
|
expect(allKeys).toContain('dashboard');
|
||||||
expect(allKeys).toContain('timestamp');
|
expect(allKeys).toContain('timestamp');
|
||||||
expect(allKeys).toContain('storageCleaner');
|
expect(allKeys).toContain('storageCleaner');
|
||||||
@@ -104,8 +104,6 @@ describe('features', () => {
|
|||||||
expect(allKeys).toContain('jwt');
|
expect(allKeys).toContain('jwt');
|
||||||
expect(allKeys).toContain('jsonDiff');
|
expect(allKeys).toContain('jsonDiff');
|
||||||
expect(allKeys).toContain('base64Converter');
|
expect(allKeys).toContain('base64Converter');
|
||||||
expect(allKeys).toContain('markdownToHtml');
|
|
||||||
expect(allKeys).toContain('htmlToMarkdown');
|
|
||||||
expect(allKeys).toContain('rightClickRestorer');
|
expect(allKeys).toContain('rightClickRestorer');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -123,9 +121,9 @@ describe('features', () => {
|
|||||||
expect(pageOrder).toContain('qrCode');
|
expect(pageOrder).toContain('qrCode');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have 10 items in page order', () => {
|
it('should have 8 items in page order', () => {
|
||||||
const pageOrder = getDefaultPageOrder();
|
const pageOrder = getDefaultPageOrder();
|
||||||
expect(pageOrder).toHaveLength(10);
|
expect(pageOrder).toHaveLength(8);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import {
|
|||||||
Key,
|
Key,
|
||||||
GitCompareArrows,
|
GitCompareArrows,
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
Code,
|
|
||||||
File,
|
|
||||||
MousePointerClick,
|
MousePointerClick,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -25,8 +23,6 @@ const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics'));
|
|||||||
const JwtPage = lazy(() => import('@/pages/Jwt'));
|
const JwtPage = lazy(() => import('@/pages/Jwt'));
|
||||||
const JsonToolsPage = lazy(() => import('@/pages/JsonTools'));
|
const JsonToolsPage = lazy(() => import('@/pages/JsonTools'));
|
||||||
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
|
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
|
||||||
const MarkdownToHtmlPage = lazy(() => import('@/pages/MarkdownToHtml'));
|
|
||||||
const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown'));
|
|
||||||
const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer'));
|
const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer'));
|
||||||
|
|
||||||
export interface FeatureConfig {
|
export interface FeatureConfig {
|
||||||
@@ -146,32 +142,6 @@ export const FEATURES: FeatureConfig[] = [
|
|||||||
tab: Base64ConverterPage,
|
tab: Base64ConverterPage,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'markdownToHtml',
|
|
||||||
labelKey: 'markdownToHtml_title',
|
|
||||||
descriptionKey: 'markdownToHtml_description',
|
|
||||||
themeColorKey: 'secondary',
|
|
||||||
icon: Code,
|
|
||||||
defaultVisible: true,
|
|
||||||
components: {
|
|
||||||
popup: MarkdownToHtmlPage,
|
|
||||||
sidepanel: MarkdownToHtmlPage,
|
|
||||||
tab: MarkdownToHtmlPage,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'htmlToMarkdown',
|
|
||||||
labelKey: 'htmlToMarkdown_title',
|
|
||||||
descriptionKey: 'htmlToMarkdown_description',
|
|
||||||
themeColorKey: 'secondary',
|
|
||||||
icon: File,
|
|
||||||
defaultVisible: true,
|
|
||||||
components: {
|
|
||||||
popup: HtmlToMarkdownPage,
|
|
||||||
sidepanel: HtmlToMarkdownPage,
|
|
||||||
tab: HtmlToMarkdownPage,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'rightClickRestorer',
|
key: 'rightClickRestorer',
|
||||||
labelKey: 'rightClickRestorer_title',
|
labelKey: 'rightClickRestorer_title',
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
|
||||||
import { Download, Trash2 } from 'lucide-react';
|
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
|
||||||
import CopyButton from '@/components/CopyButton';
|
|
||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { useStorageState } from '@/utils/useStorageState';
|
|
||||||
import type { HtmlToMarkdownPreviewMode } from '@/types/storage';
|
|
||||||
import { downloadMarkdownFile, htmlToMarkdown, SAMPLE_HTML } from '@/utils/htmlToMarkdown';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const isValidPreviewMode = (val: unknown): val is HtmlToMarkdownPreviewMode =>
|
|
||||||
typeof val === 'string' && ['split', 'preview', 'markdown'].includes(val);
|
|
||||||
|
|
||||||
export default function HtmlToMarkdownPage() {
|
|
||||||
const { t } = useI18n('htmlToMarkdown');
|
|
||||||
const [previewMode, setPreviewMode] = useStorageState(
|
|
||||||
'htmlToMarkdown/previewMode',
|
|
||||||
'split' as HtmlToMarkdownPreviewMode,
|
|
||||||
isValidPreviewMode,
|
|
||||||
);
|
|
||||||
const [html, setHtml] = useState(SAMPLE_HTML);
|
|
||||||
|
|
||||||
const result = useMemo(() => htmlToMarkdown(html), [html]);
|
|
||||||
const error = result.hasError ? (result.error ?? null) : null;
|
|
||||||
|
|
||||||
const handleModeChange = useCallback(
|
|
||||||
(newMode: HtmlToMarkdownPreviewMode) => {
|
|
||||||
setPreviewMode(newMode);
|
|
||||||
},
|
|
||||||
[setPreviewMode],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
|
||||||
setHtml('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
|
||||||
if (result.markdown) {
|
|
||||||
downloadMarkdownFile(result.markdown, 'converted.md');
|
|
||||||
}
|
|
||||||
}, [result.markdown]);
|
|
||||||
|
|
||||||
const showInput = previewMode !== 'preview';
|
|
||||||
const showOutput = previewMode !== 'markdown';
|
|
||||||
|
|
||||||
return (
|
|
||||||
/* 💡 统一间距尺寸:
|
|
||||||
- 彻底清除多余的 container max-w-7xl 这种网页大边距,
|
|
||||||
- 统一收拢为我们先前在 Dashboard 页、JSON 工具箱制定的 p-4 space-y-4 标准极客桌面规格。
|
|
||||||
*/
|
|
||||||
<div className="p-4 w-full flex flex-col space-y-4 select-none">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{/* 工具栏集成区 */}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-between items-stretch sm:items-center bg-secondary/40 rounded-xl border border-border/60 px-1.5 py-1.5 sm:h-12">
|
|
||||||
<SwitchButtonGroup
|
|
||||||
value={previewMode}
|
|
||||||
options={[
|
|
||||||
{ value: 'split', label: t('splitMode') },
|
|
||||||
{ value: 'preview', label: t('previewMode') },
|
|
||||||
{ value: 'markdown', label: t('markdownMode') },
|
|
||||||
]}
|
|
||||||
onChange={handleModeChange}
|
|
||||||
size="small"
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex gap-2 shrink-0">
|
|
||||||
{/* 2. 重塑下载按钮:接入受控 Button,追加 active 物理微缩放动效 */}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleDownload}
|
|
||||||
disabled={!result.markdown}
|
|
||||||
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm"
|
|
||||||
>
|
|
||||||
<Download className="h-3.5 w-3.5" />
|
|
||||||
{t('download')}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 重塑清空按钮 */}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleClear}
|
|
||||||
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm text-destructive hover:text-destructive hover:bg-destructive/5 dark:hover:bg-destructive/10 border-input/60"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
{t('clear')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 错误提示:
|
|
||||||
- 💡 核心修复点:将硬编码的 bg-red-50 实色,完美超进化为系统的全自适应透明色变体
|
|
||||||
*/}
|
|
||||||
{error && (
|
|
||||||
<div className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-destructive text-xs font-semibold tracking-wide">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 双翼/单栏联动面板展示区 */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'grid gap-4 min-h-[460px] w-full',
|
|
||||||
showInput && showOutput ? 'grid-cols-1 md:grid-cols-2' : 'grid-cols-1',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* HTML 输入端卡片面板 */}
|
|
||||||
{showInput && (
|
|
||||||
/* 3. 智能聚焦框联动(Focus Ring Clamping):
|
|
||||||
- 外层容器追加 focus-within 变量追踪大闸。
|
|
||||||
- 只要用户用鼠标点击了内部的 textarea,外层整块精巧的圆角大边框会一帧内亮起 primary 系统的深色呼吸发光环,
|
|
||||||
- 这种“全外包裹层框聚焦”的体验极大模仿了本地原生 IDE 的硬核专业体验!
|
|
||||||
*/
|
|
||||||
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm overflow-hidden flex flex-col focus-within:ring-1 focus-within:ring-ring focus-within:border-ring">
|
|
||||||
{/* 卡片头部:改用标准的灰色 bg-muted/50 */}
|
|
||||||
<div className="flex h-9 items-center justify-between px-4 bg-muted/50 border-b border-border select-none">
|
|
||||||
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
|
|
||||||
{t('inputLabel')}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] font-mono text-muted-foreground/60 tabular-nums">
|
|
||||||
{t('charCount', { count: html.length })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={html}
|
|
||||||
onChange={(e) => setHtml(e.target.value)}
|
|
||||||
placeholder={t('inputPlaceholder')}
|
|
||||||
className="flex-1 min-h-[380px] p-4 bg-transparent font-mono text-xs leading-relaxed resize-none focus:outline-none text-foreground/90 select-text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Markdown 输出端卡片面板 */}
|
|
||||||
{showOutput && (
|
|
||||||
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm overflow-hidden flex flex-col">
|
|
||||||
<div className="flex h-9 items-center justify-between px-4 bg-muted/50 border-b border-border select-none">
|
|
||||||
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
|
|
||||||
{(previewMode as string) === 'markdown'
|
|
||||||
? t('markdownOutputLabel')
|
|
||||||
: t('previewLabel')}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[10px] font-mono text-muted-foreground/60 tabular-nums">
|
|
||||||
{t('charCount', { count: result.markdownLength })}
|
|
||||||
</span>
|
|
||||||
<CopyButton
|
|
||||||
text={result.markdown}
|
|
||||||
className="h-6 w-6 rounded-md border text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(previewMode as string) === 'markdown' ? (
|
|
||||||
<textarea
|
|
||||||
value={result.markdown}
|
|
||||||
readOnly
|
|
||||||
className="flex-1 min-h-[380px] p-4 font-mono text-xs leading-relaxed resize-none focus:outline-none bg-muted/30 dark:bg-muted/10 text-foreground/80 select-text"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 p-4 min-h-[380px] overflow-auto bg-transparent font-mono text-xs leading-relaxed whitespace-pre-wrap break-all text-foreground/90 select-text">
|
|
||||||
{result.markdown || (
|
|
||||||
<span className="text-muted-foreground/70 italic text-[11px] font-sans">
|
|
||||||
{t('emptyHint')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { Download, Printer, Trash2 } from 'lucide-react';
|
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
|
||||||
import CopyButton from '@/components/CopyButton';
|
|
||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { useStorageState } from '@/utils/useStorageState';
|
|
||||||
import type { MarkdownToHtmlPreviewMode } from '@/types/storage';
|
|
||||||
import {
|
|
||||||
downloadHtmlFile,
|
|
||||||
markdownToHtml,
|
|
||||||
printHtml,
|
|
||||||
SAMPLE_MARKDOWN,
|
|
||||||
wrapHtmlDocument,
|
|
||||||
} from '@/utils/markdownToHtml';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const isValidPreviewMode = (val: unknown): val is MarkdownToHtmlPreviewMode =>
|
|
||||||
typeof val === 'string' && ['split', 'preview', 'html'].includes(val);
|
|
||||||
|
|
||||||
const PREVIEW_STYLES = `
|
|
||||||
.markdown-body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--md-foreground);
|
|
||||||
background-color: transparent;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
|
|
||||||
margin-top: 24px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.25;
|
|
||||||
color: var(--md-foreground);
|
|
||||||
}
|
|
||||||
.markdown-body h1 { border-bottom: 1px solid var(--md-border); padding-bottom: 0.3em; font-size: 1.6em; }
|
|
||||||
.markdown-body h2 { border-bottom: 1px solid var(--md-border); padding-bottom: 0.3em; font-size: 1.35em; }
|
|
||||||
.markdown-body p { margin-top: 0; margin-bottom: 16px; }
|
|
||||||
.markdown-body a { color: var(--md-link-color); text-decoration: none; }
|
|
||||||
.markdown-body a:hover { text-decoration: underline; }
|
|
||||||
.markdown-body code {
|
|
||||||
background-color: var(--md-code-bg);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 85%;
|
|
||||||
padding: 0.2em 0.4em;
|
|
||||||
font-family: Menlo, Consolas, monospace;
|
|
||||||
}
|
|
||||||
.markdown-body pre {
|
|
||||||
background-color: var(--md-pre-bg);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 85%;
|
|
||||||
line-height: 1.45;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 16px;
|
|
||||||
margin: 0 0 16px;
|
|
||||||
border: 1px solid var(--md-border);
|
|
||||||
}
|
|
||||||
.markdown-body pre code {
|
|
||||||
background-color: transparent;
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.markdown-body blockquote {
|
|
||||||
border-left: 0.25em solid var(--md-quote-line);
|
|
||||||
color: var(--md-muted);
|
|
||||||
margin: 0 0 16px;
|
|
||||||
padding: 0 1em;
|
|
||||||
}
|
|
||||||
.markdown-body table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.markdown-body table th, .markdown-body table td {
|
|
||||||
border: 1px solid var(--md-border);
|
|
||||||
padding: 6px 13px;
|
|
||||||
}
|
|
||||||
.markdown-body table tr:nth-child(2n) { background-color: var(--md-code-bg); }
|
|
||||||
.markdown-body table th { font-weight: 600; background-color: var(--md-code-bg); }
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default function MarkdownToHtmlPage() {
|
|
||||||
const { t } = useI18n('markdownToHtml');
|
|
||||||
const [previewMode, setPreviewMode] = useStorageState(
|
|
||||||
'markdownToHtml/previewMode',
|
|
||||||
'split' as MarkdownToHtmlPreviewMode,
|
|
||||||
isValidPreviewMode,
|
|
||||||
);
|
|
||||||
const [markdown, setMarkdown] = useState(SAMPLE_MARKDOWN);
|
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
||||||
|
|
||||||
const result = useMemo(() => markdownToHtml(markdown), [markdown]);
|
|
||||||
const error = result.hasError ? (result.error ?? null) : null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const iframe = iframeRef.current;
|
|
||||||
if (!iframe) return;
|
|
||||||
|
|
||||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
|
||||||
|
|
||||||
const themeVariables = isDarkMode
|
|
||||||
? `:root {
|
|
||||||
--md-bg: #090d16;
|
|
||||||
--md-foreground: #e6edf3;
|
|
||||||
--md-border: rgba(255,255,255,0.15);
|
|
||||||
--md-code-bg: rgba(255,255,255,0.12);
|
|
||||||
--md-pre-bg: rgba(255,255,255,0.04);
|
|
||||||
--md-muted: #8b949e;
|
|
||||||
--md-quote-line: rgba(255,255,255,0.25);
|
|
||||||
--md-link-color: #58a6ff;
|
|
||||||
}`
|
|
||||||
: `:root {
|
|
||||||
--md-bg: #ffffff;
|
|
||||||
--md-foreground: #1f2328;
|
|
||||||
--md-border: rgba(128,128,128,0.2);
|
|
||||||
--md-code-bg: rgba(128,128,128,0.08);
|
|
||||||
--md-pre-bg: rgba(128,128,128,0.03);
|
|
||||||
--md-muted: #4b5563;
|
|
||||||
--md-quote-line: rgba(128,128,128,0.3);
|
|
||||||
--md-link-color: #3b82f6;
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const baseGlobalStyles = `
|
|
||||||
html, body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: var(--md-bg);
|
|
||||||
color: var(--md-foreground);
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
padding: 16px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
iframe.srcdoc = `<!DOCTYPE html>
|
|
||||||
<html lang="zh" style="background-color: ${isDarkMode ? '#090d16' : '#ffffff'};">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<style>
|
|
||||||
${themeVariables}
|
|
||||||
${PREVIEW_STYLES}
|
|
||||||
${baseGlobalStyles}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="markdown-body">${result.html}</body>
|
|
||||||
</html>`;
|
|
||||||
}, [result.html, previewMode]);
|
|
||||||
|
|
||||||
const handleModeChange = useCallback(
|
|
||||||
(newMode: MarkdownToHtmlPreviewMode) => {
|
|
||||||
setPreviewMode(newMode);
|
|
||||||
},
|
|
||||||
[setPreviewMode],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
|
||||||
setMarkdown('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePrint = useCallback(() => {
|
|
||||||
printHtml(result.html, t('pageTitle'));
|
|
||||||
}, [result.html, t]);
|
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
|
||||||
const doc = wrapHtmlDocument(result.html, t('pageTitle'));
|
|
||||||
downloadHtmlFile(doc, 'markdown-export.html');
|
|
||||||
}, [result.html, t]);
|
|
||||||
|
|
||||||
const showInput = previewMode !== 'preview';
|
|
||||||
const showPreview = previewMode !== 'html';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4 w-full flex flex-col space-y-4 select-none">
|
|
||||||
<div className="flex flex-col space-y-4">
|
|
||||||
{/* 工具集成控制中枢 */}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-between items-stretch sm:items-center bg-secondary/40 rounded-xl border border-border/60 px-1.5 py-1.5 sm:h-12">
|
|
||||||
<SwitchButtonGroup
|
|
||||||
value={previewMode}
|
|
||||||
options={[
|
|
||||||
{ value: 'split', label: t('splitMode') },
|
|
||||||
{ value: 'preview', label: t('previewMode') },
|
|
||||||
{ value: 'html', label: t('htmlMode') },
|
|
||||||
]}
|
|
||||||
onChange={handleModeChange}
|
|
||||||
size="small"
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex gap-2 shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleClear}
|
|
||||||
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm text-destructive hover:text-destructive hover:bg-destructive/5 dark:hover:bg-destructive/10 border-input/60"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
{t('clear')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handlePrint}
|
|
||||||
disabled={!result.html}
|
|
||||||
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm"
|
|
||||||
>
|
|
||||||
<Printer className="h-3.5 w-3.5" />
|
|
||||||
{t('print')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleDownload}
|
|
||||||
disabled={!result.html}
|
|
||||||
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm"
|
|
||||||
>
|
|
||||||
<Download className="h-3.5 w-3.5" />
|
|
||||||
{t('download')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 错误拦截提示框 */}
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-destructive text-xs font-semibold tracking-wide"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 主框架多栏联动排版轴 */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'grid gap-4 min-h-[480px] w-full',
|
|
||||||
showInput && showPreview ? 'grid-cols-1 md:grid-cols-2' : 'grid-cols-1',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Markdown 输入翼终端 */}
|
|
||||||
{showInput && (
|
|
||||||
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm overflow-hidden flex flex-col focus-within:ring-1 focus-within:ring-ring focus-within:border-ring">
|
|
||||||
<div className="flex h-9 items-center justify-between px-4 bg-muted/50 border-b border-border select-none">
|
|
||||||
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
|
|
||||||
{t('inputLabel')}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] font-mono text-muted-foreground/60 tabular-nums">
|
|
||||||
{t('charCount', { count: markdown.length })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={markdown}
|
|
||||||
onChange={(e) => setMarkdown(e.target.value)}
|
|
||||||
placeholder={t('inputPlaceholder')}
|
|
||||||
className="flex-1 min-h-[390px] p-4 bg-transparent font-mono text-xs leading-relaxed resize-none focus:outline-none text-foreground/90 select-text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 实时 HTML/Iframe 预览翼终端 */}
|
|
||||||
{showPreview && (
|
|
||||||
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm overflow-hidden flex flex-col">
|
|
||||||
<div className="flex h-9 items-center justify-between px-4 bg-muted/50 border-b border-border select-none">
|
|
||||||
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
|
|
||||||
{(previewMode as string) === 'html' ? t('htmlOutputLabel') : t('previewLabel')}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[10px] font-mono text-muted-foreground/60 tabular-nums">
|
|
||||||
{t('charCount', { count: result.htmlLength })}
|
|
||||||
</span>
|
|
||||||
<CopyButton
|
|
||||||
text={result.html}
|
|
||||||
className="h-6 w-6 rounded-md border text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(previewMode as string) === 'html' ? (
|
|
||||||
<textarea
|
|
||||||
value={result.html}
|
|
||||||
readOnly
|
|
||||||
className="flex-1 min-h-[390px] p-4 font-mono text-xs leading-relaxed resize-none focus:outline-none bg-muted/30 dark:bg-muted/10 text-foreground/80 select-text"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 min-h-[390px] overflow-hidden bg-transparent">
|
|
||||||
<iframe
|
|
||||||
ref={iframeRef}
|
|
||||||
title="markdown-preview"
|
|
||||||
className="w-full h-full min-h-[360px] border-none bg-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Vendored
-16
@@ -10,8 +10,6 @@ export type PageType =
|
|||||||
| 'jwt' // JWT 解析工具
|
| 'jwt' // JWT 解析工具
|
||||||
| 'jsonDiff' // JSON 差异比较工具
|
| 'jsonDiff' // JSON 差异比较工具
|
||||||
| 'base64Converter' // Base64 转换器工具
|
| 'base64Converter' // Base64 转换器工具
|
||||||
| 'markdownToHtml' // Markdown 转 HTML 工具
|
|
||||||
| 'htmlToMarkdown' // HTML 转 Markdown 工具
|
|
||||||
| 'rightClickRestorer'; // 右键菜单恢复工具
|
| 'rightClickRestorer'; // 右键菜单恢复工具
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,16 +27,6 @@ export type Base64ConverterPageMode = 'text' | 'file' | 'image';
|
|||||||
*/
|
*/
|
||||||
export type Base64ConvertDirection = 'encode' | 'decode';
|
export type Base64ConvertDirection = 'encode' | 'decode';
|
||||||
|
|
||||||
/**
|
|
||||||
* Markdown 转 HTML 页面预览模式类型定义
|
|
||||||
*/
|
|
||||||
export type MarkdownToHtmlPreviewMode = 'split' | 'preview' | 'html';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTML 转 Markdown 页面预览模式类型定义
|
|
||||||
*/
|
|
||||||
export type HtmlToMarkdownPreviewMode = 'split' | 'preview' | 'markdown';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 表单映射条目定义
|
* 表单映射条目定义
|
||||||
*/
|
*/
|
||||||
@@ -121,10 +109,6 @@ export interface StorageSchema {
|
|||||||
'base64Converter/fileMode/direction': Base64ConvertDirection;
|
'base64Converter/fileMode/direction': Base64ConvertDirection;
|
||||||
/** Base64 转换器「图像」子模式当前方向 */
|
/** Base64 转换器「图像」子模式当前方向 */
|
||||||
'base64Converter/imageMode/direction': Base64ConvertDirection;
|
'base64Converter/imageMode/direction': Base64ConvertDirection;
|
||||||
/** Markdown 转 HTML 页面当前预览模式 */
|
|
||||||
'markdownToHtml/previewMode': MarkdownToHtmlPreviewMode;
|
|
||||||
/** HTML 转 Markdown 页面当前预览模式 */
|
|
||||||
'htmlToMarkdown/previewMode': HtmlToMarkdownPreviewMode;
|
|
||||||
/** 语言偏好设置 */
|
/** 语言偏好设置 */
|
||||||
'app/language': string;
|
'app/language': string;
|
||||||
/** 右键菜单待处理数据 */
|
/** 右键菜单待处理数据 */
|
||||||
|
|||||||
@@ -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 内容吧!*`;
|
|
||||||
@@ -30,10 +30,6 @@ function manualChunksForHtmlOnly(): Plugin {
|
|||||||
if (id.includes('@dnd-kit')) {
|
if (id.includes('@dnd-kit')) {
|
||||||
return 'vendor-dnd';
|
return 'vendor-dnd';
|
||||||
}
|
}
|
||||||
// Markdown 高性能转换引擎分流
|
|
||||||
if (id.includes('marked')) {
|
|
||||||
return 'vendor-markdown';
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user