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

文章目录

    • 背景
    • 界面
    • [当前支持的 Markdown 语法](#当前支持的 Markdown 语法)
    • [不支持的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 -> 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
相关推荐
崔庆才丨静觅12 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606112 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了13 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅13 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅13 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅13 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment13 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅14 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊14 小时前
jwt介绍
前端
爱敲代码的小鱼14 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax