• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

yunnysunny / bookforge / 20906579525

12 Jan 2026 02:51AM UTC coverage: 56.206% (+1.2%) from 54.965%
20906579525

push

github

yunnysunny
chore: fix biome format error

33 of 57 branches covered (57.89%)

Branch coverage included in aggregate %.

0 of 1 new or added line in 1 file covered. (0.0%)

14 existing lines in 2 files now uncovered.

207 of 370 relevant lines covered (55.95%)

24.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

61.8
/src/core/markdown-parser.ts
1
// Markdown 解析器
2

3
import { copyFile } from 'fs/promises';
4
import { marked, type Token, type Tokens, Marked } from 'marked';
5
import { basename, dirname, extname, join } from 'path';
6
import type { Env, Heading, MarkdownFile } from '../types/index.js';
7
import {
8
  generateIdFromText,
9
  isMarkdownFile,
10
  mkdirAsync,
11
  readFile,
12
} from '../utils';
13
import { gitbookExtension } from './marked-plugins/gitbook.plugin.js';
14
import { katexExtension } from './marked-plugins/katex.plugin.js';
15

16
const renderer = new marked.Renderer();
12✔
17
renderer.heading = ({ tokens, depth }: Tokens.Heading) => {
12✔
18
  const token = tokens[0] as unknown as Heading;
45✔
19
  token.id = generateIdFromText(token.text);
45✔
20
  return `<h${depth} id="${token.id}">
45✔
21
  <a href="#${token.id}" class="anchor"></a>
22
  ${token.text}
23
</h${depth}>`;
24
};
25

26
export interface MarkdownParserOptions {
27
  env: Env;
28
}
29
export interface ToHTMLOptions {
30
  contentPath: string;
31
  destDir: string;
32
}
33
export class MarkdownParser {
34
  private marked: Marked;
35
  private readonly env: Env;
36

37
  constructor(options: MarkdownParserOptions) {
38
    this.env = options.env;
111✔
39
    this.marked = new Marked();
111✔
40
    this.marked.setOptions({
111✔
41
      gfm: true,
42
      breaks: true,
43
      renderer,
44
    });
45
    this.marked.use(gitbookExtension);
111✔
46
    this.marked.use(katexExtension);
111✔
47
  }
48

49
  /**
50
   * 解析 markdown 文件
51
   */
52
  async parseFile(filePath: string): Promise<MarkdownFile> {
53
    const content = await readFile(filePath);
39✔
54
    const headings = this.extractHeadings(content);
36✔
55
    const title = this.extractTitle(content, headings);
36✔
56

57
    return {
36✔
58
      path: filePath,
59
      title,
60
      content,
61
      headings,
62
    };
63
  }
64

65
  /**
66
   * 提取标题结构
67
   */
68
  private extractHeadings(content: string): Heading[] {
69
    const normalizedStr = content.replace(/\r\n?/g, '\n');
45✔
70
    const lines = normalizedStr.split('\n');
45✔
71
    // logger.info('normalized', normalizedStr);
72
    const headings: Heading[] = [];
45✔
73
    const stack: Heading[] = [];
45✔
74

75
    for (const line of lines) {
45✔
76
      const match = line.trim().match(/^(#{1,6})\s+(.+)$/);
579✔
77
      if (match) {
579✔
78
        const level = match[1].length;
144✔
79
        const text = match[2].trim();
144✔
80
        const id = generateIdFromText(text);
144✔
81

82
        const heading: Heading = {
144✔
83
          level,
84
          text,
85
          id,
86
          children: [],
87
        };
88

89
        // 找到合适的父级标题
90
        while (stack.length > 0 && stack[stack.length - 1].level >= level) {
144✔
91
          stack.pop();
57✔
92
        }
93

94
        if (stack.length === 0) {
144✔
95
          headings.push(heading);
42✔
96
        } else {
97
          stack[stack.length - 1].children.push(heading);
102✔
98
        }
99

100
        stack.push(heading);
144✔
101
      }
102
    }
103

104
    return headings;
45✔
105
  }
106

107
  /**
108
   * 提取文档标题
109
   */
110
  private extractTitle(content: string, headings: Heading[]): string {
111
    // 优先使用第一个一级标题
112
    const firstH1 = headings.find((h) => h.level === 1);
36✔
113
    if (firstH1) {
36✔
114
      return firstH1.text;
33✔
115
    }
116

117
    // 如果没有一级标题,使用第一个标题
118
    if (headings.length > 0) {
3✔
119
      return headings[0].text;
×
120
    }
121

122
    // 如果没有任何标题,使用文件名
123
    return 'Untitled';
3✔
124
  }
125

126
  private async copyResource(src: string, options: ToHTMLOptions) {
127
    const decodedSrc = decodeURIComponent(src);
×
128
    const imageFromPath = join(dirname(options.contentPath), decodedSrc);
×
129
    const imageToPath = join(options.destDir, decodedSrc);
×
130
    const imageToDir = dirname(imageToPath);
×
131
    await mkdirAsync(imageToDir);
×
132
    await copyFile(imageFromPath, imageToPath);
×
133
    return imageToPath;
×
134
  }
135

136
  /**
137
   * 将 markdown 转换为 HTML
138
   */
139
  async toHtml(content: string, options: ToHTMLOptions): Promise<string> {
140
    const html = await this.marked.parse(content, {
51✔
141
      async: true,
142
      walkTokens: async (token: Token) => {
143
        if (token.type === 'image') {
210✔
144
          const src = token.href;
×
145
          if (
×
146
            !src
147
            || src.startsWith('http')
148
            || src.startsWith('data:image/')
149
            || src.startsWith('blob:')
150
            || src.startsWith('//')
151
          ) {
152
            return;
×
153
          }
154
          const imageToPath = await this.copyResource(src, options);
×
155
          if (this.env === 'pdf') {
×
156
            const buffer = await readFile(imageToPath, 'base64');
×
157
            const ext = extname(imageToPath).slice(1);
×
158
            token.href = `data:image/${ext};base64,${buffer}`;
×
159
          }
160
        } else if (token.type === 'link') {
210✔
161
          const href = token.href;
×
162
          if (href.startsWith('http') || href.startsWith('https')) {
×
163
            return;
×
164
          }
165
          if (!isMarkdownFile(href)) {
×
166
            await this.copyResource(href, options);
×
167
            return;
×
168
          }
169
          const path = dirname(href);
×
170
          const filename = basename(href, extname(href));
×
171
          const link = `${path}/${filename}.html`;
×
172
          token.href = link;
×
173
        } else if (token.type === 'code') {
210✔
174
          // 处理 mermaid 代码块
175
          const codeToken = token as Tokens.Code;
3✔
176
          if (codeToken.lang === 'mermaid') {
3✔
177
            const diagram = codeToken.text;
×
178
            token.type = 'html';
×
179
            token.text = `<pre class="mermaid">
×
180
${diagram}
181
</pre>`;
182
          } else if (!codeToken.lang) {
3✔
183
            // 如果没有指定语言,设置为 plain text
UNCOV
184
            codeToken.lang = 'plain';
×
185
          }
186
        }
187
      },
188
    });
189
    return html;
51✔
190
  }
191
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc