diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 42ae805..9855a34 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -58,6 +58,25 @@ jobs: env: NODE_OPTIONS: --max-old-space-size=4096 + e2e: + runs-on: debian-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: 'npm' + - name: Install dependencies + run: npm ci + - name: Build + run: npm run build + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + - name: Run E2E smoke tests + run: npm run test:e2e + verify: runs-on: debian-latest timeout-minutes: 15 diff --git a/CHANGELOG.md b/CHANGELOG.md index fc52959..ded6dd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,62 @@ All notable changes to MetonaEditor will be documented in this file. +## [0.4.0] - 2026-08-11 + +### Added +- **Playwright 浏览器冒烟测试**: `e2e/` 目录(本地宿主页 + 真实 Chromium),22 项断言覆盖渲染 / 输入实时预览 / 工具栏命令 / 三模式切换 / 主题 / 搜索插件 / 状态栏 / 撤销 / 实例 API,并自动检测页面 console 错误。`npm run test:e2e` 一键运行,CI 新增 e2e job。 +- **`sideEffects: false`**: 便于消费方打包器 tree-shaking。 + +### Fixed +- **实例 locale 未作用于全部 UI 文案**: 状态栏、工具栏 tooltip、右键菜单、大纲标题、插入链接/图片占位、模式切换播报此前混用全局 `t`;浏览器默认语言与实例 `locale` 不一致时显示错误语言。现 `_i18nCtx` 提前至 DOM 构建前创建,所有 UI 文案统一走实例 `t()`。 + +### Changed +- **`MeEditor.destroy()` 全面复位**: 额外清理全局静态钩子(beforeCreate/afterCreate/beforeChange 等),并自动重建内部全局插件注入钩子,保证 `destroy()` 后 `MeEditor.use()` 依旧可用。 + +### Added +- 回归测试 5 个(实例 locale 决定状态栏/按钮/右键菜单文案、destroy 钩子清理与重建、destroy 后全局插件仍可用),总数 821 → 826。 + +--- + +## [0.3.1] - 2026-08-10 + +### Performance +- **高亮规则缓存**: `highlight()` 不再每次调用重建正则规则表,按语言缓存(`registerLanguage` 覆盖时自动失效)。 +- **统计增量计算**: `getStats()` / `_updateWordCount()` / `_renderGutter()` 基于共同前缀/后缀的差异区间增量计算(含单词边界扩展保证一致),击键时不再对全文做 O(n) 正则。 +- **大纲线性构建**: outline 树构建由递归 O(n²) 改为迭代栈 O(n),长文档无延迟。 + +### Changed +- **拖放图片大小限制**: 拖入 >500KB 的图片时 toast 警告且不插入,与 imagePaste 插件行为一致。 +- **`scrollToLine` 真实行高**: 使用 `getComputedStyle().lineHeight` 替代硬编码 22px。 +- **undo/redo 光标恢复**: 撤销/重做后光标位置 clamp 恢复(不越界)。 +- **selectionChange / cursorMove 与浮动工具栏解耦**: 事件现无条件触发(`floatingToolbar: false` 时仍可用)。 +- **`unregisterShortcut` 大小写归一**: 注册 `Ctrl+B` 可用 `ctrl+b` 注销。 +- **toast 动画接入 ANIMATIONS**: `animation` 参数真正应用 enter/leave 变换与时长缓动(slide/fade/scale/bounce/flip/rotate/zoom)。 +- **实例主题订阅随销毁断开**: `getThemeContext()` 新增 `dispose()`,`destroy()` 时自动断开外部跟随的 MutationObserver / 轮询订阅。 + +### Added +- 回归测试 20 个(高亮缓存失效 / 增量统计一致性与边界 / outline 树结构配对与长文档 / undo 光标 / selectionChange 解耦 / 快捷键归一 / toast 动画 / 拖放限大小 / 主题 dispose),总数 801 → 821。 + +--- + +## [0.3.0] - 2026-08-10 + +### Fixed +- **脚注 XSS 属性注入**: 脚注引用的 `fnId`(`[^id]` 中的 id)此前未转义直接拼入 `href` / `id` 属性,`[^a" onclick="x]` 可注入事件属性;现经属性级转义,行内引用与脚注区渲染均覆盖。 +- **全局插件静默失效**: `MeEditor.use('searchReplace' | 'shortcutHelp' | 'imagePaste')` 此前在 `beforeCreate` 钩子安装,但此时 DOM 尚未构建、`editor.textarea` 不存在,三个依赖 textarea 的插件静默跳过;现改为 `afterCreate` 注入,DOM 就绪后再安装。 +- **引用链接跨文档串数据**: 行内渲染缓存仅以文本为 key,不同文档中相同文本 + 不同 `[ref]: url` 定义会命中彼此缓存返回错误链接;现缓存 key 附加 refs 指纹(`parseMarkdown` 预计算,`renderTokens` 直接调用时兜底)。 +- **underline 预览不可见**: `Ctrl+U` 生成的 `` 此前被解析器转义为字面量 `<u>`;现解析器白名单透传裸 `` / ``(无属性,带属性的 `` 仍被转义,无注入面),代码块 / 行内代码内不受影响。 +- **`getStatus().locale` 与实例不一致**: 此前返回全局 locale,实例 `setLocale` 后状态不跟随;现返回实例 locale,渲染 env 的 `locale` 字段同步改为实例级。 +- **`replaceAll` 替换串 `$` 语义陷阱**: `replaceAll` 的替换文本现按字面处理(`$` 无特殊含义);`replaceAllRegex` 保留正则捕获组语义(`$1`)不变。 + +### Changed +- **`use()` 同名防重**: 同名插件重复安装时 warn 并跳过(`unuse` 后可重新安装),消除全局插件与 `config.plugins` 重复安装问题。 + +### Added +- 回归测试 19 个(脚注注入 / 全局插件 / 缓存指纹 / underline / getStatus locale / replaceAll `$` / use 防重),总数 782 → 801。 + +--- + ## [0.2.5] - 2026-08-09 ### Fixed diff --git a/README.md b/README.md index ee279a2..530a55f 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ > TypeScript 重构 · 零运行时依赖 · 轻量级桌面端 Markdown Editor 库 -[![version](https://img.shields.io/badge/version-0.2.5-blue)](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor) +[![version](https://img.shields.io/badge/version-0.4.0-blue)](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor) [![license](https://img.shields.io/badge/license-MIT-green)](./LICENSE) -[![tests](https://img.shields.io/badge/tests-780%20passed-brightgreen)](./tests) +[![tests](https://img.shields.io/badge/tests-826%20passed-brightgreen)](./tests) [![coverage](https://img.shields.io/badge/coverage-parser%2099%25-brightgreen)](./tests) [![types](https://img.shields.io/badge/types-TypeScript%20strict-blue)](./tsconfig.json) @@ -18,8 +18,10 @@ - **主题系统** — light / dark / warm / auto,CSS 变量可定制,实例级隔离,主题继承,外部跟随 - **国际化** — zh-CN / en-US / ja / ko / fr / de 六种语言完整翻译,实例级语言隔离,远程加载翻译包 - **编辑体验** — 行号装订线、智能 Enter、括号自动闭合、拖放文件、大纲面板、Zen 专注模式、右键上下文菜单 -- **内置语法高亮** — 零依赖轻量高亮器,js/ts/python/bash/css/html 等 13 种语言,`MeEditor.highlight` 即插即用 -- **安全** — HTML 转义,XSS 协议过滤(javascript / vbscript / file / data),属性级注入防护,sanitize 钩子 +- **内置语法高亮** — 零依赖轻量高亮器,js/ts/python/bash/css/html 等 16 个语言标识,规则缓存零重建开销,`MeEditor.highlight` 即插即用 +- **性能** — 字数 / 词数 / 行数差异增量统计,击键零全量扫描;大纲 O(n) 线性构建,长文档即时响应 +- **安全** — HTML 转义,XSS 协议过滤(javascript / vbscript / file / data),属性级注入防护(含脚注 id),白名单裸标签透传,sanitize 钩子 +- **工程化** — 826 个单元测试 + Playwright 真实浏览器冒烟测试,CI 并行 4 job,`sideEffects: false` 便于 tree-shaking - **桌面端优先** — 纯电脑端设计,无移动端冗余代码 - **引用链接** — 支持 `[text][ref]` / `![alt][ref]` 引用式链接和图片,含 title 属性 - **RTL 支持** — 完整的从右到左布局适配(阿拉伯语、希伯来语等) @@ -77,10 +79,10 @@ const editor = MeEditor.create('#editor', { mode: 'split' }); ```html - + - + @@ -122,6 +124,7 @@ MeEditor.create(container, { // 内容 value: '', // 初始 Markdown 文本 placeholder: '', // 占位符 + id: '', // 实例 id(默认自动生成,用于分隔条/草稿等存储键隔离) // 视图 mode: 'split', // 'edit' | 'split' | 'preview' @@ -256,6 +259,7 @@ editor.exec(action: string): this // h1 h2 h3 quote ul ol hr // link image table // indent outdent undo redo +// formatTable(光标所在表格按列宽对齐) // edit split preview fullscreen // zen wordwrap ``` @@ -352,8 +356,8 @@ editor.isWordWrap(): boolean ```typescript editor.toast(message: string, opts?: { type?: 'success' | 'error' | 'warning' | 'info'; - duration?: number; - animation?: 'fade' | 'slide' | 'scale'; + duration?: number; // ms,0 = 不自动消失 + animation?: 'fade' | 'slide' | 'scale' | 'bounce' | 'flip' | 'rotate' | 'zoom'; }): this ``` @@ -397,7 +401,7 @@ MeEditor.setLocale('en-US') MeEditor.getStatus() // => { version, theme, locale, globalPlugins, presetPlugins } -// 销毁全局资源 +// 销毁全局资源(主题/语言监听、全局插件、全局钩子全面复位) MeEditor.destroy() // 解析器独立使用 @@ -465,6 +469,7 @@ import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team | 粗斜体 | `***text***` `___text___` | `` | | 斜体 | `*italic*` `_italic_` | `` | | 删除线 | `~~text~~` | `` | +| 下划线 | `text`(裸标签透传) | `` | | 高亮 | `==text==` | `` | | 上标 | `x^2^` | `` | | 下标 | `H~2~O` | `` | @@ -482,6 +487,7 @@ import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team | 图片 | `![alt](url)` | `` | | 引用链接 | `[text][ref]` + `[ref]: url` | `` | | 引用图片 | `![alt][ref]` + `[ref]: url` | `` | +| 表格格式化 | `exec('formatTable')` | 光标所在表格按列宽对齐 | | 自动链接 | `` | `` | | 数学公式 | `$E=mc^2$` `$$\int$$` | `` / `
` | | 脚注 | `text[^1]` | `` + 底部定义 | @@ -532,7 +538,8 @@ registerBlockHandler({ - 所有文本经 `escapeHTML` 转义 - 链接 URL 过滤 `javascript:` / `vbscript:` / `file:` / 非图片 `data:` -- 属性级注入防护:`href` / `src` / `alt` / `title` / `language-*` 引号与换行均被转义 +- 属性级注入防护:`href` / `src` / `alt` / `title` / 脚注 id / `language-*` 引号与换行均被转义 +- 白名单裸标签透传:仅 `` / ``(无属性),带属性的标签仍被转义 - `data:image` 限制最大 500KB - `sanitize` 钩子供外部净化(如 DOMPurify) - `highlight` 钩子异常自动回退为纯文本 @@ -666,6 +673,9 @@ editor.getThemeContext().adopt(); // 跟随外部元素 editor.getThemeContext().syncWithElement(document.body); + +// 手动断开所有外部跟随订阅(实例 destroy 时也会自动断开) +editor.getThemeContext().dispose(); ``` --- @@ -749,6 +759,9 @@ npm test # 测试覆盖率 npm run test -- --coverage +# 浏览器冒烟测试(Playwright + Chromium,需先构建) +npm run build && npm run test:e2e + # 性能基准(先构建再运行) npm run build && npm run bench @@ -760,11 +773,11 @@ npm run format ``` dist/ -├── metona-editor.js UMD(浏览器直接引入) -├── metona-editor.min.js UMD 压缩版(CDN) -├── metona-editor.mjs ES Module -├── metona-editor.cjs CommonJS -└── metona-editor.d.ts TypeScript 类型声明 +├── metona-editor.js UMD(浏览器直接引入) +├── metona-editor.min.js UMD 压缩版(CDN) +├── metona-editor.mjs ES Module +├── metona-editor.cjs CommonJS +└── metona-editor.d.ts TypeScript 类型声明 ``` ### 源码结构 @@ -796,7 +809,8 @@ src/ |------|------| | 语言 | TypeScript 5 (strict) | | 构建 | Rollup 3 | -| 测试 | Jest 29 + jsdom | +| 测试 | Jest 29 + jsdom(826 用例) | +| 浏览器冒烟 | Playwright + Chromium(22 项断言) | | 类型生成 | rollup-plugin-dts | | 零运行时依赖 | ✅ | diff --git a/e2e/host.html b/e2e/host.html new file mode 100644 index 0000000..51d02bd --- /dev/null +++ b/e2e/host.html @@ -0,0 +1,32 @@ + + + + +MetonaEditor E2E Host + + +
+ + + + diff --git a/e2e/smoke.cjs b/e2e/smoke.cjs new file mode 100644 index 0000000..1d565b4 --- /dev/null +++ b/e2e/smoke.cjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +/** + * MetonaEditor 浏览器冒烟测试(Playwright + 本地静态服务) + * 前置: npm run build(需 dist/metona-editor.js 存在) + * 用法: npm run test:e2e + */ + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { chromium } = require('playwright'); + +const ROOT = path.resolve(__dirname, '..'); +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript', + '.css': 'text/css', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.mjs': 'text/javascript', + '.cjs': 'text/javascript', +}; + +const server = http.createServer((req, res) => { + let p = decodeURIComponent((req.url || '/').split('?')[0]); + if (p === '/') p = '/e2e/host.html'; + const file = path.resolve(ROOT, '.' + p); + if (!file.startsWith(ROOT) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { + res.writeHead(404); res.end('404 Not Found'); return; + } + res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'text/plain' }); + fs.createReadStream(file).pipe(res); +}); + +let passed = 0; +const check = (name, cond) => { + if (!cond) throw new Error(`FAIL: ${name}`); + passed++; + console.log(` ✓ ${name}`); +}; + +(async () => { + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const port = server.address().port; + console.log(`MetonaEditor E2E · http://127.0.0.1:${port}/`); + + const browser = await chromium.launch(); + const page = await browser.newPage(); + const errors = []; + page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); + page.on('pageerror', (e) => errors.push(String(e))); + + await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'load' }); + + // 1. 编辑器渲染 + await page.waitForSelector('.me-wrapper', { timeout: 10000 }); + check('编辑器 DOM 渲染', await page.locator('.me-wrapper').count() === 1); + check('textarea 存在', await page.locator('.me-textarea').count() === 1); + check('初始 value 写入', (await page.locator('.me-textarea').inputValue()).includes('# E2E Title')); + + // 2. 预览渲染 + const h1 = await page.locator('.me-preview h1').textContent(); + check('预览渲染 h1', h1 === 'E2E Title'); + check('预览渲染粗体', (await page.locator('.me-preview strong').count()) === 1); + check('预览渲染下划线 ', (await page.locator('.me-preview u').count()) === 1); + check('预览渲染表格', (await page.locator('.me-preview table').count()) === 1); + + // 3. 语法高亮 + check('代码块高亮 keyword', (await page.locator('.me-preview .me-hl-keyword').count()) > 0); + check('代码块高亮 comment', (await page.locator('.me-preview .me-hl-comment').count()) > 0); + + // 4. 输入 → 预览实时更新 + await page.locator('.me-textarea').fill('# 输入测试\n\nhello **world**'); + await page.waitForTimeout(100); + check('输入后预览更新 h1', (await page.locator('.me-preview h1').textContent()) === '输入测试'); + check('输入后预览更新 strong', (await page.locator('.me-preview strong').textContent()) === 'world'); + + // 5. 工具栏命令(bold 包裹选区) + await page.locator('.me-textarea').fill('select me'); + await page.locator('.me-textarea').evaluate((el) => { el.focus(); el.setSelectionRange(0, 9); }); + await page.locator('.me-btn[data-action="bold"]').click(); + check('工具栏 bold 命令', (await page.locator('.me-textarea').inputValue()) === '**select me**'); + + // 6. 模式切换 + await page.locator('.me-btn[data-mode="preview"]').click(); + check('切换到 preview 模式', await page.locator('.me-body.me-mode-preview').count() === 1); + await page.locator('.me-btn[data-mode="edit"]').click(); + check('切换到 edit 模式', await page.locator('.me-body.me-mode-edit').count() === 1); + await page.locator('.me-btn[data-mode="split"]').click(); + check('切换到 split 模式', await page.locator('.me-body.me-mode-split').count() === 1); + + // 7. 主题切换 + await page.evaluate(() => window.__ed.setTheme('dark')); + check('实例主题 dark', (await page.locator('.me-wrapper').getAttribute('data-md-theme')) === 'dark'); + + // 8. 搜索插件(Ctrl+F) + await page.locator('.me-textarea').press('Control+f'); + check('Ctrl+F 打开搜索面板', (await page.locator('.me-search').count()) === 1); + await page.locator('.me-search-find').fill('select'); + await page.locator('.me-search-next').click(); + check('搜索面板查找选中', (await page.locator('.me-textarea').evaluate((el) => el.value.substring(el.selectionStart, el.selectionEnd))) === 'select'); + await page.keyboard.press('Escape'); + + // 9. 统计状态栏 + check('状态栏字数统计', (await page.locator('.me-statusbar').textContent()).includes('字符')); + + // 10. 撤销(bold 后撤销回到初始内容) + await page.locator('.me-textarea').press('Control+z'); + await page.waitForTimeout(50); + check('Ctrl+Z 撤销', (await page.locator('.me-textarea').inputValue()).includes('E2E Title')); + + // 11. 实例 API 直调 + const stats = await page.evaluate(() => window.__ed.getStats()); + check('getStats 返回结构', typeof stats.characters === 'number' && typeof stats.words === 'number'); + const status = await page.evaluate(() => window.__ed.getStatus()); + check('getStatus 返回结构', status.mode === 'split' && status.theme === 'dark' && status.locale === 'zh-CN'); + + await browser.close(); + server.close(); + + if (errors.length) { + console.error('\n页面错误:', errors); + process.exit(1); + } + console.log(`\n全部通过: ${passed} 项断言 · 无页面错误`); +})().catch((e) => { + console.error('\n' + (e && e.message ? e.message : e)); + try { server.close(); } catch (_) {} + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json index bf28014..6772c30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@metona-team/metona-editor", - "version": "0.2.2", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@metona-team/metona-editor", - "version": "0.2.2", + "version": "0.3.1", "license": "MIT", "devDependencies": { "@babel/core": "^7.22.0", @@ -24,6 +24,7 @@ "eslint": "^8.40.0", "jest": "^29.5.0", "jest-environment-jsdom": "^29.5.0", + "playwright": "^1.62.1", "prettier": "^2.8.0", "rollup": "^3.20.0", "rollup-plugin-dts": "^5.3.0", @@ -6567,6 +6568,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index 8114447..512d446 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metona-team/metona-editor", - "version": "0.2.5", + "version": "0.4.0", "description": "Type-safe, lightweight, zero-dependency Markdown Editor. Desktop-first. React-free. Single-file bundle.", "type": "module", "main": "dist/metona-editor.cjs", @@ -23,6 +23,7 @@ "README.md", "LICENSE" ], + "sideEffects": false, "scripts": { "build": "rollup -c", "dev": "rollup -c -w", @@ -33,6 +34,7 @@ "format": "prettier --write src/", "typecheck": "tsc --noEmit", "bench": "node bench/benchmark.cjs", + "test:e2e": "node e2e/smoke.cjs", "prepublishOnly": "npm run typecheck && npm test && npm run build" }, "repository": { @@ -71,6 +73,7 @@ "eslint": "^8.40.0", "jest": "^29.5.0", "jest-environment-jsdom": "^29.5.0", + "playwright": "^1.62.1", "prettier": "^2.8.0", "rollup": "^3.20.0", "rollup-plugin-dts": "^5.3.0", diff --git a/site/demo.html b/site/demo.html index 3f18a99..c454678 100644 --- a/site/demo.html +++ b/site/demo.html @@ -4,7 +4,7 @@ -MetonaEditor v0.2.5 — 全功能演示 +MetonaEditor v0.4.0 — 全功能演示