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

yunnysunny / bookforge / 22036907291

15 Feb 2026 01:57PM UTC coverage: 70.995% (+14.8%) from 56.206%
22036907291

push

github

web-flow
feat: refactor ui flow (#2)

* feat: add top button
* feat: add top navigate bar
* feat: use zstatic.net for cdn provider
* feat: add gitbook stepper and tabs support
* feat: add notion db support
* feat: add gitbook stepper and tabs support

63 of 81 branches covered (77.78%)

Branch coverage included in aggregate %.

98 of 169 new or added lines in 13 files covered. (57.99%)

4 existing lines in 3 files now uncovered.

358 of 512 relevant lines covered (69.92%)

27.05 hits per line

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

58.82
/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 { generateIdFromText, isMarkdownFile, mkdirAsync, readFile } from '../utils';
8
import { gitbookExtension } from './marked-plugins/gitbook.plugin.js';
9
import { katexExtension } from './marked-plugins/katex.plugin.js';
10
import { gitbookTabExtension } from './marked-plugins/gitbook-tab.plugin.js';
11
import { gitbookStepperExtension } from './marked-plugins/gitbook-stepper.plugin.js';
12
import {
13
  gitbookIncludeExtension,
14
  type GitbookIncludeToken,
15
  IncludeTokenType,
16
} from './marked-plugins/gitbook-include.plugin.js';
17

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

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

39
  constructor(options: MarkdownParserOptions) {
40
    this.env = options.env;
117✔
41
    this.marked = new Marked();
117✔
42
    this.marked.setOptions({
117✔
43
      gfm: true,
44
      breaks: true,
45
      renderer,
46
    });
47
    this.marked.use(gitbookExtension);
117✔
48
    this.marked.use(gitbookTabExtension);
117✔
49
    this.marked.use(gitbookStepperExtension);
117✔
50
    this.marked.use(katexExtension);
117✔
51
    this.marked.use(gitbookIncludeExtension);
117✔
52
  }
53

54
  /**
55
   * 解析 markdown 文件
56
   */
57
  async parseFile(filePath: string): Promise<MarkdownFile> {
58
    const content = await readFile(filePath);
48✔
59
    const headings = this.extractHeadings(content);
45✔
60
    const title = this.extractTitle(content, headings);
45✔
61

62
    return {
45✔
63
      path: filePath,
64
      title,
65
      content,
66
      headings,
67
    };
68
  }
69

70
  /**
71
   * 提取标题结构
72
   */
73
  private extractHeadings(content: string): Heading[] {
74
    const normalizedStr = content.replace(/\r\n?/g, '\n');
54✔
75
    const lines = normalizedStr.split('\n');
54✔
76
    // logger.info('normalized', normalizedStr);
77
    const headings: Heading[] = [];
54✔
78
    const stack: Heading[] = [];
54✔
79

80
    for (const line of lines) {
54✔
81
      const match = line.trim().match(/^(#{1,6})\s+(.+)$/);
858✔
82
      if (match) {
858✔
83
        const level = match[1].length;
153✔
84
        const text = match[2].trim();
153✔
85
        const id = generateIdFromText(text);
153✔
86

87
        const heading: Heading = {
153✔
88
          level,
89
          text,
90
          id,
91
          children: [],
92
        };
93

94
        // 找到合适的父级标题
95
        while (stack.length > 0 && stack[stack.length - 1].level >= level) {
153✔
96
          stack.pop();
57✔
97
        }
98

99
        if (stack.length === 0) {
153✔
100
          headings.push(heading);
51✔
101
        } else {
102
          stack[stack.length - 1].children.push(heading);
102✔
103
        }
104

105
        stack.push(heading);
153✔
106
      }
107
    }
108

109
    return headings;
54✔
110
  }
111

112
  /**
113
   * 提取文档标题
114
   */
115
  private extractTitle(content: string, headings: Heading[]): string {
116
    // 优先使用第一个一级标题
117
    const firstH1 = headings.find((h) => h.level === 1);
45✔
118
    if (firstH1) {
45✔
119
      return firstH1.text;
42✔
120
    }
121

122
    // 如果没有一级标题,使用第一个标题
123
    if (headings.length > 0) {
3✔
124
      return headings[0].text;
×
125
    }
126

127
    // 如果没有任何标题,使用文件名
128
    return 'Untitled';
3✔
129
  }
130

131
  private async copyResource(src: string, options: ToHTMLOptions) {
132
    const decodedSrc = decodeURIComponent(src);
×
133
    const imageFromPath = join(dirname(options.contentPath), decodedSrc);
×
134
    const imageToPath = join(options.destDir, decodedSrc);
×
135
    const imageToDir = dirname(imageToPath);
×
136
    await mkdirAsync(imageToDir);
×
137
    await copyFile(imageFromPath, imageToPath);
×
138
    return imageToPath;
×
139
  }
140

141
  // /**
142
  //  * 解析表格单元格中的链接
143
  //  */
144
  // private parseTableCellLinks(cell: Tokens.TableCell): void {
145
  //   // 如果单元格内容只是纯文本且包含链接格式,则解析为链接
146
  //   if (cell.tokens.length === 1 && cell.tokens[0].type === 'text') {
147
  //     const textToken = cell.tokens[0] as Tokens.Text;
148
  //     const linkMatch = textToken.text.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
149
  //     if (linkMatch) {
150
  //       let href = linkMatch[2];
151
  //       if (isMarkdownFile(href)) {
152
  //         const path = dirname(href);
153
  //         const filename = basename(href, extname(href));
154
  //         href = path === '.' ? `./${filename}.html` : `${path}/${filename}.html`;
155
  //       }
156
  //       // 将文本 token 替换为链接 token
157
  //       const linkToken: Tokens.Link = {
158
  //         type: 'link',
159
  //         raw: textToken.raw,
160
  //         href,
161
  //         title: null,
162
  //         text: linkMatch[1],
163
  //         tokens: [
164
  //           {
165
  //             type: 'text',
166
  //             raw: linkMatch[1],
167
  //             text: linkMatch[1],
168
  //           },
169
  //         ],
170
  //       };
171
  //       cell.tokens = [linkToken];
172
  //     }
173
  //   }
174
  // }
175

176
  /**
177
   * 将 markdown 转换为 HTML
178
   */
179
  async toHtml(content: string, options: ToHTMLOptions): Promise<string> {
180
    const html = await this.marked.parse(content, {
51✔
181
      async: true,
182
      walkTokens: async (token: Token) => {
183
        if (token.type === 'image') {
210✔
184
          const src = token.href;
×
185
          if (
×
186
            !src
187
            || src.startsWith('http')
188
            || src.startsWith('data:image/')
189
            || src.startsWith('blob:')
190
            || src.startsWith('//')
191
          ) {
192
            return;
×
193
          }
194
          const imageToPath = await this.copyResource(src, options);
×
195
          if (this.env === 'pdf') {
×
196
            const buffer = await readFile(imageToPath, 'base64');
×
197
            const ext = extname(imageToPath).slice(1);
×
198
            token.href = `data:image/${ext};base64,${buffer}`;
×
199
          }
200
        } else if (token.type === 'link') {
210✔
201
          const href = token.href;
×
202
          // 检查 href 是否存在
NEW
203
          if (!href) {
×
NEW
204
            return;
×
205
          }
206
          if (href.startsWith('http') || href.startsWith('https')) {
×
207
            return;
×
208
          }
209
          if (!isMarkdownFile(href)) {
×
210
            await this.copyResource(href, options);
×
211
            return;
×
212
          }
213
          const path = dirname(href);
×
214
          const filename = basename(href, extname(href));
×
215
          // 确保路径格式正确:如果 path 是 '.',则使用相对路径
NEW
216
          const link = path === '.' ? `./${filename}.html` : `${path}/${filename}.html`;
×
UNCOV
217
          token.href = link;
×
218
        } else if (token.type === 'code') {
210✔
219
          // 处理 mermaid 代码块
220
          const codeToken = token as Tokens.Code;
3✔
221
          if (codeToken.lang === 'mermaid') {
3✔
222
            const diagram = codeToken.text;
×
223
            token.type = 'html';
×
224
            token.text = `<pre class="mermaid">
×
225
${diagram}
226
</pre>`;
227
          } else if (!codeToken.lang) {
3✔
228
            // 如果没有指定语言,设置为 plain text
229
            codeToken.lang = 'plain';
×
230
          }
231
        } else if (token.type === IncludeTokenType) {
207✔
NEW
232
          const includeToken = token as GitbookIncludeToken;
×
NEW
233
          const includeContent = await readFile(
×
234
            join(dirname(options.contentPath), includeToken.path),
235
          );
NEW
236
          token.type = 'html';
×
NEW
237
          token.text = await this.toHtml(includeContent, options);
×
238
        }
239
      },
240
    });
241
    return html;
51✔
242
  }
243
}
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