Initial commit: MetonaEditor v0.1.0

This commit is contained in:
2026-07-23 16:23:07 +08:00
commit 22e867eda8
32 changed files with 17621 additions and 0 deletions
+322
View File
@@ -0,0 +1,322 @@
/**
* MetonaEditor Parser - 轻量 Markdown 解析器
* @module parser
* @version 0.1.0
* @description 自研零依赖 Markdown 解析器,覆盖 CommonMark 子集 + GFM 扩展
*
* 支持语法:
* - 标题 # ~ ######
* - 段落 / 换行
* - 粗体 **text** / __text__
* - 斜体 *text* / _text_
* - 删除线 ~~text~~
* - 行内代码 `code`
* - 代码块 ```lang ... ``` 与 ~~~ ... ~~~
* - 引用块 >
* - 无序列表 - / * / +
* - 有序列表 1.
* - 任务列表 - [ ] / - [x]
* - 水平线 --- / *** / ___
* - 表格 | a | b |
* - 链接 [text](url) / 自动链接 <url>
* - 图片 ![alt](url)
* - 转义字符 \X
*
* 安全:所有文本经 HTML 转义;URL 过滤 javascript:/vbscript: 等危险协议
* 扩展:env.highlight(code, lang) => html 钩子用于代码高亮
*/
import { escapeHTML } from './utils.js';
/**
* 危险协议过滤
*/
const safeUrl = (url) => {
if (!url) return '';
const u = String(url).trim();
if (/^(javascript|vbscript|file):/i.test(u)) return '';
// 仅允许 data:image/
if (/^data:/i.test(u) && !/^data:image\//i.test(u)) return '';
return u;
};
/**
* 主解析入口
* @param {string} md - Markdown 源文本
* @param {Object} env - 渲染环境 { highlight, locale }
* @returns {string} HTML 字符串
*/
export const parseMarkdown = (md, env = {}) => {
if (md == null) return '';
const text = String(md).replace(/\r\n?/g, '\n');
const lines = text.split('\n');
const tokens = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// 空行
if (/^\s*$/.test(line)) { i++; continue; }
// 围栏代码块
const fence = line.match(/^(\s*)(`{3,}|~{3,})\s*([\w+-]*)\s*$/);
if (fence) {
const fenceChar = fence[2][0];
const lang = fence[3] || '';
const codeLines = [];
i++;
const closeRe = new RegExp('^\\s*' + fenceChar + '{3,}\\s*$');
while (i < lines.length && !closeRe.test(lines[i])) {
codeLines.push(lines[i]);
i++;
}
if (i < lines.length) i++; // 跳过结束围栏
tokens.push({ type: 'code', lang, content: codeLines.join('\n') });
continue;
}
// ATX 标题
const h = line.match(/^(#{1,6})\s+(.*?)(?:\s+#{1,6})?\s*$/);
if (h) {
tokens.push({ type: 'heading', level: h[1].length, text: h[2] });
i++;
continue;
}
// 水平线
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
tokens.push({ type: 'hr' });
i++;
continue;
}
// 引用块
if (/^\s*>/.test(line)) {
const quote = [];
while (i < lines.length && /^\s*>/.test(lines[i])) {
quote.push(lines[i].replace(/^\s*>?\s?/, ''));
i++;
}
tokens.push({ type: 'quote', content: quote.join('\n') });
continue;
}
// 表格:当前行含 |,下一行是分隔行
if (/\|/.test(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
const header = line;
i += 2;
const rows = [];
while (i < lines.length && /\|/.test(lines[i]) && !/^\s*$/.test(lines[i])) {
rows.push(lines[i]);
i++;
}
tokens.push({ type: 'table', header, rows });
continue;
}
// 无序列表 / 任务列表
if (/^\s*[-*+]\s/.test(line)) {
const items = [];
while (i < lines.length && /^\s*[-*+]\s/.test(lines[i])) {
const raw = lines[i].replace(/^\s*[-*+]\s/, '');
const task = raw.match(/^\[([ xX])\]\s+(.*)$/);
if (task) {
items.push({ text: task[2], checked: task[1].toLowerCase() === 'x', task: true });
} else {
items.push({ text: raw, checked: false, task: false });
}
i++;
}
tokens.push({ type: 'ul', items });
continue;
}
// 有序列表
if (/^\s*\d+\.\s/.test(line)) {
const items = [];
while (i < lines.length && /^\s*\d+\.\s/.test(lines[i])) {
items.push(lines[i].replace(/^\s*\d+\.\s/, ''));
i++;
}
tokens.push({ type: 'ol', items });
continue;
}
// 段落(收集连续非块级行)
const para = [];
while (i < lines.length) {
const l = lines[i];
if (/^\s*$/.test(l)) break;
if (/^\s*(`{3,}|~{3,})/.test(l)) break;
if (/^#{1,6}\s/.test(l)) break;
if (/^\s*>/.test(l)) break;
if (/^\s*[-*+]\s/.test(l)) break;
if (/^\s*\d+\.\s/.test(l)) break;
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(l)) break;
para.push(l);
i++;
}
tokens.push({ type: 'paragraph', text: para.join('\n') });
}
return tokens.map((tok) => renderToken(tok, env)).join('\n');
};
/**
* 表格分隔行判定
*/
const isTableSeparator = (line) => {
return /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(line) && /-/.test(line);
};
/**
* 渲染单个块级 token
*/
const renderToken = (tok, env) => {
switch (tok.type) {
case 'heading': {
const id = slugify(tok.text);
return `<h${tok.level} id="${id}">${renderInline(tok.text, env)}</h${tok.level}>`;
}
case 'paragraph':
return `<p>${renderInline(tok.text, env)}</p>`;
case 'hr':
return '<hr/>';
case 'quote':
return `<blockquote>${parseMarkdown(tok.content, env)}</blockquote>`;
case 'code':
return renderCode(tok.content, tok.lang, env);
case 'ul':
return `<ul>${tok.items.map((it) => renderListItem(it, env)).join('')}</ul>`;
case 'ol':
return `<ol>${tok.items.map((it) => `<li>${renderInline(it, env)}</li>`).join('')}</ol>`;
case 'table':
return renderTable(tok, env);
default:
return '';
}
};
/**
* 渲染列表项
*/
const renderListItem = (it, env) => {
if (it.task) {
const checked = it.checked ? ' checked' : '';
return `<li class="me-task-item"><input type="checkbox" disabled${checked}/> ${renderInline(it.text, env)}</li>`;
}
return `<li>${renderInline(it.text, env)}</li>`;
};
/**
* 渲染代码块
*/
const renderCode = (code, lang, env) => {
const langClass = lang ? ` class="language-${escapeHTML(lang)}"` : '';
if (env.highlight && typeof env.highlight === 'function' && lang) {
try {
const highlighted = env.highlight(code, lang);
if (typeof highlighted === 'string') {
return `<pre><code${langClass}>${highlighted}</code></pre>`;
}
} catch (e) {
console.error('MeEditor highlight error:', e);
}
}
return `<pre><code${langClass}>${escapeHTML(code)}</code></pre>`;
};
/**
* 渲染表格
*/
const renderTable = (tok, env) => {
const splitRow = (r) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/);
const headers = splitRow(tok.header);
let html = '<div class="me-table-wrap"><table><thead><tr>';
html += headers.map((h) => `<th>${renderInline(h, env)}</th>`).join('');
html += '</tr></thead><tbody>';
html += tok.rows.map((r) => {
const cells = splitRow(r);
return `<tr>${cells.map((c) => `<td>${renderInline(c, env)}</td>`).join('')}</tr>`;
}).join('');
html += '</tbody></table></div>';
return html;
};
/**
* 渲染内联元素
* 顺序:提取代码占位 -> escape -> 图片 -> 链接 -> 自动链接 -> 粗体 -> 斜体 -> 删除线 -> 还原代码 -> 换行
*/
const renderInline = (text, env) => {
if (!text) return '';
const codes = [];
let s = String(text);
// 1. 提取行内代码到占位符,避免被后续正则破坏
s = s.replace(/`+([^`]+?)`+/g, (m, c) => {
const idx = codes.length;
codes.push(c);
return `\u0000${idx}\u0000`;
});
// 2. HTML 转义(占位符中的零字符不被影响)
s = escapeHTML(s);
// 3. 图片 ![alt](url "title")
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+&quot;([^&]*)&quot;)?\)/g, (m, alt, url, title) => {
const u = safeUrl(url);
if (!u) return escapeHTML(m);
const t = title ? ` title="${title}"` : '';
return `<img src="${u}" alt="${alt}"${t} loading="lazy"/>`;
});
// 4. 链接 [text](url "title")
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+&quot;([^&]*)&quot;)?\)/g, (m, txt, url, title) => {
const u = safeUrl(url);
if (!u) return escapeHTML(m);
const t = title ? ` title="${title}"` : '';
return `<a href="${u}"${t} target="_blank" rel="noopener noreferrer">${txt}</a>`;
});
// 5. 自动链接 <url>
s = s.replace(/&lt;(https?:\/\/[^\s&]+)&gt;/g, (m, url) => {
const u = safeUrl(url);
if (!u) return m;
return `<a href="${u}" target="_blank" rel="noopener noreferrer">${url}</a>`;
});
// 6. 粗体 **text** / __text__
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
s = s.replace(/__([^_]+)__/g, '<strong>$1</strong>');
// 7. 斜体 *text* / _text_
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
s = s.replace(/(^|[^_])_([^_\n]+)_/g, '$1<em>$2</em>');
// 8. 删除线 ~~text~~
s = s.replace(/~~([^~]+)~~/g, '<del>$1</del>');
// 9. 还原行内代码
s = s.replace(/\u0000(\d+)\u0000/g, (m, idx) => `<code>${escapeHTML(codes[+idx])}</code>`);
// 10. 软换行
s = s.replace(/\n/g, '<br/>');
return s;
};
/**
* 生成标题锚点 id(支持中文)
*/
const slugify = (text) => {
return String(text)
.toLowerCase()
.replace(/[^\w\u4e00-\u9fa5\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
};
export { safeUrl, slugify };
export default parseMarkdown;