feat: v0.2.1 — reference links, context menu, RTL, ja/ko, regex search, hooks, copy API
CI / test (16.x) (push) Canceled after 0s
CI / test (18.x) (push) Canceled after 0s
CI / test (20.x) (push) Canceled after 0s

## Added
- Reference link/image resolution: [text][ref] + ![alt][ref] with [ref]: url definitions
- Right-click context menu: undo/redo/cut/copy/paste/selectAll + custom items
- RTL CSS layout support for Arabic, Hebrew, Persian etc.
- Japanese (ja) and Korean (ko) locales with 60+ keys each
- Divider position localStorage persistence
- Regex search toggle in search/replace panel
- Export HTML with embedded CSS styles
- beforeChange / afterChange lifecycle hooks (instance + global)
- copyAsMarkdown() / copyAsHTML() clipboard APIs
- CHANGELOG.md, CONTRIBUTING.md, CI workflow (.github/workflows/ci.yml)
- 2 new test suites: index.test.ts, styles.test.ts (684 total tests, +74)

## Changed
- autoSave plugin: closure-based state per instance instead of this context
- Plugin install() now receives options as second argument
- RTL locale detection: now uses language prefix (ar-SA → RTL)
- Rollup dev mode: only builds UMD format
- prepublishOnly now includes typecheck + test
- Version bumped to 0.2.1

