nodejs: 能在线编辑 Markdown 文档的 Web 服务程序,更多扩展功能

承上一篇:nodejs: 能在线编辑 Markdown 文档的 Web 服务程序

如果需要更多 Markdown 扩展(如表格、数学公式)等功能,怎样编写?

已经采用了移除服务端 mermaid 依赖的方案,现在想要为这个 Markdown 编辑器扩展表格、数学公式等功能,继续完善代码,添加这些常用的 Markdown 扩展能力,同时保持代码的简洁和可维护性。

实现思路

  1. 表格支持marked 本身已内置 GitHub 风格的表格解析,只需确保启用相关配置
  2. 数学公式支持 :集成 katexmathjax 来渲染 LaTeX 格式的数学公式
  3. 代码高亮 :添加 highlight.js 增强代码块的语法高亮效果
  4. 其他扩展:支持任务列表、脚注等常用 Markdown 扩展

完整修改后的代码

1. 安装新增依赖
安装代码高亮和数学公式渲染相关依赖

npm install marked-highlight highlight.js

2. 编写 app2.js(服务端增强 Markdown 解析)
javascript 复制代码
// Web Server 程序
const express = require('express');
const marked = require('marked');
const markedHighlight = require('marked-highlight');
const hljs = require('highlight.js');
const cors = require('cors');
const bodyParser = require('body-parser');
const fs = require('fs-extra');
const path = require('path');

// 初始化 Express 应用
const app = express();

// 配置静态文件目录
app.use(express.static(path.join(__dirname, 'public')));

// 配置 Marked 解析器,添加高亮、表格、公式等支持
// 配置代码高亮
const highlightOptions = {
  langPrefix: 'hljs language-',
  highlight(code, lang) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return hljs.highlight(code, { language: lang }).value;
      } catch (err) {
        console.error(`高亮 ${lang} 代码失败:`, err);
      }
    }
    // 自动检测语言
    return hljs.highlightAuto(code).value;
  }
};

// 创建带高亮的 renderer
const renderer = new marked.Renderer();

// 重写代码块渲染逻辑,识别 mermaid 代码块
renderer.code = (code, language) => {
  if (language === 'mermaid') {
    // 为 Mermaid 代码块生成容器(仅保留代码,渲染交给前端)
    return `<div class="mermaid">${code}</div>`;
  }
  // 其他代码块使用高亮渲染
  return `<pre><code class="hljs language-${language}">${
    highlightOptions.highlight(code, language)
  }</code></pre>`;
};

// 配置 marked 核心选项(启用所有扩展)
marked.setOptions({
  renderer: renderer,
  extensions: [
    // 启用表格扩展(GFM 已包含,但显式声明更清晰)
    {
      name: 'table',
      level: 'block',
      start: (src) => src.match(/^\|/)?.index
    },
    // 启用任务列表
    {
      name: 'tasklist',
      level: 'block',
      start: (src) => src.match(/^\s*-\s\[\s?x?\]\s/)?.index
    }
  ],
  gfm: true,                // 启用 GitHub 风格 Markdown
  breaks: true,             // 支持换行符
  tables: true,             // 启用表格
  tasklists: true,          // 启用任务列表
  footnotes: true,          // 启用脚注
  smartypants: true         // 启用智能标点
});

// 中间件配置
app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));

// 设置模板引擎
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

// 确保保存文件的目录存在
const DOCS_DIR = path.join(__dirname, 'docs');
fs.ensureDirSync(DOCS_DIR);

// 路由配置
// 首页 - 编辑器界面
app.get('/', (req, res) => {
  res.render('editor', { title: 'Markdown 在线编辑器 (支持表格/公式/Mermaid)' });
});

// 解析 Markdown 为 HTML (API)
app.post('/api/parse', (req, res) => {
  try {
    const { markdown } = req.body;
    if (!markdown) {
      return res.status(400).json({ error: 'Markdown 内容不能为空' });
    }
    // 解析 Markdown 为 HTML
    const html = marked.parse(markdown);
    res.json({ html });
  } catch (error) {
    res.status(500).json({ error: '解析 Markdown 失败: ' + error.message });
  }
});

// 保存文档 (API)
app.post('/api/save', (req, res) => {
  try {
    const { filename, content } = req.body;
    if (!filename || !content) {
      return res.status(400).json({ error: '文件名和内容不能为空' });
    }
    // 拼接文件路径
    const filePath = path.join(DOCS_DIR, `${filename}.md`);
    // 写入文件
    fs.writeFileSync(filePath, content, 'utf8');
    res.json({ success: true, message: '文件保存成功', filePath });
  } catch (error) {
    res.status(500).json({ error: '保存文件失败: ' + error.message });
  }
});

