Skip to content

Commit 9ea30c5

Browse files
committed
feat(markdown): 添加代码高亮功能并增强HTML过滤
- 引入highlight.js实现代码语法高亮 - 扩展HTML过滤规则以支持高亮所需的span标签和class属性 - 添加测试用例验证高亮功能和HTML过滤规则
1 parent fd90e70 commit 9ea30c5

5 files changed

Lines changed: 121 additions & 2 deletions

File tree

.husky/commit-msg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#!/usr/bin/env sh
22
. "$(dirname -- "$0")/_/husky.sh"
3+
export PATH="/opt/homebrew/bin:$PATH"
34

45
pnpm exec commitlint --edit ${1}

.husky/pre-commit

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#!/usr/bin/env sh
22
. "$(dirname -- "$0")/_/husky.sh"
3+
export PATH="/opt/homebrew/bin:$PATH"
34

45
# 1) Validate committer email
56

packages/libro-markdown/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,14 @@
5050
"@difizen/mana-l10n": "latest",
5151
"@traptitech/markdown-it-katex": "^3.6.0",
5252
"@types/markdown-it": "^12.2.3",
53+
"highlight.js": "^11.11.1",
5354
"katex": "^0.16.10",
5455
"markdown-it": "^13.0.1",
5556
"markdown-it-anchor": "^8.6.5",
5657
"sanitize-html": "^2.14.0"
5758
},
5859
"devDependencies": {
60+
"@types/highlight.js": "^10.1.0",
5961
"@types/sanitize-html": "^2.13.0"
6062
}
6163
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import 'reflect-metadata';
2+
import assert from 'assert';
3+
4+
import { MarkdownRender } from './markdown-render.js';
5+
6+
// Declare jest globals to avoid compilation errors if types are missing
7+
declare const describe: any;
8+
declare const it: any;
9+
declare const beforeEach: any;
10+
declare const jest: any;
11+
12+
// Mock ConfigurationService
13+
const mockConfigService = {
14+
get: jest.fn(),
15+
};
16+
17+
describe('MarkdownRender', () => {
18+
let markdownRender: MarkdownRender;
19+
20+
beforeEach(() => {
21+
// Reset mock and set default behavior
22+
mockConfigService.get.mockReset();
23+
mockConfigService.get.mockResolvedValue(false);
24+
25+
markdownRender = new MarkdownRender();
26+
// Inject mock manually
27+
(markdownRender as any).configurationService = mockConfigService;
28+
// Manually call init to trigger postConstruct logic
29+
markdownRender.init();
30+
});
31+
32+
it('should render basic markdown', () => {
33+
const md = '# Hello';
34+
const html = markdownRender.render(md);
35+
// h1 id="hello" comes from anchor plugin
36+
assert.ok(html.includes('<h1 id="hello">Hello</h1>'));
37+
});
38+
39+
it('should highlight code blocks', () => {
40+
const md = '```javascript\nconst a = 1;\n```';
41+
const html = markdownRender.render(md);
42+
43+
// Check for language class added by markdown-it/highlight.js
44+
assert.ok(html.includes('language-javascript'));
45+
46+
// Check for highlight.js specific classes (indicating highlighting actually happened)
47+
// "const" is a keyword
48+
assert.ok(html.includes('hljs-keyword'));
49+
});
50+
51+
it('should sanitize html', () => {
52+
const md = '<script>alert(1)</script>';
53+
const html = markdownRender.render(md);
54+
assert.ok(!html.includes('<script>'));
55+
assert.ok(!html.includes('alert(1)'));
56+
});
57+
58+
it('should allow span tags with class attributes (needed for highlighting)', () => {
59+
// Manually construct a highlighted-like span to ensure sanitizer doesn't strip it
60+
// Note: markdown-it render output is sanitized.
61+
// If we input raw HTML, it might be stripped depending on settings.
62+
// But highlight.js output is generated internally.
63+
// Let's test with a code block that generates spans.
64+
65+
const md = '```javascript\nconst a = 1;\n```';
66+
const html = markdownRender.render(md);
67+
68+
// Check that span tags are preserved
69+
assert.ok(html.includes('<span class="hljs-keyword">const</span>'));
70+
});
71+
72+
it('should support target="_blank" configuration', async () => {
73+
mockConfigService.get.mockResolvedValue(true);
74+
75+
const renderer = new MarkdownRender();
76+
(renderer as any).configurationService = mockConfigService;
77+
renderer.init();
78+
79+
// Wait for promise resolution in init (microtask)
80+
// Increase wait time to ensure the promise chain in init() completes
81+
await new Promise((resolve) => setTimeout(resolve, 100));
82+
83+
const md = '[link](http://example.com)';
84+
const html = renderer.render(md);
85+
assert.ok(html.includes('target="_blank"'));
86+
});
87+
88+
it('should NOT add target="_blank" when disabled', async () => {
89+
mockConfigService.get.mockResolvedValue(false);
90+
91+
const renderer = new MarkdownRender();
92+
(renderer as any).configurationService = mockConfigService;
93+
renderer.init();
94+
95+
// Wait for promise resolution in init
96+
await new Promise((resolve) => setTimeout(resolve, 100));
97+
98+
const md = '[link](http://example.com)';
99+
const html = renderer.render(md);
100+
assert.ok(!html.includes('target="_blank"'));
101+
});
102+
});