## Fixed
- [text][ref] now correctly renders as link (was raw text)
- ![alt][ref] no longer produces empty src
- autoSave plugin state isolation across multiple editor instances
- Footnote definitions no longer consumed by refDef handler
This commit is contained in:
2026-07-25 08:53:58 +08:00
parent 16464af0ae
commit 1cd5b63174
22 changed files with 1448 additions and 116 deletions
+217 -1
View File
@@ -536,7 +536,7 @@ describe('MarkdownEditor - 构造边界', () => {
destroy: jest.fn(),
};
const ed = new MarkdownEditor(document.createElement('div'), { plugins: [plugin] });
expect(plugin.install).toHaveBeenCalledWith(ed);
expect(plugin.install).toHaveBeenCalledWith(ed, {});
ed.destroy();
expect(plugin.destroy).toHaveBeenCalledWith(ed);
});
@@ -1731,3 +1731,219 @@ describe('MarkdownEditor - v0.2.0 getStatus plugins', () => {
ed.destroy();
});
});
// ============ v0.2.1 覆盖率补齐测试 ============
describe('MarkdownEditor - v0.2.1 工具栏键盘导航', () => {
test('ArrowRight 聚焦下一个按钮', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic', 'code'] });
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
const first = btns[0] as HTMLButtonElement;
first.focus();
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
// 焦点应移动
expect(document.activeElement).not.toBe(first);
ed.destroy();
});
test('ArrowLeft 聚焦上一个按钮', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic'] });
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
(btns[1] as HTMLButtonElement).focus();
(btns[1] as HTMLButtonElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
expect(document.activeElement).toBe(btns[0]);
ed.destroy();
});
test('Home 聚焦第一个按钮', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic', 'code'] });
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
(btns[2] as HTMLButtonElement).focus();
(btns[2] as HTMLButtonElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
expect(document.activeElement).toBe(btns[0]);
ed.destroy();
});
test('End 聚焦最后一个按钮', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic', 'code'] });
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
(btns[0] as HTMLButtonElement).focus();
(btns[0] as HTMLButtonElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
expect(document.activeElement).toBe(btns[btns.length - 1]);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.1 ARIA live', () => {
test('_initAriaLive 创建 sr-only 元素', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
const live = ed.el.querySelector('.me-sr-only');
expect(live).not.toBeNull();
expect(live!.getAttribute('aria-live')).toBe('polite');
ed.destroy();
});
test('_announce 设置消息', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed._announce('test message');
const live = ed.el.querySelector('.me-sr-only') as HTMLElement;
expect(live).not.toBeNull();
// jsdom 中 requestAnimationFrame 可能不被支持,但不应抛错
expect(() => ed._announce('hello')).not.toThrow();
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.1 当前行高亮', () => {
test('_updateCurrentLine 高亮当前行', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'line1\nline2\nline3', lineNumbers: true });
ed.textarea.setSelectionRange(7, 7); // 第二行
ed._updateCurrentLine();
const active = ed.gutter.querySelector('.me-gutter-active');
expect(active).not.toBeNull();
ed.destroy();
});
test('_updateCurrentLine 无 gutter 时不抛错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'test', lineNumbers: false });
expect(() => ed._updateCurrentLine()).not.toThrow();
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.1 右键上下文菜单', () => {
test('contextmenu 事件显示菜单', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello' });
ed.el.dispatchEvent(new MouseEvent('contextmenu', { clientX: 100, clientY: 100, bubbles: true }));
const menu = document.querySelector('.me-context-menu');
expect(menu).not.toBeNull();
// 清理
if (menu) menu.remove();
ed.destroy();
});
test('上下文菜单点击外部关闭', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello' });
ed.el.dispatchEvent(new MouseEvent('contextmenu', { clientX: 100, clientY: 100, bubbles: true }));
expect(document.querySelector('.me-context-menu')).not.toBeNull();
document.body.click();
// once listener removes it
expect(document.querySelector('.me-context-menu')).toBeNull();
ed.destroy();
});
test('registerContextMenu 添加自定义菜单项', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.registerContextMenu([{ label: 'Custom Action', onClick: jest.fn() }]);
expect(ed._contextMenuItems.length).toBe(1);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.1 copy API', () => {
test('copyAsMarkdown 不抛错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# hello' });
expect(() => ed.copyAsMarkdown()).not.toThrow();
ed.destroy();
});
test('copyAsHTML 不抛错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# hello' });
expect(() => ed.copyAsHTML()).not.toThrow();
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.1 beforeChange/afterChange 钩子', () => {
test('setValue 触发 beforeChange', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'old' });
const handler = jest.fn();
ed.on('beforeChange', handler);
ed.setValue('new');
expect(handler).toHaveBeenCalledWith('old', 'new', ed);
ed.destroy();
});
test('setValue 触发 afterChange', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'old' });
const handler = jest.fn();
ed.on('afterChange', handler);
ed.setValue('new');
expect(handler).toHaveBeenCalledWith('new', ed);
ed.destroy();
});
test('全局 beforeChange/afterChange 钩子', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const beforeFn = jest.fn();
const afterFn = jest.fn();
MarkdownEditor.on('beforeChange', beforeFn);
MarkdownEditor.on('afterChange', afterFn);
const ed = new MarkdownEditor(c, { value: 'test' });
ed.setValue('updated');
expect(beforeFn).toHaveBeenCalled();
expect(afterFn).toHaveBeenCalled();
MarkdownEditor.off('beforeChange', beforeFn);
MarkdownEditor.off('afterChange', afterFn);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.1 分隔条持久化', () => {
test('_saveDividerPosition / _restoreDividerPosition 不抛错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { mode: 'split' });
expect(() => ed._saveDividerPosition()).not.toThrow();
expect(() => ed._restoreDividerPosition()).not.toThrow();
ed.destroy();
});
});
+181
View File
@@ -0,0 +1,181 @@
/**
* index.ts 单元测试
* 覆盖全局 APIcreate / use / on / off / setTheme / setLocale / destroy / getStatus
*/
import MeEditor, {
create, use, on, off, setTheme, setLocale, destroy, getStatus,
parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache,
registerBlockHandler, topologicalSort, validateConfig,
VERSION,
} from '../src/index';
import { MarkdownEditor } from '../src/core';
describe('index.ts - 全局 API', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
container.id = 'test-host';
document.body.appendChild(container);
// Set MeEditor on window as index.ts does
(window as any).MeEditor = MeEditor;
});
afterEach(() => {
document.body.innerHTML = '';
});
test('VERSION 是字符串', () => {
expect(typeof VERSION).toBe('string');
expect(VERSION).toBe('0.2.1');
});
test('api.default 是 api 本身', () => {
expect(MeEditor).toBeDefined();
expect(MeEditor.VERSION).toBe(VERSION);
});
test('create 创建编辑器实例', () => {
const editor = create('#test-host', { value: '# Hello' });
expect(editor).toBeInstanceOf(MarkdownEditor);
expect(editor.getValue()).toBe('# Hello');
editor.destroy();
});
test('create 接受 HTMLElement', () => {
const editor = create(container, { value: 'text' });
expect(editor.getValue()).toBe('text');
editor.destroy();
});
test('use 注册全局插件(通过字符串)', () => {
const result = use('autoSave', { delay: 100 });
expect(result).toBe(MeEditor); // 返回 api(链式)
const status = getStatus();
expect(status.globalPlugins).toContain('autoSave');
});
test('use 未知预设插件 warn', () => {
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
use('non-existent-preset');
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test('use 无效插件对象 warn', () => {
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
use(null);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test('全局插件被注入到新实例', () => {
// Reset global plugins by destroying
destroy();
use('autoSave');
const ed = create(container, { value: 'test' });
const plugins = ed.getPlugins();
expect(plugins.some((p: any) => p.name === 'autoSave')).toBe(true);
ed.destroy();
});
test('on / off 全局钩子', () => {
const fn = jest.fn();
const unsub = on('afterCreate', fn);
expect(typeof unsub).toBe('function');
const ed = create(container, {});
expect(fn).toHaveBeenCalled();
off('afterCreate', fn);
ed.destroy();
});
test('setTheme 切换全局主题', () => {
expect(() => setTheme('dark')).not.toThrow();
});
test('setLocale 切换全局语言', () => {
expect(() => setLocale('en-US')).not.toThrow();
});
test('getStatus 返回状态对象', () => {
const status = getStatus();
expect(status).toHaveProperty('version');
expect(status).toHaveProperty('theme');
expect(status).toHaveProperty('locale');
expect(status).toHaveProperty('globalPlugins');
expect(status).toHaveProperty('presetPlugins');
expect(Array.isArray(status.globalPlugins)).toBe(true);
expect(Array.isArray(status.presetPlugins)).toBe(true);
});
test('destroy 清理全局资源', () => {
expect(() => destroy()).not.toThrow();
const status = getStatus();
expect(status.globalPlugins.length).toBe(0);
});
test('window.MeEditor 被设置', () => {
expect((window as any).MeEditor).toBeDefined();
expect((window as any).MeEditor.VERSION).toBe(VERSION);
});
});
describe('index.ts - 解析器导出', () => {
test('parseMarkdown 可直接调用', () => {
const html = parseMarkdown('# Hello');
expect(html).toContain('<h1');
});
test('parseTokens 可直接调用', () => {
const { tokens, footnotes, refs } = parseTokens('# Hi\n\n**bold**');
expect(Array.isArray(tokens)).toBe(true);
expect(tokens.length).toBeGreaterThan(0);
expect(typeof footnotes).toBe('object');
expect(typeof refs).toBe('object');
});
test('renderTokens 可直接调用', () => {
const { tokens } = parseTokens('**hello**');
const html = renderTokens(tokens);
expect(html).toContain('<strong>');
});
test('safeUrl 过滤危险协议', () => {
expect(safeUrl('javascript:alert(1)')).toBe('');
expect(safeUrl('https://example.com')).toBe('https://example.com');
});
test('slugify 生成锚点 id', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
test('clearRenderCache 不抛错', () => {
expect(() => clearRenderCache()).not.toThrow();
});
test('registerBlockHandler 注册自定义块', () => {
registerBlockHandler({
name: 'testBlock',
priority: 50,
test: () => null,
parse: (_l, i) => ({ token: null, newIndex: i }),
});
// 不抛错即通过
expect(true).toBe(true);
});
});
describe('index.ts - 具名导出', () => {
test('create 是函数', () => expect(typeof create).toBe('function'));
test('use 是函数', () => expect(typeof use).toBe('function'));
test('on 是函数', () => expect(typeof on).toBe('function'));
test('off 是函数', () => expect(typeof off).toBe('function'));
test('setTheme 是函数', () => expect(typeof setTheme).toBe('function'));
test('setLocale 是函数', () => expect(typeof setLocale).toBe('function'));
test('destroy 是函数', () => expect(typeof destroy).toBe('function'));
test('getStatus 是函数', () => expect(typeof getStatus).toBe('function'));
test('topologicalSort 是函数', () => expect(typeof topologicalSort).toBe('function'));
test('validateConfig 是函数', () => expect(typeof validateConfig).toBe('function'));
});
+107
View File
@@ -1118,3 +1118,110 @@ describe('parseMarkdown - 分支:嵌套列表子项渲染', () => {
expect(innerUlCount).toBeGreaterThanOrEqual(2);
});
});
// ============ v0.2.1 引用链接/图片测试 ============
describe('parseMarkdown - v0.2.1 引用链接', () => {
test('引用链接 [text][ref] 解析为链接', () => {
const md = 'Click [here][1]\n\n[1]: https://example.com';
const html = parseMarkdown(md);
expect(html).toContain('<a href="https://example.com"');
expect(html).toContain('>here</a>');
});
test('引用链接忽略大小写', () => {
const md = 'Visit [Site][MyRef]\n\n[myref]: https://example.com';
const html = parseMarkdown(md);
expect(html).toContain('<a href="https://example.com"');
});
test('引用链接无定义时保留原文', () => {
const md = 'Click [here][undef]';
const html = parseMarkdown(md);
expect(html).not.toContain('<a href=');
expect(html).toContain('here');
});
test('引用链接使用隐式引用(同文本)', () => {
const md = 'Visit [example][]\n\n[example]: https://example.com';
const html = parseMarkdown(md);
expect(html).toContain('<a href="https://example.com"');
});
test('引用图片 ![alt][ref] 解析', () => {
const md = '![logo][img1]\n\n[img1]: https://example.com/logo.png';
const html = parseMarkdown(md);
expect(html).toContain('<img src="https://example.com/logo.png"');
expect(html).toContain('alt="logo"');
});
test('引用图片带 title 通过 parseMarkdown', () => {
clearRenderCache();
const md = '![logo][img1]\n\n[img1]: https://example.com/logo.png "MyLogo"';
const html = parseMarkdown(md);
expect(html).toContain('src="https://example.com/logo.png"');
expect(html).toContain('title="MyLogo"');
});
test('引用链接带 title 通过 parseMarkdown', () => {
clearRenderCache();
const md = '[link][1]\n\n[1]: https://example.com "MyTitle"';
const html = parseMarkdown(md);
expect(html).toContain('href="https://example.com"');
expect(html).toContain('title="MyTitle"');
});
test('引用链接危险协议被过滤', () => {
const md = 'Click [bad][1]\n\n[1]: javascript:alert(1)';
const html = parseMarkdown(md);
expect(html).not.toContain('href="javascript:');
});
test('引用图片无定义时使用空 src', () => {
const md = '![logo][nope]';
const html = parseMarkdown(md);
expect(html).toContain('src=""');
expect(html).toContain('me-img-ref');
});
test('引用定义不匹配脚注', () => {
const md = 'Text[^1]\n\n[^1]: footnote text\n\n[link][lnk]\n\n[lnk]: https://example.com';
const html = parseMarkdown(md);
expect(html).toContain('me-footnotes');
expect(html).toContain('footnote text');
expect(html).toContain('<a href="https://example.com"');
});
});
// ============ v0.2.1 data:image 超限测试 ============
describe('parseMarkdown - v0.2.1 safeUrl 增强', () => {
test('超长 data:image URL 被过滤', () => {
// 构造一个超过 500000 字符的 data:image URL
const longData = 'data:image/png;base64,' + 'A'.repeat(500001);
const url = safeUrl(longData);
expect(url).toBe('');
});
test('正常 data:image 通过', () => {
const url = safeUrl('data:image/png;base64,abc123');
expect(url).toBe('data:image/png;base64,abc123');
});
});
// ============ v0.2.1 引用链接格式错误容错 ============
describe('parseMarkdown - v0.2.1 引用链接容错', () => {
test('格式错误的引用图片不崩溃', () => {
const html = parseMarkdown('![broken ref');
expect(html).toContain('broken ref');
});
test('引用定义中 JSON 解析错误被容错', () => {
// 正常情况下 refs 中的值是 JSON 字符串
const { refs } = parseTokens('[link][test]\n\n[test]: https://example.com');
expect(refs['test']).toBeDefined();
// 不应抛错
expect(() => renderTokens([{ type: 'paragraph', text: '[x][bad]' }], { refs: { bad: 'not-json' } })).not.toThrow();
});
});
+3 -2
View File
@@ -792,8 +792,9 @@ describe('零散分支补全', () => {
ed.use('autoSave');
// 让 editor.getValue 抛错,触发 save 的 catch 分支
jest.spyOn(ed, 'getValue').mockImplementation(() => { throw new Error('get failed'); });
const plugin = ed.getPlugins().find((p) => p.name === 'autoSave');
expect(() => plugin._save()).not.toThrow();
const cleanup = (ed as any).__autoSaveCleanup;
expect(cleanup).toBeDefined();
expect(() => cleanup.save()).not.toThrow();
expect(spy).toHaveBeenCalled();
ed.destroy();
spy.mockRestore();
+109
View File
@@ -0,0 +1,109 @@
/**
* styles.ts 单元测试
* 覆盖 injectStyles / updateStyles / removeStyles
*/
import { injectStyles, updateStyles, removeStyles, getSystemTheme, watchSystemTheme } from '../src/styles';
describe('styles.ts - injectStyles', () => {
afterEach(() => {
removeStyles();
});
test('injectStyles 创建 <style> 元素', () => {
injectStyles();
const el = document.getElementById('metona-editor-styles');
expect(el).not.toBeNull();
expect(el!.tagName).toBe('STYLE');
});
test('injectStyles 重复调用不创建重复元素', () => {
injectStyles();
injectStyles();
const els = document.querySelectorAll('#metona-editor-styles');
expect(els.length).toBe(1);
});
test('注入的 CSS 包含编辑器样式规则', () => {
injectStyles();
const el = document.getElementById('metona-editor-styles');
const css = el!.textContent || '';
expect(css).toContain('.me-wrapper');
expect(css).toContain('.me-toolbar');
expect(css).toContain('.me-textarea');
expect(css).toContain('.me-preview');
expect(css).toContain('--md-bg');
expect(css).toContain('--md-accent');
});
test('注入的 CSS 包含 RTL 规则', () => {
injectStyles();
const el = document.getElementById('metona-editor-styles');
const css = el!.textContent || '';
expect(css).toContain('[dir=rtl]');
});
test('注入的 CSS 包含打印样式', () => {
injectStyles();
const el = document.getElementById('metona-editor-styles');
const css = el!.textContent || '';
expect(css).toContain('@media print');
});
test('注入的 CSS 包含 reduced-motion', () => {
injectStyles();
const el = document.getElementById('metona-editor-styles');
const css = el!.textContent || '';
expect(css).toContain('prefers-reduced-motion');
});
});
describe('styles.ts - updateStyles', () => {
afterEach(() => {
removeStyles();
});
test('updateStyles 更新已注入的样式', () => {
injectStyles();
const el = document.getElementById('metona-editor-styles')!;
const original = el.textContent;
updateStyles();
expect(el.textContent).toBe(original); // 内容一致
});
test('updateStyles 在未注入时自动注入', () => {
removeStyles();
expect(document.getElementById('metona-editor-styles')).toBeNull();
updateStyles();
expect(document.getElementById('metona-editor-styles')).not.toBeNull();
});
});
describe('styles.ts - removeStyles', () => {
test('removeStyles 移除样式元素', () => {
injectStyles();
expect(document.getElementById('metona-editor-styles')).not.toBeNull();
removeStyles();
expect(document.getElementById('metona-editor-styles')).toBeNull();
});
test('removeStyles 重复调用不抛错', () => {
removeStyles();
expect(() => removeStyles()).not.toThrow();
});
});
describe('styles.ts - getSystemTheme', () => {
test('返回 light 或 dark', () => {
const theme = getSystemTheme();
expect(['light', 'dark']).toContain(theme);
});
});
describe('styles.ts - watchSystemTheme', () => {
test('返回取消函数', () => {
const unsub = watchSystemTheme(() => {});
expect(typeof unsub).toBe('function');
unsub();
});
});