// 加载文档 (API)
app.get('/api/load/:filename', (req, res) => {
  try {
    const { filename } = req.params;
    const filePath = path.join(DOCS_DIR, `${filename}.md`);
    // 检查文件是否存在
    if (!fs.existsSync(filePath)) {
      return res.status(404).json({ error: '文件不存在' });
    }
    // 读取文件内容
    const content = fs.readFileSync(filePath, 'utf8');
    res.json({ success: true, content });
  } catch (error) {
    res.status(500).json({ error: '加载文件失败: ' + error.message });
  }
});

// 获取文档列表 (API)
app.get('/api/docs', (req, res) => {
  try {
    // 读取目录下所有 .md 文件
    const files = fs.readdirSync(DOCS_DIR)
      .filter(file => path.extname(file) === '.md')
      .map(file => ({
        name: path.basename(file, '.md'),
        path: file
      }));
    res.json({ success: true, docs: files });
  } catch (error) {
    res.status(500).json({ error: '获取文档列表失败: ' + error.message });
  }
});

// 启动服务器
//const PORT = process.env.PORT || 8000;
const PORT = 8000;
app.listen(PORT, () => {
  console.log(`服务器运行在: http://localhost:${PORT}`);
  console.log(`文档保存目录: ${DOCS_DIR}`);
});
3. 编辑 views/editor.ejs(前端添加公式渲染和样式)
html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title><%= title %></title>
  <!-- 1. 引入 Mermaid -->
  <script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
  <!-- 2. 引入 highlight.js 核心脚本(关键修复) -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
  <!-- 3. 引入代码高亮样式 -->
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js/styles/github-dark.min.css">
  <!-- 4. 引入 KaTeX 用于数学公式渲染 -->
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
  <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
  <style>
    * {
      box-sizing: border-box;
      margin: 0;
      padding: 0;
    }
    body {
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
      display: flex;
      flex-direction: column;
      height: 100vh;
    }
    .header {
      background: #2c3e50;
      color: white;
      padding: 1rem;
      text-align: center;
    }
    .container {
      display: flex;
      flex: 1;
      overflow: hidden;
    }
    .editor-container, .preview-container {
      flex: 1;
      padding: 1rem;
      overflow: hidden;
      border: 1px solid #ddd;
    }
    textarea {
      width: 100%;
      height: 100%;
      padding: 1rem;
      font-family: 'Consolas', 'Monaco', monospace;
      font-size: 14px;
      border: none;
      outline: none;
      resize: none;
    }
    .preview {
      width: 100%;
      height: 100%;
      overflow-y: auto;
      padding: 1rem;
      background: #f9f9f9;
      line-height: 1.6;
    }
    .controls {
      padding: 1rem;
      background: #f1f1f1;
      display: flex;
      gap: 1rem;
    }
    button {
      padding: 0.5rem 1rem;
      background: #3498db;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    }
    button:hover {
      background: #2980b9;
    }
    .file-input {
      padding: 0.5rem;
    }
    /* 表格样式优化 */
    table {
      border-collapse: collapse;
      width: 100%;
      margin: 1rem 0;
    }
    th, td {
      border: 1px solid #ddd;
      padding: 8px 12px;
      text-align: left;
    }
    th {
      background-color: #f2f2f2;
      font-weight: bold;
    }
    tr:nth-child(even) {
      background-color: #f9f9f9;
    }
    /* 任务列表样式 */
    .task-list-item {
      list-style-type: none;
      margin: 0.5rem 0;
    }
    /* 脚注样式 */
    .footnote-ref {
      vertical-align: super;
      font-size: 0.8em;
    }
    .footnotes {
      margin-top: 2rem;
      border-top: 1px solid #ddd;
      padding-top: 1rem;
      font-size: 0.9em;
    }
    /* 代码块样式优化 */
    pre {
      padding: 1rem;
      border-radius: 6px;
      margin: 1rem 0;
      overflow-x: auto;
    }
    code {
      font-family: 'Consolas', 'Monaco', monospace;
    }
    /* 公式样式 */
    .katex {
      font-size: 1.1em !important;
    }
  </style>
</head>
<body>
  <div class="header">
    <h1>Markdown 在线编辑器 (支持表格/公式/Mermaid)</h1>
  </div>
  
  <div class="controls">
    <input type="text" id="filename" placeholder="输入文件名(无需.md)" value="demo">
    <button id="saveBtn">保存文档</button>
    <button id="loadBtn">加载文档</button>
    <button id="refreshBtn">刷新预览</button>
  </div>
  
  <div class="container">
    <div class="editor-container">
      <textarea id="editor" placeholder="请输入 Markdown 内容...">

      </textarea>
    </div>
    <div class="preview-container">
      <div id="preview" class="preview"></div>
    </div>
  </div>

  <script>
    // 初始化 Mermaid
    mermaid.initialize({ 
      startOnLoad: false,
      theme: 'default'
    });
    
    // 获取 DOM 元素
    const editor = document.getElementById('editor');
    const preview = document.getElementById('preview');
    const saveBtn = document.getElementById('saveBtn');
    const loadBtn = document.getElementById('loadBtn');
    const refreshBtn = document.getElementById('refreshBtn');
    const filenameInput = document.getElementById('filename');

    // 解析 Markdown 并更新预览
    async function updatePreview() {
      try {
        const markdown = editor.value;
        const response = await fetch('/api/parse', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({ markdown })
        });
        
        const result = await response.json();
        if (result.error) {
          preview.innerHTML = `<div style="color: red;">解析错误: ${result.error}</div>`;
          return;
        }
        
        // 更新预览内容
        preview.innerHTML = result.html;
        
        // 渲染 Mermaid 图表
        mermaid.run({
          querySelector: '.mermaid',
          node: preview
        });
        
        // 渲染数学公式
        renderMathInElement(preview, {
          delimiters: [
            { left: '$$', right: '$$', display: true }, // 块级公式
            { left: '$', right: '$', display: false },   // 行内公式
            { left: '\\(', right: '\\)', display: false }, // LaTeX 行内
            { left: '\\[', right: '\\]', display: true }   // LaTeX 块级
          ],
          throwOnError: false,
          strict: false
        });
        
        // 高亮所有代码块(兜底)
        document.querySelectorAll('pre code').forEach((block) => {
          hljs.highlightElement(block);
        });
        
      } catch (error) {
        preview.innerHTML = `<div style="color: red;">预览更新失败: ${error.message}</div>`;
      }
    }

    // 保存文档
    async function saveDocument() {
      const filename = filenameInput.value.trim();
      const content = editor.value;
      
      if (!filename) {
        alert('请输入文件名');
        return;
      }
      
      try {
        const response = await fetch('/api/save', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({ filename, content })
        });
        
        const result = await response.json();
        if (result.error) {
          alert('保存失败: ' + result.error);
        } else {
          alert('保存成功!');
        }
      } catch (error) {
        alert('保存失败: ' + error.message);
      }
    }

    // 加载文档
    async function loadDocument() {
      const filename = filenameInput.value.trim();
      
      if (!filename) {
        alert('请输入要加载的文件名');
        return;
      }
      
      try {
        const response = await fetch(`/api/load/${filename}`);
        const result = await response.json();
        
        if (result.error) {
          alert('加载失败: ' + result.error);
        } else {
          editor.value = result.content;
          updatePreview();
          alert('加载成功!');
        }
      } catch (error) {
        alert('加载失败: ' + error.message);
      }
    }

    // 绑定事件
    refreshBtn.addEventListener('click', updatePreview);
    saveBtn.addEventListener('click', saveDocument);
    loadBtn.addEventListener('click', loadDocument);
    
    // 初始加载预览
    window.addEventListener('load', updatePreview);
    
    // 可选:实时预览(取消注释启用)
    // editor.addEventListener('input', debounce(updatePreview, 300));
    
    // 防抖函数(实时预览时避免频繁请求)
    function debounce(func, wait) {
      let timeout;
      return function(...args) {
        clearTimeout(timeout);
        timeout = setTimeout(() => func.apply(this, args), wait);
      };
    }
  </script>
