fix: 复查修复 5 处问题 — URL 双重转义/zen 泄漏/高亮环境不一致等
- parser: href/src/alt 改最小属性转义(escapeAttr 会把已实体化的 & 再转义成 &amp;,导致链接 URL 双重转义错误);新增 URL/alt 防回归测试 - core: _pushHistory 增加 maxLength 兜底截断(exec/Tab/Smart Enter/括号 自动闭合等程序化路径此前绕过 maxlength 限制) - core: destroy 时清理 Zen 模式的 document mousemove 监听(此前泄漏) - highlight: 自写环境无关转义(仅 & < >),修复 Node 环境下字符串高亮 失效、浏览器与 SSR 输出不一致的问题 - floating-toolbar/context-menu: toggleFloatingToolbar/registerContextMenu 恢复链式 return this(拆分时丢失) - plugins: 补齐 3 个硬编码文案的 i18n(regex/shortcuts/imageTooLarge), 6 种语言同步 - 测试 768 → 780
This commit is contained in:
+1
-1
@@ -24,7 +24,7 @@ All notable changes to MetonaEditor will be documented in this file.
|
||||
- **eslint type-aware**: 启用 `parserOptions.project` 与 `consistent-type-imports` / `no-unnecessary-type-assertion` 规则,0 errors。
|
||||
- **CI 并行化**: 测试拆分为 parser / core / rest 三个并行 job,另设跨 Node 18/20/24 的 verify job(typecheck + lint + build)。
|
||||
- **docs.html / README 补齐**: 光标选区 API、浮动工具栏、replaceAll、新事件、内置高亮文档。
|
||||
- 版本同步 0.2.4 → 0.2.5,测试 725 → 770+。
|
||||
- 版本同步 0.2.4 → 0.2.5,测试 725 → 768。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -401,6 +401,9 @@ MeEditor.destroy()
|
||||
|
||||
// 解析器独立使用
|
||||
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, registerBlockHandler } from '@metona-team/metona-editor';
|
||||
|
||||
// 内置语法高亮
|
||||
import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team/metona-editor';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+2
-1
@@ -7,8 +7,9 @@
|
||||
import { t as i18nT } from './i18n';
|
||||
import type { MarkdownEditor } from './core';
|
||||
|
||||
export function registerContextMenu(this: MarkdownEditor, items: any[] = []): void {
|
||||
export function registerContextMenu(this: MarkdownEditor, items: any[] = []): this {
|
||||
this._contextMenuItems = items;
|
||||
return this;
|
||||
}
|
||||
|
||||
export function bindContextMenu(this: MarkdownEditor): void {
|
||||
|
||||
@@ -512,6 +512,13 @@ export class MarkdownEditor {
|
||||
_scheduleHistory(): void { if (this._historyTimer) clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); }
|
||||
|
||||
_pushHistory(): void {
|
||||
// Enforce maxLength as a last-resort guard for programmatic edit paths
|
||||
// (tab/indent, smart-enter, bracket auto-close, exec commands) that write
|
||||
// to the textarea directly and bypass the native maxlength attribute.
|
||||
if (this.config.maxLength && this.config.maxLength > 0 && this._value.length > this.config.maxLength) {
|
||||
this._value = this._value.slice(0, this.config.maxLength);
|
||||
if (this.textarea) this.textarea.value = this._value;
|
||||
}
|
||||
const cur = this._history[this._historyIndex]; if (cur === this._value) return;
|
||||
this._history = this._history.slice(0, this._historyIndex + 1); this._history.push(this._value);
|
||||
const limit = this.config.historyLimit! > 0 ? this.config.historyLimit! : 100;
|
||||
@@ -846,6 +853,7 @@ export class MarkdownEditor {
|
||||
if (this._historyTimer) clearTimeout(this._historyTimer);
|
||||
if (this._outlineTimer) clearTimeout(this._outlineTimer);
|
||||
this._cleanups.forEach((fn) => { try { fn(); } catch (_) {} }); this._cleanups = [];
|
||||
if (this._zenMouseHandler) { document.removeEventListener('mousemove', this._zenMouseHandler); this._zenMouseHandler = null; }
|
||||
this._shortcuts = []; this._contextMenuItems = []; this._customActions = {};
|
||||
if (this._floatingToolbar && this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
|
||||
this._floatingToolbar = null;
|
||||
|
||||
@@ -85,7 +85,7 @@ export function initFloatingToolbar(this: MarkdownEditor): void {
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleFloatingToolbar(this: MarkdownEditor): void {
|
||||
export function toggleFloatingToolbar(this: MarkdownEditor): this {
|
||||
this._floatingEnabled = !this._floatingEnabled;
|
||||
if (!this._floatingEnabled) {
|
||||
if (this._floatingToolbar) {
|
||||
@@ -93,6 +93,7 @@ export function toggleFloatingToolbar(this: MarkdownEditor): void {
|
||||
this._floatingToolbar = null;
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
export function isFloatingToolbar(this: MarkdownEditor): boolean { return this._floatingEnabled; }
|
||||
|
||||
+11
-2
@@ -8,7 +8,16 @@
|
||||
* escaped text (no raw HTML can leak through).
|
||||
*/
|
||||
|
||||
import { escapeHTML } from './utils';
|
||||
/**
|
||||
* Environment-agnostic escaping for tokenizer input.
|
||||
* Unlike the `escapeHTML` util (whose quote handling differs between browser
|
||||
* DOM and Node), we only escape `& < >` here so string/comment token rules
|
||||
* behave identically in both environments. Quote characters are safe inside
|
||||
* text nodes and do not need escaping.
|
||||
*/
|
||||
const escapeText = (s: string): string => {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
};
|
||||
|
||||
// ============ Token rules ============
|
||||
|
||||
@@ -196,7 +205,7 @@ export const normalizeLanguage = (lang: string): string => {
|
||||
export const highlight = (code: string, lang: string): string => {
|
||||
const canonical = normalizeLanguage(lang);
|
||||
const def = LANGUAGES[canonical];
|
||||
const escaped = escapeHTML(code);
|
||||
const escaped = escapeText(code);
|
||||
if (!def) return escaped;
|
||||
return tokenize(escaped, buildRules(def));
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
|
||||
renderError: '渲染失败',
|
||||
outline: '大纲',
|
||||
cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选',
|
||||
regex: '正则表达式', shortcuts: '快捷键', imageTooLarge: '图片过大({size}KB > {max}KB)',
|
||||
},
|
||||
'en-US': {
|
||||
bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough',
|
||||
@@ -56,6 +57,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
|
||||
renderError: 'Render failed',
|
||||
outline: 'Outline',
|
||||
cut: 'Cut', copy: 'Copy', paste: 'Paste', selectAll: 'Select All',
|
||||
regex: 'Regex', shortcuts: 'Shortcuts', imageTooLarge: 'Image too large ({size}KB > {max}KB)',
|
||||
},
|
||||
ja: {
|
||||
bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線',
|
||||
@@ -82,6 +84,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
|
||||
renderError: 'レンダリング失敗',
|
||||
outline: 'アウトライン',
|
||||
cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択',
|
||||
regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB)',
|
||||
},
|
||||
ko: {
|
||||
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
|
||||
@@ -108,6 +111,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
|
||||
renderError: '렌더링 실패',
|
||||
outline: '개요',
|
||||
cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택',
|
||||
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다({size}KB > {max}KB)',
|
||||
},
|
||||
fr: {
|
||||
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
|
||||
@@ -134,6 +138,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
|
||||
renderError: 'Échec du rendu',
|
||||
outline: 'Plan',
|
||||
cut: 'Couper', copy: 'Copier', paste: 'Coller', selectAll: 'Tout sélectionner',
|
||||
regex: 'Regex', shortcuts: 'Raccourcis', imageTooLarge: 'Image trop grande ({size}Ko > {max}Ko)',
|
||||
},
|
||||
de: {
|
||||
bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen',
|
||||
@@ -160,5 +165,6 @@ export const LOCALES: Record<string, Record<string, string>> = {
|
||||
renderError: 'Rendern fehlgeschlagen',
|
||||
outline: 'Gliederung',
|
||||
cut: 'Ausschneiden', copy: 'Kopieren', paste: 'Einfügen', selectAll: 'Alles auswählen',
|
||||
regex: 'Regex', shortcuts: 'Tastenkürzel', imageTooLarge: 'Bild zu groß ({size}KB > {max}KB)',
|
||||
},
|
||||
};
|
||||
|
||||
+15
-9
@@ -142,8 +142,14 @@ const unescapePunct = (text: string): string => text.replace(/\\([!"#$%&'()*+,\-
|
||||
|
||||
/**
|
||||
* Safe double-quoted attribute value. Inline-sourced text is already HTML-escaped
|
||||
* (entities are safe inside quoted attributes); ref-sourced text is raw but only
|
||||
* `"` terminates a double-quoted attribute, so escaping quotes (and newlines) suffices.
|
||||
* (entities are safe inside quoted attributes and decode correctly), so only the
|
||||
* quote character that would terminate the attribute needs escaping.
|
||||
*/
|
||||
const attrSafe = (v: string): string => v.replace(/"/g, '"').replace(/\r?\n/g, ' ');
|
||||
|
||||
/**
|
||||
* Same as `attrSafe` but also restores backslash-escaped punctuation first
|
||||
* (used for title attributes where \" is a markdown escape).
|
||||
*/
|
||||
const titleAttr = (title: string | undefined): string => {
|
||||
if (!title) return '';
|
||||
@@ -695,7 +701,7 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
|
||||
const m = match.match(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
|
||||
if (!m) return match;
|
||||
const u = safeUrl(m[2]); if (!u) return escapeHTML(match);
|
||||
return `<img src="${escapeAttr(u)}" alt="${escapeAttr(m[1])}"${titleAttr(m[3])} loading="lazy"/>`;
|
||||
return `<img src="${attrSafe(u)}" alt="${attrSafe(m[1])}"${titleAttr(m[3])} loading="lazy"/>`;
|
||||
}
|
||||
if (match.startsWith('![') && match.includes('][')) {
|
||||
const m = match.match(/!\[([^\]]*)\]\[([^\]]*)\]/);
|
||||
@@ -705,17 +711,17 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
|
||||
try {
|
||||
const ref = JSON.parse(env.refs[refKey]);
|
||||
const u = safeUrl(ref.url); if (!u) return escapeHTML(match);
|
||||
return `<img src="${escapeAttr(u)}" alt="${escapeAttr(m[1])}"${titleAttr(escapeHTML(ref.title))} loading="lazy"/>`;
|
||||
} catch (_) { return `<img src="" alt="${escapeAttr(m[1])}" class="me-img-ref"/>`; }
|
||||
return `<img src="${attrSafe(u)}" alt="${attrSafe(m[1])}"${titleAttr(escapeHTML(ref.title))} loading="lazy"/>`;
|
||||
} catch (_) { return `<img src="" alt="${attrSafe(m[1])}" class="me-img-ref"/>`; }
|
||||
}
|
||||
return `<img src="" alt="${escapeAttr(m[1])}" class="me-img-ref"/>`;
|
||||
return `<img src="" alt="${attrSafe(m[1])}" class="me-img-ref"/>`;
|
||||
}
|
||||
if (match.startsWith('[') && match.includes('](')) {
|
||||
const m = match.match(/\[([^\]]+)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
|
||||
if (!m) return match;
|
||||
const u = safeUrl(m[2]); if (!u) return match;
|
||||
const linkText = renderInline(m[1], env);
|
||||
return `<a href="${escapeAttr(u)}"${titleAttr(m[3])} target="_blank" rel="noopener noreferrer">${linkText}</a>`;
|
||||
return `<a href="${attrSafe(u)}"${titleAttr(m[3])} target="_blank" rel="noopener noreferrer">${linkText}</a>`;
|
||||
}
|
||||
if (match.startsWith('[') && match.includes('][')) {
|
||||
const m = match.match(/\[([^\]]+)\]\[([^\]]*)\]/);
|
||||
@@ -725,7 +731,7 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
|
||||
try {
|
||||
const ref = JSON.parse(env.refs[refKey]);
|
||||
const u = safeUrl(ref.url); if (!u) return escapeHTML(match);
|
||||
return `<a href="${escapeAttr(u)}"${titleAttr(escapeHTML(ref.title))} target="_blank" rel="noopener noreferrer">${m[1]}</a>`;
|
||||
return `<a href="${attrSafe(u)}"${titleAttr(escapeHTML(ref.title))} target="_blank" rel="noopener noreferrer">${m[1]}</a>`;
|
||||
} catch (_) { return escapeHTML(match); }
|
||||
}
|
||||
return escapeHTML(match);
|
||||
@@ -733,7 +739,7 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
|
||||
if (match.startsWith('<http')) {
|
||||
const url = match.slice(4, -4);
|
||||
const u = safeUrl(url);
|
||||
return `<a href="${escapeAttr(u)}" target="_blank" rel="noopener noreferrer">${url}</a>`;
|
||||
return `<a href="${attrSafe(u)}" target="_blank" rel="noopener noreferrer">${url}</a>`;
|
||||
}
|
||||
if (match.startsWith('**')) return `<strong>${match.slice(2, -2)}</strong>`;
|
||||
if (match.startsWith('__')) return `<strong>${match.slice(2, -2)}</strong>`;
|
||||
|
||||
+4
-4
@@ -323,7 +323,7 @@ const searchReplacePlugin: Plugin = {
|
||||
const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd);
|
||||
const panel = document.createElement('div'); panel.className = 'me-search';
|
||||
panel.dataset.replace = showReplace ? '1' : '0';
|
||||
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder')||'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${t('findPrev')||'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${t('findNext')||'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${t('matchCase')||'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${t('wholeWord')||'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="Regex">.*</button><button class="me-search-btn me-search-close" title="${t('close')||'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder')||'Replace'}"/><button class="me-search-btn me-search-replace-one">${t('replace')||'Replace'}</button><button class="me-search-btn me-search-replace-all">${t('replaceAll')||'All'}</button></div>`;
|
||||
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder')||'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${t('findPrev')||'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${t('findNext')||'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${t('matchCase')||'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${t('wholeWord')||'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${t('regex')||'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${t('close')||'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder')||'Replace'}"/><button class="me-search-btn me-search-replace-one">${t('replace')||'Replace'}</button><button class="me-search-btn me-search-replace-all">${t('replaceAll')||'All'}</button></div>`;
|
||||
editor.el.appendChild(panel); state._panel = panel;
|
||||
_updateReplaceVisible();
|
||||
state._regexMode = false; state._matchCase = true; state._wholeWord = false;
|
||||
@@ -483,7 +483,7 @@ const imagePastePlugin: Plugin = {
|
||||
const sizeKB = file.size / 1024;
|
||||
if (maxSizeKB > 0 && sizeKB > maxSizeKB) {
|
||||
e.preventDefault();
|
||||
if (typeof editor.toast === 'function') editor.toast(`Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
|
||||
if (typeof editor.toast === 'function') editor.toast(t('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
|
||||
break;
|
||||
}
|
||||
e.preventDefault();
|
||||
@@ -527,11 +527,11 @@ const shortcutHelpPlugin: Plugin = {
|
||||
['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'],
|
||||
['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'],
|
||||
['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'],
|
||||
['?', t('close') || 'Shortcuts'],
|
||||
['?', t('shortcuts') || 'Shortcuts'],
|
||||
];
|
||||
let rows = ''; builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; });
|
||||
const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay';
|
||||
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${t('close') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
|
||||
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${t('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay || (e.target as HTMLElement).classList.contains('me-shortcut-close')) _close(); });
|
||||
document.body.appendChild(overlay); _panel = overlay;
|
||||
};
|
||||
|
||||
@@ -2414,3 +2414,79 @@ describe('MarkdownEditor - v0.2.5 分隔条实例隔离', () => {
|
||||
a.destroy(); b.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.2.5 复查:maxLength 兜底 + zen destroy 清理 ============
|
||||
|
||||
describe('MarkdownEditor - v0.2.5 maxLength 兜底', () => {
|
||||
test('exec 命令路径不突破 maxLength', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { maxLength: 20, value: '12345678901234567890' });
|
||||
ed.focus();
|
||||
ed.textarea.setSelectionRange(0, 0);
|
||||
ed.exec('indent');
|
||||
expect(ed.getValue().length).toBeLessThanOrEqual(20);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('Smart Enter 不突破 maxLength', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { maxLength: 30, value: '- item 123456789012345678901234' });
|
||||
ed.focus();
|
||||
ed.textarea.setSelectionRange(ed.getValue().length, ed.getValue().length);
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
expect(ed.getValue().length).toBeLessThanOrEqual(30);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.2.5 zen destroy 清理', () => {
|
||||
test('destroy 移除 zen mousemove 监听', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { zenMode: true });
|
||||
expect(ed._zenMouseHandler).not.toBeNull();
|
||||
const spy = jest.spyOn(document, 'removeEventListener');
|
||||
ed.destroy();
|
||||
expect(spy).toHaveBeenCalledWith('mousemove', expect.any(Function));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('destroy 后 mousemove 不再修改 toolbar', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { zenMode: true });
|
||||
const toolbar = ed.toolbarEl;
|
||||
ed.destroy();
|
||||
toolbar.style.opacity = '1';
|
||||
document.dispatchEvent(new MouseEvent('mousemove', { clientY: 10 }));
|
||||
expect(toolbar.style.opacity).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.2.5 复查:链式 API 契约 ============
|
||||
|
||||
describe('MarkdownEditor - v0.2.5 链式返回', () => {
|
||||
test('toggleFloatingToolbar 返回 this', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
expect(ed.toggleFloatingToolbar()).toBe(ed);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('registerContextMenu 返回 this', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
expect(ed.registerContextMenu([{ label: 'x', onClick: () => {} }])).toBe(ed);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,3 +99,18 @@ describe('highlight - 语言注册', () => {
|
||||
expect(langs).toContain('python');
|
||||
});
|
||||
});
|
||||
|
||||
describe('highlight - 环境一致性', () => {
|
||||
test('字符串高亮与引号转义环境无关(jsdom 断言)', () => {
|
||||
const out = highlight('const s = "hi"', 'js');
|
||||
expect(out).toContain('<span class="me-hl-string">"hi"</span>');
|
||||
});
|
||||
|
||||
test('引号不被实体化,span 文本保持原样', () => {
|
||||
const out = highlight('"a" + \'b\'', 'js');
|
||||
expect(out).not.toContain('"');
|
||||
// 双引号字符串应被完整标记
|
||||
expect(out).toContain('<span class="me-hl-string">"a"</span>');
|
||||
expect(out).toContain("<span class=\"me-hl-string\">'b'</span>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1328,3 +1328,29 @@ describe('parseMarkdown - v0.2.5 title 转义', () => {
|
||||
expect(html).toContain('title="say "hi""');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseMarkdown - v0.2.5 URL 属性无双重转义', () => {
|
||||
test('链接 URL 中的 & 不双重转义', () => {
|
||||
const html = parseMarkdown('[x](https://example.com/?a=1&b=2)');
|
||||
expect(html).toContain('href="https://example.com/?a=1&b=2"');
|
||||
expect(html).not.toContain('&amp;');
|
||||
});
|
||||
|
||||
test('图片 alt 中的 & 不双重转义', () => {
|
||||
const html = parseMarkdown('');
|
||||
expect(html).toContain('alt="a & b"');
|
||||
expect(html).not.toContain('&amp;');
|
||||
});
|
||||
|
||||
test('引用链接 URL 中的 & 不被破坏', () => {
|
||||
const html = parseMarkdown('[r]: https://example.com/?a=1&b=2\n\n[x][r]');
|
||||
expect(html).toContain('href="https://example.com/?a=1&b=2"');
|
||||
expect(html).not.toContain('&amp;');
|
||||
});
|
||||
|
||||
test('URL 中的引号仍被转义防注入', () => {
|
||||
const html = parseMarkdown('[x](https://example.com/"onclick="alert(1))');
|
||||
expect(html).not.toContain(' onclick="');
|
||||
expect(html).toContain('"onclick');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user