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

yunnysunny / bookforge / 20639889242

01 Jan 2026 02:03PM UTC coverage: 55.087% (-36.6%) from 91.667%
20639889242

push

github

web-flow
feat: add notion support

27 of 51 branches covered (52.94%)

Branch coverage included in aggregate %.

75 of 221 new or added lines in 12 files covered. (33.94%)

195 of 352 relevant lines covered (55.4%)

23.5 hits per line

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

61.04
/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 { pathToFileURL } from 'url';
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
  }
47

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

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

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

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

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

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

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

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

103
    return headings;
45✔
104
  }
105

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

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

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

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

135
  /**
136
   * 将 markdown 转换为 HTML
137
   */
138
  async toHtml(content: string, options: ToHTMLOptions): Promise<string> {
139
    const html = await this.marked.parse(content, {
51✔
140
      async: true,
141
      walkTokens: async (token: Token) => {
142
        if (token.type === 'image') {
210✔
143
          const src = token.href;
×
144
          if (
×
145
            !src
146
            || src.startsWith('http')
147
            || src.startsWith('data:image/')
148
            || src.startsWith('blob:')
149
            || src.startsWith('//')
150
          ) {
151
            return;
×
152
          }
NEW
153
          const imageToPath = await this.copyResource(src, options);
×
NEW
154
          if (this.env === 'pdf') {
×
NEW
155
            const buffer = await readFile(imageToPath, 'base64');
×
NEW
156
            const ext = extname(imageToPath).slice(1);
×
NEW
157
            token.href = `data:image/${ext};base64,${buffer}`;
×
158
          }
159
        } else if (token.type === 'link') {
210✔
NEW
160
          const href = token.href;
×
NEW
161
          if (href.startsWith('http') || href.startsWith('https')) {
×
NEW
162
            return;
×
163
          }
NEW
164
          if (!isMarkdownFile(href)) {
×
NEW
165
            await this.copyResource(href, options);
×
NEW
166
            return;
×
167
          }
NEW
168
          const path = dirname(href);
×
NEW
169
          const filename = basename(href, extname(href));
×
NEW
170
          const link = `${path}/${filename}.html`;
×
NEW
171
          token.href = link;
×
172
        }
173
      },
174
    });
175
    return html;
51✔
176
  }
177
}
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