packages/libro-markdown/src/markdown-render.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
singleton,
66
} from '@difizen/mana-app';
77
import latexPlugin from '@traptitech/markdown-it-katex';
8+
import hljs from 'highlight.js';
89
import MarkdownIt from 'markdown-it';
910
import sanitizeHtml from 'sanitize-html';
1011

@@ -13,6 +14,7 @@ import { LibroMarkdownConfiguration } from './config.js';
1314
import type { MarkdownRenderOption } from './markdown-protocol.js';
1415
import { MarkdownParser } from './markdown-protocol.js';
1516
import 'katex/dist/katex.min.css';
17+
import 'highlight.js/styles/github.css';
1618

1719
@singleton({ token: MarkdownParser })
1820
export class MarkdownRender implements MarkdownParser {
@@ -26,6 +28,16 @@ export class MarkdownRender implements MarkdownParser {
2628
this.mkt = new MarkdownIt({
2729
html: true,
2830
linkify: true,
31+
highlight: function (str, lang) {
32+
if (lang && hljs.getLanguage(lang)) {
33+
try {
34+
return hljs.highlight(str, { language: lang }).value;
35+
} catch (__) {
36+
//
37+
}
38+
}
39+
return ''; // use external default escaping
40+
},
2941
});
3042
this.mkt.linkify.set({ fuzzyLink: false });
3143
this.mkt.use(libroAnchor, {
@@ -113,6 +125,7 @@ export class MarkdownRender implements MarkdownParser {
113125
'q',
114126
's',
115127
'small',
128+
'span',
116129
'strong',
117130
'sub',
118131
'sup',
@@ -131,14 +144,14 @@ export class MarkdownRender implements MarkdownParser {
131144
const allowedAttributes = Object.fromEntries(
132145
allowedTags.map((tag) => [
133146
tag,
134-
[...(sanitizeHtml.defaults.allowedAttributes[tag] || []), 'id'],
147+
[...(sanitizeHtml.defaults.allowedAttributes[tag] || []), 'id', 'class'],
135148
]),
136149
);
137150
return sanitizeHtml(html, {
138151
allowedTags, // 允许的标签
139152
allowedAttributes: {
140153
...allowedAttributes,
141-
a: ['href', 'title', 'id'],
154+
a: ['href', 'title', 'id', 'target'],
142155
img: ['src', 'alt', 'id'],
143156
},
144157
});

0 commit comments

Comments
 (0)