</body>
</html>
4. 编写 docs/demo.md
markdown 复制代码
# Markdown 编辑器 (增强版)

## 1. 表格示例
| 姓名 | 年龄 | 职业 |
|------|------|------|
| 张三 | 25   | 工程师 |
| 李四 | 30   | 设计师 |
| 王五 | 28   | 产品经理 |

## 2. 数学公式示例
### 行内公式
欧拉公式:$e^{i\pi} + 1 = 0$

### 块级公式
$$
\int_{0}^{\infty} e^{-x^2} dx = \frac{\sqrt{\pi}}{2}
$$

二次方程求根公式:
$$
x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}
$$

## 3. 任务列表示例
- [x] 完成 Markdown 解析
- [x] 集成表格支持
- [ ] 优化公式渲染
- [ ] 测试兼容性

## 4. 代码高亮示例
```javascript
// JavaScript 代码示例
function calculateArea(radius) {
  const PI = Math.PI;
  return PI * radius * radius;
}
console.log(calculateArea(5)); // 78.53981633974483
python 复制代码
# Python 代码示例
def fibonacci(n):
    a, b = 0, 1
    for _ in range(n):
        yield a
        a, b = b, a + b

print(list(fibonacci(10)))

5. Mermaid 流程图示例

编辑
预览
开始
选择功能
Markdown 编辑
实时预览
支持表格/公式/代码高亮
保存文档
结束

复制代码
## 6. 脚注示例
这是一个带脚注的文本[^1],还有另一个脚注[^2]。

[^1]: 第一个脚注的内容
[^2]: 第二个脚注的内容,支持多行
    这是第二行内容
      这是第三行(缩进显示)

运行与使用说明

  1. 创建目录结构

    md-edit-app/
    ├── app.js 和 app2.js // 服务器主程序
    ├── views/ // 模板目录
    │ └── edit.ejs 和 editor.ejs // 编辑器页面
    ├── public/ // 静态资源目录
    │ └── css 和 js // 静态子目录
    └── docs/ // 文档保存目录 (自动创建)

所有的 *.min.js , 可用 curl -O https://... 下载,都放在 public/js/

所有的 *.css , 可用 curl -O https://... 下载,都放在 public/css/

  1. 重启服务器 :修改代码后,重启 Node.js 服务使配置生效

    node app2.js

  2. 功能测试

    • 表格:直接使用 GitHub 风格的表格语法即可渲染
    • 数学公式:使用 $公式$(行内)或 $$公式$$(块级)包裹 LaTeX 公式
    • 任务列表:使用 - [x](完成)或 - [ ](未完成)格式
    • 代码高亮:自动识别代码语言并高亮,支持上百种编程语言
    • 脚注:使用 [^标记] 定义脚注,文末添加 [^标记]: 内容

扩展更多功能的建议

  1. 图片上传 :可以集成 multer 实现本地图片上传,或对接第三方图库
  2. 目录生成:解析 Markdown 标题,自动生成导航目录
  3. 导出功能 :添加导出为 HTML/PDF 的功能(可使用 puppeteer
  4. 自定义样式:允许用户切换不同的预览主题(如浅色/深色模式)
  5. 自动保存:添加 localStorage 或定时保存功能,防止内容丢失

总结

  1. 核心扩展 :通过启用 marked 的内置配置支持表格、任务列表、脚注;集成 katex 实现数学公式渲染;使用 highlight.js 增强代码高亮。
  2. 实现方式:服务端负责 Markdown 解析和扩展支持,前端负责公式、Mermaid 的渲染和样式美化。
  3. 使用要点:数学公式使用 LaTeX 语法,表格使用 GitHub 风格语法,所有扩展功能无需额外配置即可直接使用。

现在你的编辑器已经具备了完整的 Markdown 扩展能力,涵盖表格、数学公式、代码高亮、任务列表、脚注等常用功能,完全满足日常编辑需求。

相关推荐
程序员林北北1 小时前
【前端进阶之旅】一种新的数据格式:TOON
前端·javascript·vue.js·react.js·typescript·json
木斯佳1 小时前
前端八股文面经大全:2026-01-23快手AI应用方向前端实习一面面经深度解析
前端·人工智能·状态模式
容沁风2 小时前
react路由Cannot GET错误
前端·react.js·前端框架·sharp7
少云清2 小时前
【UI自动化测试】6_web自动化测试 _浏览器操作
前端·web自动化测试
globaldomain2 小时前
立海世纪:.com和.net域名哪个更适合你的网站
大数据·前端·人工智能·新媒体运营·国外域名·域名注册
phltxy3 小时前
Vue Router:从入门到实战
前端·javascript·vue.js
Zhencode3 小时前
Vue3核心运行时之runtime-core
前端·javascript·vue.js
木斯佳3 小时前
前端八股文面经大全:腾讯WXG技术架构前端面试(2025-11-19)·面经深度解析
前端·面试·架构
感性的程序员小王3 小时前
HTTPS页面请求HTTP接口失败?一文讲透Mixed Content
前端·后端