` |
| 脚注 | `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 — 全功能演示