luogu-renderer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "luogu-renderer",
3
+ "version": "1.0.0",
4
+ "description": "Luogu Markdown renderer - old (markdown-it) and new (remark+rehype)",
5
+ "type": "module",
6
+ "exports": {
7
+ "./old": {
8
+ "import": "./render-old.js"
9
+ },
10
+ "./new": {
11
+ "import": "./render-new.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "render-old.js",
16
+ "render-new.js",
17
+ "dist/"
18
+ ],
19
+ "scripts": {
20
+ "build:old": "esbuild render-old.js --bundle --format=iife --global-name=LuoguOldRenderer --outfile=dist/luogu-old-renderer.iife.js --platform=browser",
21
+ "build:new": "esbuild render-new.js --bundle --format=iife --global-name=LuoguNewRenderer --outfile=dist/luogu-new-renderer.iife.js --platform=browser",
22
+ "build": "npm run build:old && npm run build:new",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "dependencies": {
26
+ "highlight.js": "^10.7.3",
27
+ "katex": "^0.16.0",
28
+ "markdown-it": "^14.2.0",
29
+ "markdown-it-abbr": "^2.0.0",
30
+ "markdown-it-deflist": "^3.0.1",
31
+ "markdown-it-footnote": "^4.0.0",
32
+ "markdown-it-sub": "^2.0.0",
33
+ "markdown-it-sup": "^2.0.0",
34
+ "markdown-it-task-lists": "^2.1.1",
35
+ "markdown-it-texmath": "^1.0.0",
36
+ "rehype-katex": "^7.0.1",
37
+ "rehype-prism": "^2.3.3",
38
+ "rehype-stringify": "^9.0.0",
39
+ "remark-directive": "^4.0.0",
40
+ "remark-extended-table": "^2.0.3",
41
+ "remark-gfm": "^4.0.1",
42
+ "remark-math": "^6.0.0",
43
+ "remark-parse": "^11.0.0",
44
+ "remark-rehype": "^11.1.2",
45
+ "sanitize-html": "^2.17.5",
46
+ "unified": "^11.0.5",
47
+ "unist-util-visit": "^5.1.0"
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/andyhpy/luogu-markdown-renderer.git"
52
+ },
53
+ "bugs": {
54
+ "url": "https://github.com/andyhpy/luogu-markdown-renderer/issues"
55
+ },
56
+ "homepage": "https://github.com/andyhpy/luogu-markdown-renderer#readme",
57
+ "license": "AGPL-3.0"
58
+ }
package/render-new.js ADDED
@@ -0,0 +1,436 @@
1
+ import { unified } from 'unified';
2
+ import remarkParse from 'remark-parse';
3
+ import remarkGfm from 'remark-gfm';
4
+ import remarkMath from 'remark-math';
5
+ import remarkDirective from 'remark-directive';
6
+ import remarkExtendedTable from 'remark-extended-table';
7
+ import remarkRehype from 'remark-rehype';
8
+ import rehypeStringify from 'rehype-stringify';
9
+ import rehypePrism from 'rehype-prism';
10
+ import rehypeKatex from 'rehype-katex';
11
+ import { visit } from 'unist-util-visit';
12
+ import { toText } from 'hast-util-to-text';
13
+ import katex from 'katex';
14
+ import { h } from 'hastscript';
15
+ import { createRequire } from 'module';
16
+ const require = createRequire(import.meta.url);
17
+ // Load Prism and make globally accessible so CJS components can find it
18
+ globalThis.Prism = require('prismjs');
19
+ require('prismjs/components/prism-clike.js');
20
+ require('prismjs/components/prism-c.js');
21
+ require('prismjs/components/prism-cpp.js');
22
+
23
+ // ========== 1. 表格合并(处理 ^ 符号,生成 rowspan)==========
24
+ // 在 rehype 阶段直接扫描表格,^ 向上合并并添加 rowspan
25
+ function rehypeTableMerge() {
26
+ return (tree) => {
27
+ visit(tree, 'element', (node) => {
28
+ if (node.tagName !== 'table') return;
29
+ const tbody = node.children.find(c => c.tagName === 'tbody');
30
+ const rows = tbody ? tbody.children.filter(c => c.tagName === 'tr') : node.children.filter(c => c.tagName === 'tr');
31
+ if (rows.length === 0) return;
32
+
33
+ // 遍历所有 ^ 单元格并计算目标 cell 应该增加的行合并数
34
+ const rowspanIncrement = {}; // key: "rowIndex,colIndex" -> count of ^ below
35
+ for (let i = 0; i < rows.length; i++) {
36
+ const cells = rows[i].children.filter(c => c.tagName === 'td' || c.tagName === 'th');
37
+ for (let j = 0; j < cells.length; j++) {
38
+ const text = cells[j].children?.[0]?.value?.trim();
39
+ if (text === '^') {
40
+ // 向上找到第一个非 ^ 的同列单元格
41
+ for (let k = i - 1; k >= 0; k--) {
42
+ const prevCells = rows[k].children.filter(c => c.tagName === 'td' || c.tagName === 'th');
43
+ if (j < prevCells.length && prevCells[j]?.children?.[0]?.value?.trim() !== '^') {
44
+ const key = `${k},${j}`;
45
+ rowspanIncrement[key] = (rowspanIncrement[key] || 0) + 1;
46
+ break;
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+ // 应用 rowspan
53
+ for (let i = 0; i < rows.length; i++) {
54
+ const cells = rows[i].children.filter(c => c.tagName === 'td' || c.tagName === 'th');
55
+ for (let j = 0; j < cells.length; j++) {
56
+ const key = `${i},${j}`;
57
+ if (rowspanIncrement[key]) {
58
+ const current = parseInt(cells[j].properties?.rowspan || '1');
59
+ cells[j].properties = cells[j].properties || {};
60
+ cells[j].properties.rowspan = current + rowspanIncrement[key];
61
+ }
62
+ }
63
+ }
64
+ // 移除所有 ^ 单元格及周围的空白文本节点(从右向左删,避免索引偏移)
65
+ for (let i = 0; i < rows.length; i++) {
66
+ const children = rows[i].children;
67
+ for (let j = children.length - 1; j >= 0; j--) {
68
+ const c = children[j];
69
+ if (c.tagName === 'td' || c.tagName === 'th') {
70
+ const text = c.children?.[0]?.value?.trim();
71
+ if (text === '^') {
72
+ children.splice(j, 1);
73
+ // 移除前后的空白文本节点
74
+ if (j < children.length && children[j]?.type === 'text' && !children[j].value.trim()) {
75
+ children.splice(j, 1);
76
+ }
77
+ if (j > 0 && children[j-1]?.type === 'text' && !children[j-1].value.trim()) {
78
+ children.splice(j-1, 1);
79
+ j--;
80
+ }
81
+ }
82
+ }
83
+ }
84
+ }
85
+ });
86
+ };
87
+ }
88
+
89
+ // ========== 2. 自定义容器(info/warning/error/success -> details)==========
90
+ function remarkCustomContainers() {
91
+ return (tree) => {
92
+ visit(tree, 'containerDirective', (node) => {
93
+ const name = node.name;
94
+ if (!['info', 'warning', 'error', 'success'].includes(name)) return;
95
+ const data = node.data || (node.data = {});
96
+ data.hName = 'details';
97
+ data.hProperties = { className: name };
98
+ if ('open' in (node.attributes || {})) {
99
+ data.hProperties.open = true;
100
+ }
101
+ // [标题] 语法:第一个 child 是 paragraph 包含标题内容(可能含内联公式)
102
+ if (node.children.length > 0 && node.children[0].type === 'paragraph') {
103
+ const titlePara = node.children[0];
104
+ // 把这个 paragraph 变成 summary(保留其 children,如 inlineMath)
105
+ titlePara.data = titlePara.data || {};
106
+ titlePara.data.hName = 'summary';
107
+ } else {
108
+ // 无标题时默认用容器名
109
+ const summaryNode = {
110
+ type: 'element',
111
+ tagName: 'summary',
112
+ children: [{ type: 'text', value: name.charAt(0).toUpperCase() + name.slice(1) }]
113
+ };
114
+ node.children.unshift(summaryNode);
115
+ }
116
+ });
117
+ };
118
+ }
119
+
120
+ // ========== 3. 对齐容器(align 给内部的标题或段落加 style)==========
121
+ function remarkAlignContainer() {
122
+ return (tree) => {
123
+ visit(tree, 'containerDirective', (node) => {
124
+ if (node.name !== 'align') return;
125
+ const attrs = node.attributes || {};
126
+ const align = 'center' in attrs ? 'center' : 'left' in attrs ? 'left' : 'right';
127
+ const data = node.data || (node.data = {});
128
+ data.hName = 'div';
129
+ data.hProperties = { style: `text-align: ${align};` };
130
+ });
131
+ };
132
+ }
133
+
134
+ // 后处理:将 align div 的 style 分布到所有块级子元素,然后移除 div
135
+ function rehypeAlignCleanup() {
136
+ return (tree) => {
137
+ visit(tree, 'element', (node, idx, parent) => {
138
+ if (node.tagName !== 'div' || !node.properties?.style?.startsWith('text-align:')) return;
139
+ if (!parent) return;
140
+ const children = (node.children || []).filter(c => c.type !== 'text' || c.value.trim());
141
+ const style = node.properties.style;
142
+ for (const child of children) {
143
+ if (child.type === 'element' && (child.tagName === 'p' || child.tagName.match(/^h[1-6]$/))) {
144
+ child.properties = child.properties || {};
145
+ child.properties.style = style;
146
+ }
147
+ }
148
+ parent.children.splice(idx, 1, ...children);
149
+ });
150
+ };
151
+ }
152
+
153
+ // ========== 4. Cute Table 容器 ==========
154
+ function remarkCuteTable() {
155
+ return (tree) => {
156
+ visit(tree, 'leafDirective', (node) => {
157
+ if (node.name !== 'cute-table') return;
158
+ const isTuack = node.attributes && 'tuack' in node.attributes;
159
+ const data = node.data || (node.data = {});
160
+ data.hName = isTuack ? 'cute-table-tuack' : 'cute-table';
161
+ });
162
+ };
163
+ }
164
+
165
+ // rehype 阶段:将 leafDirective 生成的标记元素与其后的 table 合并
166
+ function rehypeCuteTableCleanup() {
167
+ return (tree) => {
168
+ visit(tree, 'element', (node, idx, parent) => {
169
+ if (!parent) return;
170
+ const isTuack = node.tagName === 'cute-table-tuack';
171
+ if (node.tagName !== 'cute-table' && !isTuack) return;
172
+ const siblings = parent.children;
173
+ const nextSib = siblings[idx + 1];
174
+ if (nextSib && nextSib.type === 'element' && nextSib.tagName === 'table') {
175
+ node.tagName = 'div';
176
+ node.properties = node.properties || {};
177
+ node.properties.className = isTuack
178
+ ? 'cute-table cute-table-tuack'
179
+ : 'cute-table cute-table';
180
+ node.children = [nextSib];
181
+ siblings.splice(idx + 1, 1);
182
+ }
183
+ });
184
+ };
185
+ }
186
+
187
+ // ========== 5. Epigraph 容器 ==========
188
+ function remarkEpigraph() {
189
+ return (tree) => {
190
+ visit(tree, 'containerDirective', (node) => {
191
+ if (node.name !== 'epigraph') return;
192
+ const data = node.data || (node.data = {});
193
+ data.hName = 'div';
194
+ data.hProperties = { className: 'epigraph has-source' };
195
+ // [来源] 语法:第一个 child 是 paragraph 包含来源
196
+ if (node.children.length > 1 && node.children[0].type === 'paragraph') {
197
+ const sourcePara = node.children.shift();
198
+ node.children.push(sourcePara); // 将来源移到末尾
199
+ }
200
+ });
201
+ };
202
+ }
203
+
204
+ // ========== 6. 代码块参数(行号、高亮行)==========
205
+ function remarkCodeBlockParams() {
206
+ return (tree) => {
207
+ visit(tree, 'code', (node) => {
208
+ const meta = node.meta || '';
209
+ const lineNumbers = /line-numbers/.test(meta);
210
+ const linesMatch = meta.match(/lines=([\d,-]+)/);
211
+ let highlightLines = [];
212
+ if (linesMatch) {
213
+ const ranges = linesMatch[1].split(',');
214
+ for (const range of ranges) {
215
+ if (range.includes('-')) {
216
+ const [start, end] = range.split('-').map(Number);
217
+ for (let i = start; i <= end; i++) highlightLines.push(i);
218
+ } else {
219
+ highlightLines.push(Number(range));
220
+ }
221
+ }
222
+ }
223
+ node.data = node.data || {};
224
+ node.data.hProperties = node.data.hProperties || {};
225
+ if (lineNumbers) node.data.hProperties.dataLineNumbers = true;
226
+ if (highlightLines.length) {
227
+ node.data.hProperties.highlightLines = highlightLines.join(',');
228
+ node.data.hProperties.dataLine = linesMatch ? linesMatch[1] : '';
229
+ }
230
+ });
231
+ };
232
+ }
233
+
234
+ function rehypeCodeBlockEnhance() {
235
+ return (tree) => {
236
+ visit(tree, 'element', (node, index, parent) => {
237
+ if (node.tagName === 'pre' && node.children[0]?.tagName === 'code') {
238
+ const codeNode = node.children[0];
239
+ const dataLineNumbers = codeNode.properties?.dataLineNumbers;
240
+ const dataLine = codeNode.properties?.dataLine || '';
241
+ const highlightLinesStr = codeNode.properties?.highlightLines || '';
242
+ const highlightLines = highlightLinesStr.split(',').filter(Boolean).map(Number);
243
+ const lang = codeNode.properties?.className?.find(c => c.startsWith('language-'))?.slice(9) || '';
244
+
245
+ // Clean up properties not meant for final HTML output
246
+ delete codeNode.properties.dataLineNumbers;
247
+ delete codeNode.properties.dataLine;
248
+ delete codeNode.properties.highlightLines;
249
+
250
+ // Get actual line count from the rendered (highlighted) code text
251
+ const textContent = toText(codeNode, { whitespace: 'pre' });
252
+ const lines = textContent.split('\n');
253
+ const lineCount = textContent.endsWith('\n') ? lines.length - 1 : lines.length;
254
+
255
+ // Construct pre classes
256
+ const preClasses = ['pre'];
257
+ if (dataLineNumbers) preClasses.push('line-numbers');
258
+ if (lang) preClasses.push(`language-${lang}`);
259
+ node.properties.className = preClasses;
260
+ node.properties.tabindex = '0';
261
+ node.properties['data-v-a7061ca4'] = '';
262
+ codeNode.properties['data-v-a7061ca4'] = '';
263
+ if (dataLine) node.properties.dataLine = dataLine;
264
+
265
+ // Add line-numbers-rows and line-numbers-sizer
266
+ if (dataLineNumbers) {
267
+ const rowsSpan = {
268
+ type: 'element',
269
+ tagName: 'span',
270
+ properties: { 'aria-hidden': 'true', className: 'line-numbers-rows' },
271
+ children: Array.from({ length: lineCount }, () => ({
272
+ type: 'element',
273
+ tagName: 'span',
274
+ properties: { style: 'height: 21px;' },
275
+ children: []
276
+ }))
277
+ };
278
+ codeNode.children.push(rowsSpan);
279
+
280
+ const sizerSpan = {
281
+ type: 'element',
282
+ tagName: 'span',
283
+ properties: { className: 'line-numbers-sizer', style: 'display: none;' },
284
+ children: []
285
+ };
286
+ codeNode.children.push(sizerSpan);
287
+ }
288
+
289
+ // Add highlight divs with data-range attribute
290
+ if (highlightLines.length > 0) {
291
+ let currentStart = null;
292
+ for (let i = 1; i <= lineCount; i++) {
293
+ if (highlightLines.includes(i)) {
294
+ if (currentStart === null) currentStart = i;
295
+ } else {
296
+ if (currentStart !== null) {
297
+ const end = i - 1;
298
+ const height = (end - currentStart + 1) * 21;
299
+ const top = (currentStart - 1) * 21;
300
+ const rangeStr = currentStart === end ? String(currentStart) : `${currentStart}-${end}`;
301
+ const highDiv = {
302
+ type: 'element',
303
+ tagName: 'div',
304
+ properties: {
305
+ 'aria-hidden': 'true',
306
+ 'data-range': rangeStr,
307
+ className: ' line-highlight',
308
+ style: `top: ${top}px; height: ${height}px;`
309
+ },
310
+ children: []
311
+ };
312
+ node.children.push(highDiv);
313
+ currentStart = null;
314
+ }
315
+ }
316
+ }
317
+ if (currentStart !== null) {
318
+ const height = (lineCount - currentStart + 1) * 21;
319
+ const top = (currentStart - 1) * 21;
320
+ const rangeStr = currentStart === lineCount ? String(currentStart) : `${currentStart}-${lineCount}`;
321
+ const highDiv = {
322
+ type: 'element',
323
+ tagName: 'div',
324
+ properties: {
325
+ 'aria-hidden': 'true',
326
+ 'data-range': rangeStr,
327
+ className: ' line-highlight',
328
+ style: `top: ${top}px; height: ${height}px;`
329
+ },
330
+ children: []
331
+ };
332
+ node.children.push(highDiv);
333
+ }
334
+ }
335
+
336
+ // Add copy button
337
+ const copyBtn = h('button', { type: 'button', className: 'copy-button', 'data-v-a7061ca4': '' }, [
338
+ h('svg', { className: 'svg-inline--fa fa-copy copy-icon', viewBox: '0 0 448 512', 'aria-hidden': 'true', 'data-v-a7061ca4': '' }, [
339
+ h('path', { class: '', fill: 'currentColor', d: 'M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z' })
340
+ ])
341
+ ]);
342
+ const wrapper = h('div', { className: 'code-container', 'data-v-a7061ca4': '' }, [node, copyBtn]);
343
+ parent.children[index] = wrapper;
344
+ }
345
+ });
346
+ };
347
+ }
348
+
349
+ // 将 align 属性转为内联 style(与期望输出一致)
350
+ function rehypeAlignToStyle() {
351
+ return (tree) => {
352
+ visit(tree, 'element', (node) => {
353
+ const align = node.properties?.align;
354
+ if (align && (node.tagName === 'th' || node.tagName === 'td' || node.tagName === 'tr' || node.tagName === 'table')) {
355
+ node.properties.style = `text-align: ${align};`;
356
+ delete node.properties.align;
357
+ }
358
+ });
359
+ };
360
+ }
361
+
362
+ // ========== 7. 图片:处理 bilibili: 协议 ==========
363
+ function rehypeBilibili() {
364
+ return (tree) => {
365
+ visit(tree, 'element', (node) => {
366
+ if (node.tagName === 'img' && node.properties?.src?.startsWith('bilibili:')) {
367
+ const src = node.properties.src;
368
+ const urlPart = src.slice('bilibili:'.length);
369
+ const [bvidPart, query] = urlPart.includes('?') ? urlPart.split('?') : [urlPart, ''];
370
+ const params = new URLSearchParams(query);
371
+ const page = params.get('page') || '1';
372
+ const t = params.get('t') || '0';
373
+ const embedUrl = `https://player.bilibili.com/player.html?danmaku=0&autoplay=0&playlist=0&muted=0&bvid=${bvidPart}&page=${page}&t=${t}`;
374
+ const divProps = {
375
+ src: `bilibili://${bvidPart}${query ? '?' + query : ''}`,
376
+ alt: 'video',
377
+ style: 'position: relative; padding-bottom: 62.5%;'
378
+ };
379
+ const iframe = h('iframe', {
380
+ src: embedUrl,
381
+ scrolling: 'no',
382
+ border: '0',
383
+ frameborder: 'no',
384
+ framespacing: '0',
385
+ allowfullscreen: true,
386
+ style: 'position: absolute; top: 0px; left: 0px; width: 100%; height: 100%;'
387
+ });
388
+ const container = h('div', divProps, [iframe]);
389
+ node.tagName = 'div';
390
+ node.properties = divProps;
391
+ node.children = [iframe];
392
+ }
393
+ });
394
+ };
395
+ }
396
+
397
+ // ========== 8. 公式错误处理(KaTeX 渲染失败时显示红色)==========
398
+ function rehypeKatexWithError() {
399
+ return rehypeKatex({ throwOnError: false, errorColor: 'rgb(204, 0, 0)', output: 'html', strict: false });
400
+ }
401
+
402
+ // ========== 构建处理器 ==========
403
+ const processor = unified()
404
+ .use(remarkParse)
405
+ .use(remarkGfm)
406
+ .use(remarkMath)
407
+ .use(remarkDirective)
408
+ .use(remarkCustomContainers) // info/warning/error/success -> details
409
+ .use(remarkAlignContainer) // align 容器
410
+ .use(remarkCuteTable) // cute-table 容器
411
+ .use(remarkEpigraph) // epigraph 容器
412
+ .use(remarkCodeBlockParams) // 代码块参数
413
+ .use(remarkRehype, { allowDangerousHtml: true })
414
+ .use(rehypePrism)
415
+ .use(rehypeKatexWithError) // KaTeX 渲染,错误显示红色
416
+ .use(rehypeCuteTableCleanup) // cute-table 包裹 table
417
+ .use(rehypeTableMerge) // 在 HTML 层面清理合并单元格
418
+ .use(rehypeAlignCleanup) // 对齐样式转移
419
+ .use(rehypeCodeBlockEnhance) // 代码块行号和高亮
420
+ .use(rehypeBilibili) // B站视频
421
+ .use(rehypeAlignToStyle) // align -> style
422
+ .use(rehypeStringify, { allowDangerousCharacters: true, spaces: false });
423
+
424
+ export async function render(markdown) {
425
+ // Normalize CRLF -> LF in input
426
+ markdown = markdown.replace(/\r\n/g, '\n');
427
+ const result = await processor.process(markdown);
428
+ let html = String(result);
429
+ // Normalize katex-error inline style format
430
+ html = html.replace(/style="color:rgb\(204,\s*0,\s*0\)"/g, 'style="color: rgb(204, 0, 0);"');
431
+ // Ensure boolean HTML attributes (open, allowfullscreen) render as attr="" not bare attr
432
+ html = html.replace(/\b(open|allowfullscreen)(?=[\s>\/])/g, '$1=""');
433
+ return html;
434
+ }
435
+
436
+ export default { render };
package/render-old.js ADDED
@@ -0,0 +1,137 @@
1
+ import MarkdownIt from 'markdown-it';
2
+ import texmath from 'markdown-it-texmath';
3
+ import taskLists from 'markdown-it-task-lists';
4
+ import sup from 'markdown-it-sup';
5
+ import sub from 'markdown-it-sub';
6
+ import hljs from 'highlight.js';
7
+ import katex from 'katex';
8
+
9
+ function preprocessTextCommands(markdown) {
10
+ const mathPattern = /(\$\$[\s\S]*?\$\$|\$[^$]*?\$)/g;
11
+ const mathMatches = [];
12
+ const placeholders = [];
13
+
14
+ let processed = markdown.replace(mathPattern, (match) => {
15
+ const idx = mathMatches.length;
16
+ mathMatches.push(match);
17
+ const placeholder = `__MATH_PLACEHOLDER_${idx}__`;
18
+ placeholders.push(placeholder);
19
+ return placeholder;
20
+ });
21
+
22
+ processed = processed
23
+ .replace(/\\Huge\{([^}]*)\}/g, '<span style="font-size: 2em;">$1</span>')
24
+ .replace(/\\huge\{([^}]*)\}/g, '<span style="font-size: 1.5em;">$1</span>')
25
+ .replace(/\\large\{([^}]*)\}/g, '<span style="font-size: 1.2em;">$1</span>')
26
+ .replace(/\\small\{([^}]*)\}/g, '<span style="font-size: 0.8em;">$1</span>')
27
+ .replace(/\\tiny\{([^}]*)\}/g, '<span style="font-size: 0.6em;">$1</span>')
28
+ .replace(/\\color\{([^}]*)\}\{([^}]*)\}/g, '<span style="color: $1;">$2</span>')
29
+ .replace(/\\textcolor\{([^}]*)\}\{([^}]*)\}/g, '<span style="color: $1;">$2</span>');
30
+
31
+ for (let i = 0; i < mathMatches.length; i++) {
32
+ processed = processed.split(placeholders[i]).join(mathMatches[i]);
33
+ }
34
+ return processed;
35
+ }
36
+
37
+ const md = new MarkdownIt({
38
+ html: false,
39
+ linkify: true,
40
+ typographer: false,
41
+ highlight: function(str, lang) {
42
+ if (lang && hljs.getLanguage(lang)) {
43
+ try {
44
+ const langAttr = lang ? ` data-rendered-lang="${lang}"` : '';
45
+ let code = hljs.highlight(str, { language: lang }).value;
46
+ if (lang === 'cpp') {
47
+ code = code.replace(/\b(std)\b/g, '<span class="hljs-built_in">$1</span>');
48
+ }
49
+ return `<pre><code class="language-${lang}"${langAttr}>${code}</code></pre>\n`;
50
+ } catch (__) {}
51
+ }
52
+ return '';
53
+ }
54
+ });
55
+
56
+ md.use(taskLists);
57
+ md.use(sup);
58
+ md.use(sub);
59
+
60
+ texmath.rules.dollars.inline = texmath.rules.dollars.inline.filter(r => r.name !== 'math_inline_double');
61
+ texmath.rules.dollars.inline[0].rex = /(?<!\$)\$(?!\$)(.+?)\$/gy;
62
+
63
+ md.use(texmath, {
64
+ engine: katex,
65
+ delimiters: ['dollars', 'brackets'],
66
+ katexOptions: {
67
+ throwOnError: true,
68
+ strict: false,
69
+ }
70
+ });
71
+
72
+ function renderMath(tex, displayMode) {
73
+ const options = { throwOnError: true, strict: false, displayMode };
74
+ try {
75
+ const katexHtml = katex.renderToString(tex, options);
76
+ return `<span>${katexHtml}</span>`;
77
+ } catch (e) {
78
+ return `<span>${tex}</span>`;
79
+ }
80
+ }
81
+
82
+ md.renderer.rules.math_inline = (tokens, idx) => renderMath(tokens[idx].content, false);
83
+ md.renderer.rules.math_inline_double = (tokens, idx) => renderMath(tokens[idx].content, true);
84
+ md.renderer.rules.math_block = (tokens, idx) => renderMath(tokens[idx].content, true);
85
+ md.renderer.rules.math_block_eqno = (tokens, idx) => renderMath(tokens[idx].content, true);
86
+
87
+ // ========== Bilibili 视频处理 ==========
88
+ const defaultImageRenderer = md.renderer.rules.image || function(tokens, idx, options, env, self) {
89
+ return self.renderToken(tokens, idx, options);
90
+ };
91
+
92
+ md.renderer.rules.image = function(tokens, idx, options, env, self) {
93
+ const token = tokens[idx];
94
+ const srcIndex = token.attrIndex('src');
95
+ if (srcIndex >= 0) {
96
+ const src = token.attrs[srcIndex][1];
97
+ if (src.startsWith('bilibili:')) {
98
+ const urlPart = src.slice('bilibili:'.length);
99
+ const qIndex = urlPart.indexOf('?');
100
+ const idPart = qIndex >= 0 ? urlPart.slice(0, qIndex) : urlPart;
101
+ let page = '1', t = '0';
102
+ if (qIndex >= 0) {
103
+ const query = urlPart.slice(qIndex + 1);
104
+ const pairs = query.split('&');
105
+ for (const pair of pairs) {
106
+ const eq = pair.indexOf('=');
107
+ if (eq >= 0) {
108
+ const key = pair.slice(0, eq);
109
+ const val = pair.slice(eq + 1);
110
+ if (key === 'page') page = val;
111
+ if (key === 't') t = val;
112
+ }
113
+ }
114
+ }
115
+ let embedUrl;
116
+ if (/^BV/i.test(idPart)) {
117
+ embedUrl = 'https://www.bilibili.com/blackboard/webplayer/embed-old.html?bvid=' + idPart + '&danmaku=0&autoplay=0&playlist=0&high_quality=1&page=' + page + '&t=' + t;
118
+ } else {
119
+ const aid = idPart.replace(/^av/i, '');
120
+ embedUrl = 'https://www.bilibili.com/blackboard/webplayer/embed-old.html?aid=' + aid + '&danmaku=0&autoplay=0&playlist=0&high_quality=1&page=' + page + '&t=' + t;
121
+ }
122
+ return '</p><div class="iframe-wrapper" style="position: relative; padding-bottom: 62.5%"><iframe scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true" src="' + embedUrl.replace(/&/g, '&amp;') + '" style=" position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>';
123
+ }
124
+ }
125
+ return defaultImageRenderer(tokens, idx, options, env, self);
126
+ };
127
+
128
+ export async function render(markdown) {
129
+ const processed = preprocessTextCommands(markdown);
130
+ let html = md.render(processed);
131
+ html = html.replace('</div>\n:::</p>', '</div>\n:::<p></p>');
132
+ html = html.replace('</code></pre>\n\n<p>:::</p>', '</code></pre>\n<p>:::</p>');
133
+ html = html.replace(/\n$/, '');
134
+ return html;
135
+ }
136
+
137
+ export default { render };