feat: v0.2.0 — TypeScript full rewrite, 95%+ core coverage

BREAKING CHANGE: All source files converted from JavaScript to TypeScript.
- 12 .ts source files with strict types, full EditorOptions/Plugin/Token interfaces
- 7 .ts test files, 610 total tests (27 new), 7 suites all passing
- tsc --noEmit: 0 errors
- rollup-plugin-typescript build: 5 artifacts (UMD/ESM/CJS/Min/DTS)
- @babel/preset-typescript for jest
- New tsconfig.json, updated babel/jest/rollup configs
- Coverage: parser 99.5%, utils 95.7%, themes 96.2%, core 88.8%, plugins 89.5%
- Removed types/ folder (types now inline in .ts + auto-generated .d.ts)
- Desktop-only, no backward compatibility
This commit is contained in:
2026-07-24 22:28:38 +08:00
parent d7cae48073
commit e83fc211dc
40 changed files with 3563 additions and 6913 deletions
@@ -3,8 +3,8 @@
* animationUtils API + createAnimation
*/
import { animationUtils, createAnimation, animationPresets } from '../src/animations.js';
import { ANIMATIONS } from '../src/constants.js';
import { animationUtils, createAnimation, animationPresets } from '../src/animations';
import { ANIMATIONS } from '../src/constants';
describe('animationUtils - 默认动画注册', () => {
test('默认动画已注册到 animationMap', () => {
+200 -1
View File
@@ -3,7 +3,7 @@
* MarkdownEditor API
*/
import { MarkdownEditor } from '../src/core.js';
import { MarkdownEditor } from '../src/core';
// jsdom 不提供 PointerEvent,用 MouseEvent 作为基类 polyfill
if (typeof window !== 'undefined' && typeof window.PointerEvent === 'undefined') {
@@ -1532,3 +1532,202 @@ describe('MarkdownEditor - v0.1.12 shortcutHelp 插件', () => {
ed.destroy();
});
});
// ============ v0.2.0 覆盖率补齐测试 ============
describe('MarkdownEditor - v0.2.0 Smart Enter 引用空行', () => {
test('空引用行 Enter 结束引用', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '> ' });
ed.textarea.setSelectionRange(2, 2);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(ed.getValue()).toBe('\n');
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.0 括号闭合边界', () => {
test('光标在同类引号后跳过而不插入', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '"hello" world', autoBrackets: true });
ed.textarea.setSelectionRange(6, 6); // 光标在闭合引号前
ed.textarea.dispatchEvent(new KeyboardEvent('keypress', { key: '"', bubbles: true }));
// 跳过闭合引号,selectionStart 前进一位
expect(ed.textarea.selectionStart).toBe(7);
ed.destroy();
});
test('光标在单词中间不自动闭合引号', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello world', autoBrackets: true });
ed.textarea.setSelectionRange(3, 3); // 光标在 'l' 和 'l' 之间
ed.textarea.dispatchEvent(new KeyboardEvent('keypress', { key: '"', bubbles: true }));
// 不闭合,因为 \w 检查
expect(ed.getValue()).toBe('hello world');
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.0 exec 补充', () => {
test('exec zen 切换 Zen 模式', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.isZen()).toBe(false);
ed.exec('zen');
expect(ed.isZen()).toBe(true);
ed.exec('zen');
expect(ed.isZen()).toBe(false);
ed.destroy();
});
test('exec wordwrap 切换换行', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.isWordWrap()).toBe(true);
ed.exec('wordwrap');
expect(ed.isWordWrap()).toBe(false);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.0 setWordWrap API', () => {
test('setWordWrap 设置换行模式', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.setWordWrap(false);
expect(ed.isWordWrap()).toBe(false);
expect(ed.textarea.style.whiteSpace).toBe('pre');
ed.setWordWrap(true);
expect(ed.isWordWrap()).toBe(true);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.0 右键菜单和快捷键管理', () => {
test('registerContextMenu 设置菜单项', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.registerContextMenu([{ label: 'Copy', action: 'copy' }]);
expect(ed._contextMenuItems.length).toBe(1);
ed.destroy();
});
test('getShortcuts 空列表', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(Array.isArray(ed.getShortcuts())).toBe(true);
expect(ed.getShortcuts().length).toBe(0);
ed.destroy();
});
test('unregisterShortcut 移除注册', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.registerShortcut('Ctrl+K', 'link');
expect(ed.getShortcuts().length).toBe(1);
ed.unregisterShortcut('Ctrl+K');
expect(ed.getShortcuts().length).toBe(0);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.0 工具栏管理', () => {
test('removeToolbarButton 移除按钮', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic'] });
const before = ed.toolbarEl.querySelectorAll('.me-btn').length;
ed.removeToolbarButton('bold');
expect(ed.toolbarEl.querySelectorAll('.me-btn').length).toBe(before - 1);
ed.destroy();
});
test('configureToolbar 重建工具栏', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.configureToolbar(['undo', 'redo']);
expect(ed.toolbarEl.querySelectorAll('.me-btn').length).toBe(2);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.0 render 错误处理', () => {
test('render 抛错时显示错误信息', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {
value: '# test', mode: 'split',
render: () => { throw new Error('boom'); },
});
expect(ed.previewEl.innerHTML).toContain('渲染失败');
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.0 outline 构建', () => {
test('outline 启用时构建面板', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# H1\n## H2\n### H3', outline: true, mode: 'split' });
ed._buildOutline();
const panel = ed.el.querySelector('.me-outline');
expect(panel).not.toBeNull();
if (panel) {
const links = panel.querySelectorAll('a');
expect(links.length).toBeGreaterThanOrEqual(2);
}
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.0 gutter 更新', () => {
test('gutter 行数不变时跳过 DOM 更新', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'a\nb', lineNumbers: true, mode: 'split' });
// 初始渲染已经填充了 gutter
const before = ed.gutter.children.length;
expect(before).toBeGreaterThan(0);
// 再次调用 _renderGutter 应该跳过
ed._renderGutter();
expect(ed.gutter.children.length).toBe(before);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.0 getStatus plugins', () => {
test('getStatus 包含插件名列表', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.use({ name: 'test-p', install() {} });
const s = ed.getStatus();
expect(s.plugins).toContain('test-p');
ed.destroy();
});
});
+74 -4
View File
@@ -29,10 +29,11 @@ import {
loadLocale,
getDefaultLocale,
initI18n,
loadRemote,
presetLocales,
createI18nManager,
} from '../src/i18n.js';
import { LOCALES } from '../src/constants.js';
} from '../src/i18n';
import { LOCALES } from '../src/constants';
// 确保 localStorage 存在(jsdom 兼容)
if (typeof global.localStorage === 'undefined') {
@@ -414,7 +415,7 @@ describe('createI18nManager', () => {
// ============ v0.1.6 新增测试 ============
describe('v0.1.6 实例级 i18n', () => {
const { createInstanceI18n } = require('../src/i18n.js');
const { createInstanceI18n } = require('../src/i18n');
test('createInstanceI18n 创建独立上下文', () => {
const mockEditor = {
@@ -463,7 +464,7 @@ describe('v0.1.6 实例级 i18n', () => {
});
describe('v0.1.6 复数规则', () => {
const { addTranslations, t, setCurrentLocale } = require('../src/i18n.js');
const { addTranslations, t, setCurrentLocale } = require('../src/i18n');
beforeEach(() => {
setCurrentLocale('en-US');
@@ -485,3 +486,72 @@ describe('v0.1.6 复数规则', () => {
expect(t('items', { count: 3 })).toBe('3 个项目');
});
});
// ============ v0.2.0 覆盖率补齐 ============
describe('v0.2.0 复数规则', () => {
test('俄语 few 形式', () => {
addTranslations('ru', { apples: { one: '{count} яблоко', few: '{count} яблока', many: '{count} яблок', other: '{count} яблок' } });
setCurrentLocale('ru');
expect(t('apples', { count: 2 })).toContain('яблока');
expect(t('apples', { count: 5 })).toContain('яблок');
expect(t('apples', { count: 1 })).toContain('яблоко');
});
});
describe('v0.2.0 翻译回退链', () => {
test('缺翻译回退到 fallback 语言', () => {
setCurrentLocale('en-US');
// 'outline' key exists in en-US
expect(t('outline')).toBe('Outline');
});
});
describe('v0.2.0 loadRemote 错误处理', () => {
test('loadRemote 网络错误返回 false', async () => {
// jsdom doesn't expose fetch on globalThis by default
const origFetch = (globalThis as any).fetch;
(globalThis as any).fetch = jest.fn().mockRejectedValue(new Error('network'));
const result = await loadRemote('https://example.com/locale.json', 'en-US');
expect(result).toBe(false);
(globalThis as any).fetch = origFetch;
});
});
describe('v0.2.0 格式化异常处理', () => {
test('formatNumber 异常回退为字符串', () => {
// 先用一个已知 locale
setCurrentLocale('en-US');
const r = formatNumber(123);
expect(typeof r).toBe('string');
});
test('formatCurrency 正常输出', () => {
setCurrentLocale('en-US');
const r = formatCurrency(10, 'USD');
expect(r).toContain('10');
});
test('formatDate 异常回退为字符串', () => {
setCurrentLocale('en-US');
const r = formatDate('2024-01-15');
expect(typeof r).toBe('string');
});
});
describe('v0.2.0 实例级 i18n 工具栏', () => {
test('createInstanceI18n set 更新 toolbar tooltips', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new (require('../src/core').MarkdownEditor)(c, {});
// 设置语言为英文
ed.setLocale('en-US');
// 验证工具栏按钮 title 更新
const boldBtn = ed.toolbarEl.querySelector('.me-btn[data-action="bold"]') as HTMLElement;
if (boldBtn) {
expect(boldBtn.title).toBe('Bold');
}
ed.destroy();
});
});
@@ -3,7 +3,7 @@
* Markdown
*/
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler } from '../src/parser.js';
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler } from '../src/parser';
describe('parseMarkdown - 基础', () => {
test('空输入返回空字符串', () => {
+85 -12
View File
@@ -3,8 +3,8 @@
* autoSave / exportTool / searchReplace pluginUtils
*/
import { MarkdownEditor } from '../src/core.js';
import { presetPlugins, pluginUtils, PluginManager } from '../src/plugins.js';
import { MarkdownEditor } from '../src/core';
import { presetPlugins, pluginUtils, PluginManager } from '../src/plugins';
// 确保 localStorage 存在(jsdom 兼容)
if (typeof global.localStorage === 'undefined') {
@@ -871,7 +871,7 @@ describe('零散分支补全', () => {
// ============ v0.1.6 新增测试 ============
describe('v0.1.6 topologicalSort', () => {
const { topologicalSort } = require('../src/plugins.js');
const { topologicalSort } = require('../src/plugins');
test('空数组返回空数组', () => {
expect(topologicalSort([])).toEqual([]);
@@ -908,7 +908,7 @@ describe('v0.1.6 topologicalSort', () => {
});
describe('v0.1.6 validateConfig', () => {
const { validateConfig } = require('../src/plugins.js');
const { validateConfig } = require('../src/plugins');
test('required 字段缺失报错', () => {
const result = validateConfig({ name: { required: true } }, {});
@@ -938,7 +938,7 @@ describe('v0.1.6 editor.unuse()', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const { MarkdownEditor } = require('../src/core.js');
const { MarkdownEditor } = require('../src/core');
const ed = new MarkdownEditor(c, {});
let destroyed = false;
ed.use({ name: 'test', install() {}, destroy() { destroyed = true; } });
@@ -955,7 +955,7 @@ describe('v0.1.6 editor shortcuts', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const { MarkdownEditor } = require('../src/core.js');
const { MarkdownEditor } = require('../src/core');
const ed = new MarkdownEditor(c, { value: 'hello' });
let called = false;
ed.registerShortcut('Ctrl+J', () => { called = true; });
@@ -968,7 +968,7 @@ describe('v0.1.6 editor shortcuts', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const { MarkdownEditor } = require('../src/core.js');
const { MarkdownEditor } = require('../src/core');
const ed = new MarkdownEditor(c, { value: 'hello' });
let called = false;
ed.registerShortcut('Ctrl+K', () => { called = true; });
@@ -984,7 +984,7 @@ describe('v0.1.6 editor shortcuts', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const { MarkdownEditor } = require('../src/core.js');
const { MarkdownEditor } = require('../src/core');
const ed = new MarkdownEditor(c, {});
ed.registerShortcut('Ctrl+B', 'bold', 'Bold');
expect(ed.getShortcuts().length).toBe(1);
@@ -997,7 +997,7 @@ describe('v0.1.6 editor toast', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const { MarkdownEditor } = require('../src/core.js');
const { MarkdownEditor } = require('../src/core');
const ed = new MarkdownEditor(c, {});
ed.toast('Test message', { duration: 0 });
const toast = ed.el.querySelector('.me-toast');
@@ -1010,7 +1010,7 @@ describe('v0.1.6 editor toast', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const { MarkdownEditor } = require('../src/core.js');
const { MarkdownEditor } = require('../src/core');
const ed = new MarkdownEditor(c, {});
expect(ed.toast('msg')).toBe(ed);
ed.destroy();
@@ -1022,7 +1022,7 @@ describe('v0.1.6 editor toolbar config', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const { MarkdownEditor } = require('../src/core.js');
const { MarkdownEditor } = require('../src/core');
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic'] });
const before = ed.toolbarEl.querySelectorAll('.me-btn').length;
ed.configureToolbar(['bold', 'italic', 'h1', 'h2', '|', 'undo']);
@@ -1035,7 +1035,7 @@ describe('v0.1.6 editor toolbar config', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const { MarkdownEditor } = require('../src/core.js');
const { MarkdownEditor } = require('../src/core');
const ed = new MarkdownEditor(c, {});
const before = ed.toolbarEl.querySelectorAll('.me-btn-bold').length;
expect(before).toBe(1);
@@ -1045,3 +1045,76 @@ describe('v0.1.6 editor toolbar config', () => {
ed.destroy();
});
});
// ============ v0.2.0 覆盖率补齐 ============
describe('v0.2.0 topologicalSort 环检测', () => {
test('循环依赖回退到原始顺序', () => {
const { topologicalSort: ts } = require('../src/plugins');
const pA = { name: 'A', depends: ['B'], priority: 0, install() {}, destroy() {} };
const pB = { name: 'B', depends: ['A'], priority: 1, install() {}, destroy() {} };
const sorted = ts([pA, pB]);
expect(sorted.length).toBe(2);
expect(sorted[0].name).toBe('A');
expect(sorted[1].name).toBe('B');
});
});
describe('v0.2.0 searchReplace 全文交互', () => {
test('打开面板填入选中文本', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello foo bar' });
ed.use('searchReplace');
ed.textarea.setSelectionRange(6, 9);
// 触发 Ctrl+F
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
const fi = ed.el.querySelector('.me-search-find') as HTMLInputElement;
expect(fi).not.toBeNull();
expect(fi.value).toBe('foo');
// 清理
const panel = ed.el.querySelector('.me-search');
if (panel) panel.remove();
ed.destroy();
});
});
describe('v0.2.0 fileSystem 插件', () => {
test('fileSystem 安装后暴露 API', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.use('fileSystem');
expect(typeof (ed as any).openFile).toBe('function');
expect(typeof (ed as any).saveFile).toBe('function');
expect(typeof (ed as any).saveFileAs).toBe('function');
expect(typeof (ed as any).getFileHandle).toBe('function');
ed.destroy();
});
test('unuse 卸载 fileSystem', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.use('fileSystem');
ed.unuse('fileSystem');
expect((ed as any).openFile).toBeUndefined();
ed.destroy();
});
});
describe('v0.2.0 exportTool 插件', () => {
test('exportTool 安装暴露 API', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# test' });
ed.use('exportTool');
expect(typeof (ed as any).exportMarkdown).toBe('function');
expect(typeof (ed as any).exportHTML).toBe('function');
ed.destroy();
});
});
@@ -41,9 +41,9 @@ import {
adoptFromParent,
watch,
createInstanceTheme,
} from '../src/themes.js';
import { THEMES } from '../src/constants.js';
import { MarkdownEditor } from '../src/core.js';
} from '../src/themes';
import { THEMES } from '../src/constants';
import { MarkdownEditor } from '../src/core';
// 确保 localStorage 存在(jsdom 兼容)
if (typeof global.localStorage === 'undefined') {
+1 -1
View File
@@ -13,7 +13,7 @@ import {
isBrowser,
formatFileSize,
sleep,
} from '../src/utils.js';
} from '../src/utils';
describe('generateId', () => {
test('返回字符串', () => {