Initial commit: MetonaEditor v0.1.0
This commit is contained in:
+322
@@ -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>
|
||||
* - 图片 
|
||||
* - 转义字符 \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. 图片 
|
||||
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^&]*)")?\)/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+"([^&]*)")?\)/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(/<(https?:\/\/[^\s&]+)>/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;
|
||||
Reference in New Issue
Block a user