【前端】ikun-markdown: 纯js实现markdown到富文本html的转换库

发布于:2025-07-10 ⋅ 阅读:(15) ⋅ 点赞:(0)

背景

出于兴趣,我使用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 -> AST
  parse(md) {
    if (!md) return []
    const lines = md.split(/\r?\n/)
    const ast = []
    let i = 0
    // 嵌套列表解析辅助函数
    function parseList(start, indent, parentOrdered) {
      const items = []
      let idx = start
      while (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 = false
        if (match) {
          ordered = true
        } else {
          match = line.match(/^(\s*)[-\*\+] \[( |x)\] (.*)$/i)
          if (match) {
            task = true
            checked = /\[x\]/i.test(line)
          } else {
            match = line.match(/^(\s*)[-\*\+]\s+(.*)$/)
            if (!match) break
          }
        }
        const currIndent = match[1].length
        if (currIndent < indent) break
        if (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.nextIdx
          continue
        }
        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 = parentOrdered
      if (typeof ordered === 'undefined' && items.length > 0) ordered = items[0].ordered
      // 清理 ordered 字段
      for (const item of items) delete item.ordered
      return { 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 += 2
        while (i < lines.length) {
          if (/^\s*$/.test(lines[i])) { i++; continue; }
          if (!/^\|(.+)\|$/.test(lines[i])) break
          rows.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 = nextIdx
        continue
      }
      // 任务列表(不支持嵌套,原逻辑保留)
      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 -> HTML
  render(md) {
    if (!md) return ''
    const ast = typeof md === 'string' ? this.parse(md) : md
    if (!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}>`
          break
        case 'paragraph':
          html += `<p>${this.inline(node.content)}</p>`
          break
        case 'codeblock':
          html += `<pre><code>${this.escape(node.content)}</code></pre>`
          break
        case '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/>`
          break
        case 'blockquote':
          html += `<blockquote>${this.inline(node.content).replace(/\n/g, '<br/>')}</blockquote>`
          break
        case 'newline':
          html += '<br/>'
          break
        default:
          break
      }
    }
    return html
  },
  // 行内语法处理
  inline(text) {
    if (!text) return ''
    return text
      // 图片 ![alt](url)
      .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 => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[t]))
  }
}
// // 兼容 ES Module 和 CommonJS
// if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
//   module.exports = { default: nimd }
// }

export default nimd


网站公告

今日签到

点亮在社区的每一天
去签到