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/README.md +107 -0
- package/dist/luogu-new-renderer.iife.js +60378 -0
- package/dist/luogu-old-renderer.iife.js +87062 -0
- package/package.json +58 -0
- package/render-new.js +436 -0
- package/render-old.js +137 -0
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, '&') + '" 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 };
|