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 策略 限制外部资源加载 ⭐⭐⭐⭐
输入预处理 早期拦截恶意内容 ⭐⭐
相关推荐
outsider_友人A1 天前
前端也想写后端(2)ORM框架以及数据库关系和操作
node.js·orm·nestjs
outsider_友人A9 天前
前端也想写后端(1)初识 Nest.js
后端·nestjs·全栈
牧码岛11 天前
服务端之nestJS常用异常类及封装自定义响应模块
node.js·nestjs
GDAL13 天前
NestJS CLI入门
nestjs
林太白13 天前
NestJS-聊天数据库
前端·后端·nestjs
林太白13 天前
NestJS-websockets和socket.io
前端·后端·nestjs
林太白13 天前
NestJS-群组模块
前端·javascript·nestjs
林太白13 天前
NestJS-聊天模块
前端·后端·nestjs