markdown文档解析

markdown-it

依赖

js 复制代码
"markdown-it": "^14.1.0",
"@types/markdown-it": "^14.1.2",

/* 瞄点 */
"markdown-it-anchor": "^9.2.0",
"markdown-it-toc-done-right": "^4.2.0",
js 复制代码
/* 代码高亮 */
"github-markdown-css": "^5.8.1",
"highlight.js": "^11.11.1",

初始化

js 复制代码
const md = new MarkdownIt({
    html: true, // 允许 HTML 标签
    highlight: (code, lang) => {
      // 代码高亮处理
      if (lang && hljs.getLanguage(lang)) {
        try {
          return hljs.highlight(code, { language: lang }).value
        } catch (_) {}
      }
      return ''
    },
})

md.use(markdownItAnchor, {
    permalink: markdownItAnchor.permalink.linkInsideHeader({
      symbol: '',
      placement: 'after',
      class: 'header-anchor',
    }),
    }).use(markdownItTocDoneRight, {
    containerClass: 'toc-container', // TOC 容器的 CSS 类名
    includeLevel: [1, 2, 3], // 包含 h1~h3 级标题[5,10](@ref)
})

读取文件

js 复制代码
const loadRemoteMd = async () => {
    if (!props.content) return
    try {
      const response = await fetch(
        `${import.meta.env.VITE_APP_BASE_API}${props.content}`
      )
      const text = await response.text()
      // 渲染带 TOC 的 HTML
      const htmlWithToc = md.render('[TOC]\n' + text)

      const { bodyHtml: body } = extractToc(htmlWithToc)
      bodyHtml.value = body
    } catch (error) {
      message.error('加载失败')
    }
}

解析html

js 复制代码
const extractToc = (html: string) => {
    // 用 DOM 解析更安全
    const div = document.createElement('div')
    div.innerHTML = html
    const toc = div.querySelector('.toc-container')
    const tocContent = toc ? toc.outerHTML : ''
    if (toc) toc.remove()
    stepItems.value = extractStepsFromMarkdown(tocContent)
    return { bodyHtml: div.innerHTML }
}

// 解析瞄点,生成stepItems
const extractStepsFromMarkdown = (mdText: string) => {
    // 匹配 # 一级标题
    let match
    const result = []
    const regex = /<a\b[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/g
    while ((match = regex.exec(mdText)) !== null) {
      result.push({ href: match[1], title: match[2] })
    }
    return result
}

自定义瞄点

html 复制代码
<template>
  <div class="markdown-steps">
    <ul>
      <li
        v-for="(item, index) in stepItems"
        :key="item.href"
        @click="handleStepClick(index)"
      >
        <div>
          <span
            class="step-icon"
            :class="{ 'step-icon-active': selectedId === index }"
          ></span>
          <a
            :href="item.href"
            class="step-title"
            :class="{ 'step-title-active': selectedId === index }"
            >{{ item.title }}</a
          >
        </div>
        <div
          v-if="stepItems[stepItems.length - 1] !== item"
          class="step-line"
        ></div>
      </li>
    </ul>
  </div>
  <div
    class="markdown-body"
    v-html="bodyHtml"
  ></div>
</template>

点击瞄点

js 复制代码
const stepItems = ref<{ href: string; title: string }[]>([])
const bodyHtml = ref('')

const selectedId = ref(0)
const handleStepClick = (index: number) => {
	selectedId.value = index	
}

监听瞄点

js 复制代码
/* 页面滚动时 */
 // 监听锚点
const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          const activeId = entry.target.id
          // 根据锚点ID找到对应的step索引
          const stepIndex = stepItems.value.findIndex(
            (item) => item.href === `#${activeId}`
          )

          if (stepIndex !== -1) {
            selectedId.value = stepIndex
          }
        }
      })
    },
    {
      threshold: 0.5, // 元素50%进入视口时触发
      rootMargin: '0px 0px -60% 0px', // 调整检测区域,更精确地检测视口中部
    }
)

// 观察所有标题元素
const observeHeadings = () => {
    const markdownBody = document.querySelector('.markdown-body')
    if (!markdownBody) return

    const headings = markdownBody.querySelectorAll('h1, h2, h3')

    headings.forEach((heading) => {
      observer.observe(heading)
    })
}

onMounted(() => {
    loadRemoteMd().then(() => {
      // 等待DOM渲染完成后观察标题
      setTimeout(() => {
        observeHeadings()
        // 设置初始选中项
        if (stepItems.value.length > 0) {
          selectedId.value = 0
        }
      }, 200)
    })
})

// 组件卸载时清理observer
onUnmounted(() => {
    observer.disconnect()
})

样式

less 复制代码
<style scoped lang="less">
  .markdown-steps {
    position: fixed;
    top: 80px;
    right: 100px;
    z-index: 100;
    .step-icon {
      width: 10px;
      height: 10px;
      border-radius: 50%;
      display: inline-block;
      background-color: rgba(0, 0, 0, 0.3);
      margin-right: 10px;
    }
    .step-line {
      width: 1px;
      height: 30px;
      position: relative;
      left: 5px;
      background-color: rgba(0, 0, 0, 0.3);
    }
    .step-title {
      color: rgba(0, 0, 0, 0.7);
      font-size: 14px;
      transition:
        background 0.2s,
        color 0.2s;
    }
    .step-title-active {
      color: var(--color-primary);
    }
    .step-icon-active {
      background-color: var(--color-primary);
    }
  }

  .markdown-body {
    padding: 20px;
    border-radius: 10px;
    width: 80%;
    position: relative;
    left: 0;
  }
</style>
css 复制代码
/* 问题 */
代码块的背景色,需要到global.css中定义?
/* 代码块的背景色 */
.markdown-body pre {
  background: rgba(17, 17, 17, 0.9) !important;
  padding: 8px;
  border-radius: 4px;
  color: rgba(255, 255, 255, 0.9) !important;
}

安全

措施 防护目标 实现难度
禁用 HTML 阻断脚本注入 ⭐⭐
链接属性控制 防止钓鱼与 opener 攻击 ⭐⭐
HTML 净化 (DOMPurify) 过滤危险标签/属性 ⭐⭐⭐
CSP 策略 限制外部资源加载 ⭐⭐⭐⭐
输入预处理 早期拦截恶意内容 ⭐⭐
相关推荐
亮子AI16 天前
【NestJS】为什么return不返回客户端?
前端·javascript·git·nestjs
小p17 天前
nestjs学习2:利用typescript改写express服务
nestjs
Eric_见嘉23 天前
NestJS 🧑‍🍳 厨子必修课(九):API 文档 Swagger
前端·后端·nestjs
XiaoYu20021 个月前
第3章 Nest.js拦截器
前端·ai编程·nestjs
XiaoYu20021 个月前
第2章 Nest.js入门
前端·ai编程·nestjs
实习生小黄1 个月前
NestJS 调试方案
后端·nestjs
当时只道寻常1 个月前
NestJS 如何配置环境变量
nestjs
濮水大叔2 个月前
VonaJS是如何做到文件级别精确HMR(热更新)的?
typescript·node.js·nestjs
ovensi2 个月前
告别笨重的 ELK,拥抱轻量级 PLG:NestJS 日志监控实战指南
nestjs