【前端】ikun-markdown: 纯js实现markdown到富文本html的转换库
文章目录
- 背景
- 界面
- 当前支持的 Markdown 语法
- 不支持的Markdown 语法
- 代码节选
背景
出于兴趣,我使用js实现了一个 markdown语法 -> ast语法树 -> html富文本的库, 其速度应当慢于正则实现的同类js库, 但是语法扩展性更好, 嵌套列表处理起来更方便.
界面
基于此js实现vue组件了, 可在uniapp中使用,支持微信小程序和h5.
访问地址: https://ext.dcloud.net.cn/plugin?id=24280#detail
当前支持的 Markdown 语法
- 标题(# ~ ######)
- 粗体(加粗)
- 斜体(斜体)
- 删除线(
删除线) - 行内代码(
code
) - 代码块(
code
) - 链接(文本)
- 自动链接(http/https 链接自动转为
<a>
) - 有序列表(1. 2. 3.)
- 无序列表(- * +)
- 嵌套的无序列表(- * +, 四格缩进)
- 表格(| head | head | …)
- 引用块(> 引用内容,多行合并)
- 段落、换行
- 图片
不支持的Markdown 语法
- ~内嵌 HTML~
- 脚注、目录、注释等扩展语法
- ~GFM 扩展:@提及、emoji、自动任务列表渲染等~
- 多级嵌套列表/引用的递归渲染
- 代码块高亮(需配合 highlight.js 等)
- 表格对齐(:—:)等高级表格特性
- 数学公式
代码节选
// nimd.js - 轻量级 markdown AST解析与渲染库
const nimd = {// 1. Markdown -> ASTparse(md) {if (!md) return []const lines = md.split(/\r?\n/)const ast = []let i = 0// 嵌套列表解析辅助函数function parseList(start, indent, parentOrdered) {const items = []let idx = startwhile (idx < lines.length) {let line = lines[idx]if (/^\s*$/.test(line)) { idx++; continue; }// 动态判断当前行是有序、无序还是任务列表let match = line.match(/^(\s*)(\d+)\.\s+(.*)$/)let ordered = false, task = false, checked = falseif (match) {ordered = true} else {match = line.match(/^(\s*)[-\*\+] \[( |x)\] (.*)$/i)if (match) {task = truechecked = /\[x\]/i.test(line)} else {match = line.match(/^(\s*)[-\*\+]\s+(.*)$/)if (!match) break}}const currIndent = match[1].lengthif (currIndent < indent) breakif (currIndent > indent) {// 递归收集所有同级缩进的子项,类型动态判断const sublist = parseList(idx, currIndent, undefined)if (items.length > 0) {if (!items[items.length - 1].children) items[items.length - 1].children = []items[items.length - 1].children = items[items.length - 1].children.concat(sublist.items)}idx = sublist.nextIdxcontinue}if (task) {items.push({ type: 'task_item', content: match[3], checked, children: [] })} else {items.push({ type: 'list_item', content: match[ordered ? 3 : 2], children: [], ordered })}idx++}// 返回时,主列表类型以 parentOrdered 为准,否则以第一个元素类型为准let ordered = parentOrderedif (typeof ordered === 'undefined' && items.length > 0) ordered = items[0].ordered// 清理 ordered 字段for (const item of items) delete item.orderedreturn { items, nextIdx: idx, ordered }}while (i < lines.length) {let line = lines[i]// 表格(优先判断,表头和分隔符之间不能有空行)if (/^\|(.+)\|$/.test(line) &&i + 1 < lines.length &&/^\|([ \-:|]+)\|$/.test(lines[i + 1])) {const header = line.replace(/^\||\|$/g, '').split('|').map(s => s.trim())const aligns = lines[i + 1].replace(/^\||\|$/g, '').split('|').map(s => s.trim())let rows = []i += 2while (i < lines.length) {if (/^\s*$/.test(lines[i])) { i++; continue; }if (!/^\|(.+)\|$/.test(lines[i])) breakrows.push(lines[i].replace(/^\||\|$/g, '').split('|').map(s => s.trim()))i++}ast.push({ type: 'table', header, aligns, rows })continue}// blockquote 引用块if (/^>\s?(.*)/.test(line)) {let quoteLines = []while (i < lines.length && /^>\s?(.*)/.test(lines[i])) {quoteLines.push(lines[i].replace(/^>\s?/, ''))i++}ast.push({ type: 'blockquote', content: quoteLines.join('\n') })continue}// 空行if (/^\s*$/.test(line)) {ast.push({ type: 'newline' })i++continue}// 标题let m = line.match(/^(#{1,6})\s+(.*)$/)if (m) {ast.push({ type: 'heading', level: m[1].length, content: m[2] })i++continue}// 代码块if (/^```/.test(line)) {let code = []let lang = line.replace(/^```/, '').trim()i++while (i < lines.length && !/^```/.test(lines[i])) {code.push(lines[i])i++}i++ast.push({ type: 'codeblock', lang, content: code.join('\n') })continue}// 嵌套列表(自动类型判断)if (/^\s*([-\*\+]|\d+\.)\s+/.test(line)) {const { items, nextIdx, ordered } = parseList(i, line.match(/^(\s*)/)[1].length, undefined)ast.push({ type: 'list', ordered, items })i = nextIdxcontinue}// 任务列表(不支持嵌套,原逻辑保留)m = line.match(/^\s*[-\*\+] \[( |x)\] (.*)$/i)if (m) {let items = []while (i < lines.length && /^\s*[-\*\+] \[( |x)\] /.test(lines[i])) {let checked = /\[x\]/i.test(lines[i])items.push({ type: 'task_item', checked, content: lines[i].replace(/^\s*[-\*\+] \[( |x)\] /, '') })i++}ast.push({ type: 'task_list', items })continue}// 普通段落(最后判断)ast.push({ type: 'paragraph', content: line })i++}return ast},// 2. AST -> HTMLrender(md) {if (!md) return ''const ast = typeof md === 'string' ? this.parse(md) : mdif (!Array.isArray(ast)) return ''// 嵌套列表渲染辅助函数function renderList(items, ordered, ctx) {let html = ordered ? '<ol>' : '<ul>'for (const item of items) {if (item.type === 'task_item') {html += `<li><input type="checkbox" disabled${item.checked ? ' checked' : ''}> ${ctx.inline(item.content)}`if (item.children && item.children.length) {html += renderList(item.children, false, ctx)}html += '</li>'} else {html += '<li>' + ctx.inline(item.content)if (item.children && item.children.length) {html += renderList(item.children, ordered, ctx)}html += '</li>'}}html += ordered ? '</ol>' : '</ul>'return html}let html = ''for (const node of ast) {switch (node.type) {case 'heading':html += `<h${node.level}>${this.inline(node.content)}</h${node.level}>`breakcase 'paragraph':html += `<p>${this.inline(node.content)}</p>`breakcase 'codeblock':html += `<pre><code>${this.escape(node.content)}</code></pre>`breakcase 'list':html += renderList(node.items, node.ordered, this)break// 嵌套任务列表已合并到 list 渲染case 'table':const tableStyle = 'border-collapse:collapse;border:1px solid #e5e5e5;width:100%;margin:1em 0;'const thStyle = 'border:1px solid #e5e5e5;padding:8px 12px;text-align:left;background:#fafafa;'const tdStyle = 'border:1px solid #e5e5e5;padding:8px 12px;text-align:left;'html += `<table style="${tableStyle}"><thead><tr>`for (const h of node.header) html += `<th style="${thStyle}">${this.inline(h)}</th>`html += '</tr></thead><tbody>'for (const row of node.rows) {html += '<tr>'for (const c of row) html += `<td style="${tdStyle}">${this.inline(c)}</td>`html += '</tr>'}html += `</tbody></table><br/>`breakcase 'blockquote':html += `<blockquote>${this.inline(node.content).replace(/\n/g, '<br/>')}</blockquote>`breakcase 'newline':html += '<br/>'breakdefault:break}}return html},// 行内语法处理inline(text) {if (!text) return ''return text// 图片 .replace(/!\[(.*?)\]\((.*?)\)/g, '<img src="$2" alt="$1">')// 删除线.replace(/~~(.*?)~~/g, '<del>$1</del>')// 粗体.replace(/\*\*(.*?)\*\*/g, '<b>$1</b>')// 斜体.replace(/\*(.*?)\*/g, '<i>$1</i>')// 行内代码.replace(/`([^`]+)`/g, '<code>$1</code>')// 先处理 [text](url) 链接.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank">$1</a>')// 再处理自动链接(排除已在 a 标签内的).replace(/(^|[^\">])(https?:\/\/[^\s<]+)/g, '$1<a href="$2" target="_blank">$2</a>')},// 代码块转义escape(str) {return str.replace(/[&<>]/g, t => ({'&':'&','<':'<','>':'>'}[t]))}
}
// // 兼容 ES Module 和 CommonJS
// if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
// module.exports = { default: nimd }
// }export default nimd