@xingjisoft/neutrino 0.2.1 → 0.3.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 +35 -7
- package/dist/config.js +9 -0
- package/dist/screens/chat.js +40 -30
- package/dist/web/hub.js +9 -0
- package/dist/web/server.js +353 -5
- package/dist/web/session.js +65 -1
- package/dist/web/settings.js +27 -1
- package/package.json +2 -2
- package/web/README.md +83 -15
- package/web/app.js +1309 -124
- package/web/i18n.js +345 -0
- package/web/index.html +210 -34
- package/web/markdown.js +710 -0
- package/web/settings.js +73 -19
- package/web/style.css +1124 -85
package/web/markdown.js
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
// A small, dependency-free markdown renderer for the web conversation view.
|
|
2
|
+
//
|
|
3
|
+
// The browser receives model output as plain text. This module turns the
|
|
4
|
+
// common GitHub-flavoured subset into HTML:
|
|
5
|
+
//
|
|
6
|
+
// headings, paragraphs, hard line breaks, emphasis, strikethrough, inline
|
|
7
|
+
// code, fenced code blocks, block quotes, ordered/unordered/nested lists,
|
|
8
|
+
// task lists, horizontal rules, links, images and pipe tables.
|
|
9
|
+
//
|
|
10
|
+
// Raw HTML is never passed through: every text run is escaped, and links /
|
|
11
|
+
// images are limited to http(s) (plus mailto for links). The terminal has its
|
|
12
|
+
// own renderer in `src/markdown.ts`; this one shares the same syntax rules but
|
|
13
|
+
// emits HTML for the browser instead of styled terminal cells.
|
|
14
|
+
|
|
15
|
+
const HTML_ESCAPE = {
|
|
16
|
+
"&": "&",
|
|
17
|
+
"<": "<",
|
|
18
|
+
">": ">",
|
|
19
|
+
'"': """,
|
|
20
|
+
"'": "'",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function escapeHtml(value) {
|
|
24
|
+
return String(value ?? "").replace(/[&<>"']/g, (char) => HTML_ESCAPE[char]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ENTITIES = {
|
|
28
|
+
amp: "&",
|
|
29
|
+
lt: "<",
|
|
30
|
+
gt: ">",
|
|
31
|
+
quot: '"',
|
|
32
|
+
apos: "'",
|
|
33
|
+
nbsp: " ",
|
|
34
|
+
"#39": "'",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Decode the handful of entities markdown authors actually type. */
|
|
38
|
+
function decodeEntities(value) {
|
|
39
|
+
return String(value ?? "").replace(/&(#x?[0-9a-f]+|amp|lt|gt|quot|apos|nbsp);/gi, (match, name) => {
|
|
40
|
+
const key = name.toLowerCase();
|
|
41
|
+
if (key.startsWith("#x")) {
|
|
42
|
+
const code = Number.parseInt(key.slice(2), 16);
|
|
43
|
+
return Number.isFinite(code) ? safeCodePoint(code) : match;
|
|
44
|
+
}
|
|
45
|
+
if (key.startsWith("#")) {
|
|
46
|
+
const code = Number.parseInt(key.slice(1), 10);
|
|
47
|
+
return Number.isFinite(code) ? safeCodePoint(code) : match;
|
|
48
|
+
}
|
|
49
|
+
return ENTITIES[key] ?? match;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function safeCodePoint(code) {
|
|
54
|
+
if (!Number.isFinite(code) || code <= 0 || code > 0x10ffff) return "\ufffd";
|
|
55
|
+
try {
|
|
56
|
+
return String.fromCodePoint(code);
|
|
57
|
+
} catch {
|
|
58
|
+
return "\ufffd";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Text outside code spans/blocks: entities are decoded, then all markup escaped. */
|
|
63
|
+
function escapeText(value) {
|
|
64
|
+
return escapeHtml(decodeEntities(value));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function escapeAttribute(value) {
|
|
68
|
+
return escapeHtml(decodeEntities(value));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function indentOf(line) {
|
|
72
|
+
const match = /^ */.exec(line);
|
|
73
|
+
return match ? match[0].length : 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isBlank(line) {
|
|
77
|
+
return !String(line ?? "").trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isOpeningBoundary(previous) {
|
|
81
|
+
return !previous || /[\s([{<"']/.test(previous);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isSafeLink(url) {
|
|
85
|
+
return /^(?:https?:|mailto:)/i.test(String(url ?? "").trim());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isSafeImage(url) {
|
|
89
|
+
return /^https?:/i.test(String(url ?? "").trim());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function cleanUrl(raw) {
|
|
93
|
+
return String(raw ?? "")
|
|
94
|
+
.trim()
|
|
95
|
+
.replace(/[\u0000-\u001f\u007f]/g, "")
|
|
96
|
+
.replace(/^<|>$/g, "")
|
|
97
|
+
.replace(/\\([()<> ])/g, "$1");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --------------------------------------------------------------- inline HTML
|
|
101
|
+
|
|
102
|
+
function parseCodeSpan(text, start) {
|
|
103
|
+
let run = 0;
|
|
104
|
+
while (text[start + run] === "`") run += 1;
|
|
105
|
+
if (run === 0) return null;
|
|
106
|
+
const close = text.indexOf("`".repeat(run), start + run);
|
|
107
|
+
if (close < 0) return null;
|
|
108
|
+
let content = text.slice(start + run, close).replace(/\n/g, " ");
|
|
109
|
+
// A single padding space on each side is part of the syntax, not the code.
|
|
110
|
+
if (content.length > 2 && content.startsWith(" ") && content.endsWith(" ")) {
|
|
111
|
+
content = content.slice(1, -1);
|
|
112
|
+
}
|
|
113
|
+
return { html: `<code>${escapeHtml(content)}</code>`, next: close + run };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function findClosingBracket(text, start) {
|
|
117
|
+
let depth = 1;
|
|
118
|
+
for (let i = start; i < text.length; i += 1) {
|
|
119
|
+
if (text[i] === "\\") {
|
|
120
|
+
i += 1;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (text[i] === "[") depth += 1;
|
|
124
|
+
else if (text[i] === "]") {
|
|
125
|
+
depth -= 1;
|
|
126
|
+
if (depth === 0) return i;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return -1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseDestination(text, start) {
|
|
133
|
+
if (text[start] !== "(") return null;
|
|
134
|
+
let depth = 1;
|
|
135
|
+
let i = start + 1;
|
|
136
|
+
let inAngle = false;
|
|
137
|
+
for (; i < text.length; i += 1) {
|
|
138
|
+
const ch = text[i];
|
|
139
|
+
if (ch === "\\") {
|
|
140
|
+
i += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (ch === "<" && i === start + 1) {
|
|
144
|
+
inAngle = true;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (ch === ">" && inAngle) {
|
|
148
|
+
inAngle = false;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (inAngle) continue;
|
|
152
|
+
if (ch === "(") depth += 1;
|
|
153
|
+
else if (ch === ")") {
|
|
154
|
+
depth -= 1;
|
|
155
|
+
if (depth === 0) break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (depth !== 0) return null;
|
|
159
|
+
const inner = text.slice(start + 1, i).trim();
|
|
160
|
+
if (!inner) return null;
|
|
161
|
+
let url = inner;
|
|
162
|
+
let title = "";
|
|
163
|
+
if (inner.startsWith("<")) {
|
|
164
|
+
const close = inner.indexOf(">");
|
|
165
|
+
if (close > 0) {
|
|
166
|
+
url = inner.slice(1, close);
|
|
167
|
+
title = inner.slice(close + 1).trim();
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
const quote = inner.search(/\s+["'(]/);
|
|
171
|
+
if (quote > 0) {
|
|
172
|
+
url = inner.slice(0, quote);
|
|
173
|
+
title = inner.slice(quote).trim();
|
|
174
|
+
} else {
|
|
175
|
+
const space = inner.search(/\s/);
|
|
176
|
+
if (space > 0) {
|
|
177
|
+
url = inner.slice(0, space);
|
|
178
|
+
title = inner.slice(space).trim();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
title = title.replace(/^["'(]|["')]$/g, "").trim();
|
|
183
|
+
return { url: cleanUrl(url), title: cleanUrl(title), next: i + 1 };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function renderLinkOrImage(text, start, image, depth) {
|
|
187
|
+
const labelStart = start + (image ? 2 : 1);
|
|
188
|
+
const close = findClosingBracket(text, labelStart);
|
|
189
|
+
if (close < 0 || text[close + 1] !== "(") return null;
|
|
190
|
+
const destination = parseDestination(text, close + 1);
|
|
191
|
+
if (!destination) return null;
|
|
192
|
+
const label = text.slice(labelStart, close);
|
|
193
|
+
const title = destination.title ? ` title="${escapeAttribute(destination.title)}"` : "";
|
|
194
|
+
if (image) {
|
|
195
|
+
if (isSafeImage(destination.url)) {
|
|
196
|
+
return {
|
|
197
|
+
html: `<img src="${escapeAttribute(destination.url)}" alt="${escapeAttribute(label.replace(/[*_~`]/g, ""))}"${title} loading="lazy" referrerpolicy="no-referrer">`,
|
|
198
|
+
next: destination.next,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return { html: escapeText(label), next: destination.next };
|
|
202
|
+
}
|
|
203
|
+
if (isSafeLink(destination.url)) {
|
|
204
|
+
return {
|
|
205
|
+
html: `<a href="${escapeAttribute(destination.url)}"${title} target="_blank" rel="noopener noreferrer">${renderInline(label, depth + 1)}</a>`,
|
|
206
|
+
next: destination.next,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
// Do not turn javascript:/data:/file: destinations into clickable links.
|
|
210
|
+
return { html: renderInline(label, depth + 1), next: destination.next };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function findSingle(text, start, char) {
|
|
214
|
+
for (let i = start; i < text.length; i += 1) {
|
|
215
|
+
if (text[i] !== char) continue;
|
|
216
|
+
if (text[i - 1] === "\\") continue;
|
|
217
|
+
if (text[i + 1] === char || text[i - 1] === char) continue;
|
|
218
|
+
return i;
|
|
219
|
+
}
|
|
220
|
+
return -1;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function renderEmphasis(text, start, depth) {
|
|
224
|
+
const char = text[start];
|
|
225
|
+
const previous = text[start - 1] ?? "";
|
|
226
|
+
const triple = char + char + char;
|
|
227
|
+
const double = char + char;
|
|
228
|
+
if (text.startsWith(triple, start)) {
|
|
229
|
+
const end = text.indexOf(triple, start + 3);
|
|
230
|
+
if (end > start + 3) {
|
|
231
|
+
const inner = text.slice(start + 3, end);
|
|
232
|
+
if (inner.trim() && !/^\s|\s$/.test(inner)) {
|
|
233
|
+
return {
|
|
234
|
+
html: `<strong><em>${renderInline(inner, depth + 1)}</em></strong>`,
|
|
235
|
+
next: end + 3,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (text.startsWith(double, start)) {
|
|
241
|
+
const next = text[start + 2] ?? "";
|
|
242
|
+
if (!/^\s$/.test(next)) {
|
|
243
|
+
const end = text.indexOf(double, start + 2);
|
|
244
|
+
if (end > start + 2) {
|
|
245
|
+
const inner = text.slice(start + 2, end);
|
|
246
|
+
if (inner.trim() && !/^\s|\s$/.test(inner)) {
|
|
247
|
+
return {
|
|
248
|
+
html: `<strong>${renderInline(inner, depth + 1)}</strong>`,
|
|
249
|
+
next: end + 2,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// `foo_bar_baz` is an identifier, not emphasis. `*` has no such word rule
|
|
256
|
+
// (CommonMark allows intraword `*`), and non-ASCII text such as Chinese can
|
|
257
|
+
// put an underscore directly next to a word.
|
|
258
|
+
if (char === "_" && /^[A-Za-z0-9]$/.test(previous)) return null;
|
|
259
|
+
const nextChar = text[start + 1] ?? "";
|
|
260
|
+
if (!nextChar || /^\s$/.test(nextChar)) return null;
|
|
261
|
+
const end = findSingle(text, start + 1, char);
|
|
262
|
+
if (end > start + 1) {
|
|
263
|
+
const inner = text.slice(start + 1, end);
|
|
264
|
+
if (inner.trim() && !/^\s|\s$/.test(inner)) {
|
|
265
|
+
return {
|
|
266
|
+
html: `<em>${renderInline(inner, depth + 1)}</em>`,
|
|
267
|
+
next: end + 1,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function trimBareUrl(url) {
|
|
275
|
+
let value = url;
|
|
276
|
+
while (/[.,;:!?]$/.test(value)) value = value.slice(0, -1);
|
|
277
|
+
while (value.endsWith(")")) {
|
|
278
|
+
const open = (value.match(/\(/g) ?? []).length;
|
|
279
|
+
const close = (value.match(/\)/g) ?? []).length;
|
|
280
|
+
if (open >= close) break;
|
|
281
|
+
value = value.slice(0, -1);
|
|
282
|
+
}
|
|
283
|
+
return value;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function renderAutolink(text, start, depth) {
|
|
287
|
+
if (text[start] === "<") {
|
|
288
|
+
const angle = /^<((?:https?:\/\/|mailto:)[^>\s]+)>/i.exec(text.slice(start));
|
|
289
|
+
if (angle) {
|
|
290
|
+
const url = angle[1];
|
|
291
|
+
return {
|
|
292
|
+
html: `<a href="${escapeAttribute(url)}" target="_blank" rel="noopener noreferrer">${escapeText(url)}</a>`,
|
|
293
|
+
next: start + angle[0].length,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
const email = /^<([A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})>/.exec(text.slice(start));
|
|
297
|
+
if (email) {
|
|
298
|
+
return {
|
|
299
|
+
html: `<a href="mailto:${escapeAttribute(email[1])}" target="_blank" rel="noopener noreferrer">${escapeText(email[1])}</a>`,
|
|
300
|
+
next: start + email[0].length,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
const bare = /^(?:https?:\/\/)[^\s<>"'`]+/i.exec(text.slice(start));
|
|
306
|
+
if (!bare) return null;
|
|
307
|
+
const previous = text[start - 1] ?? "";
|
|
308
|
+
if (!isOpeningBoundary(previous)) return null;
|
|
309
|
+
const url = trimBareUrl(bare[0]);
|
|
310
|
+
if (!url) return null;
|
|
311
|
+
return {
|
|
312
|
+
html: `<a href="${escapeAttribute(url)}" target="_blank" rel="noopener noreferrer">${escapeText(url)}</a>`,
|
|
313
|
+
next: start + url.length,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const INLINE_SPECIAL = /[\\`[!<*_~\nhH]/;
|
|
318
|
+
|
|
319
|
+
function renderInline(source, depth = 0) {
|
|
320
|
+
const text = String(source ?? "");
|
|
321
|
+
if (!text) return "";
|
|
322
|
+
if (depth > 8) return escapeText(text);
|
|
323
|
+
|
|
324
|
+
let out = "";
|
|
325
|
+
let i = 0;
|
|
326
|
+
while (i < text.length) {
|
|
327
|
+
const ch = text[i];
|
|
328
|
+
|
|
329
|
+
if (ch === "\\" && i + 1 < text.length) {
|
|
330
|
+
if (text[i + 1] === "\n") out += "<br>";
|
|
331
|
+
else out += escapeText(text[i + 1]);
|
|
332
|
+
i += 2;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (ch === "`") {
|
|
337
|
+
const code = parseCodeSpan(text, i);
|
|
338
|
+
if (code) {
|
|
339
|
+
out += code.html;
|
|
340
|
+
i = code.next;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (ch === "!" && text[i + 1] === "[") {
|
|
346
|
+
const image = renderLinkOrImage(text, i, true, depth);
|
|
347
|
+
if (image) {
|
|
348
|
+
out += image.html;
|
|
349
|
+
i = image.next;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (ch === "[") {
|
|
355
|
+
const link = renderLinkOrImage(text, i, false, depth);
|
|
356
|
+
if (link) {
|
|
357
|
+
out += link.html;
|
|
358
|
+
i = link.next;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (ch === "<") {
|
|
364
|
+
const auto = renderAutolink(text, i, depth);
|
|
365
|
+
if (auto) {
|
|
366
|
+
out += auto.html;
|
|
367
|
+
i = auto.next;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (ch === "*" || ch === "_") {
|
|
373
|
+
const emphasis = renderEmphasis(text, i, depth);
|
|
374
|
+
if (emphasis) {
|
|
375
|
+
out += emphasis.html;
|
|
376
|
+
i = emphasis.next;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (ch === "~" && text.startsWith("~~", i)) {
|
|
382
|
+
const end = text.indexOf("~~", i + 2);
|
|
383
|
+
if (end > i + 2) {
|
|
384
|
+
const inner = text.slice(i + 2, end);
|
|
385
|
+
if (inner.trim() && !/^\s|\s$/.test(inner)) {
|
|
386
|
+
out += `<del>${renderInline(inner, depth + 1)}</del>`;
|
|
387
|
+
i = end + 2;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (ch === "h" || ch === "H") {
|
|
394
|
+
const auto = renderAutolink(text, i, depth);
|
|
395
|
+
if (auto) {
|
|
396
|
+
out += auto.html;
|
|
397
|
+
i = auto.next;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (ch === "\n") {
|
|
403
|
+
out += "<br>";
|
|
404
|
+
i += 1;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Consume a run of ordinary text in one escaped chunk.
|
|
409
|
+
let j = i + 1;
|
|
410
|
+
while (j < text.length && !INLINE_SPECIAL.test(text[j])) {
|
|
411
|
+
j += 1;
|
|
412
|
+
}
|
|
413
|
+
out += escapeText(text.slice(i, j));
|
|
414
|
+
i = j;
|
|
415
|
+
}
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// -------------------------------------------------------------- block HTML
|
|
420
|
+
|
|
421
|
+
function isFence(line) {
|
|
422
|
+
const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
423
|
+
if (!match) return null;
|
|
424
|
+
return { fence: match[1], info: match[2].trim() };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function isHeading(line) {
|
|
428
|
+
const match = /^(#{1,6})[ \t]+(.*)$/.exec(line);
|
|
429
|
+
if (!match) return null;
|
|
430
|
+
const content = match[2].replace(/[ \t]+#+[ \t]*$/, "").trim();
|
|
431
|
+
return { level: match[1].length, content };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function isRule(line) {
|
|
435
|
+
return /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/.test(line);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function isQuote(line) {
|
|
439
|
+
return /^ {0,3}>/.test(line);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function matchList(line) {
|
|
443
|
+
const match = /^( *)([-*+]|\d{1,9}[.)])(?:[ \t]+(.*))?$/.exec(line);
|
|
444
|
+
if (!match) return null;
|
|
445
|
+
const marker = match[2];
|
|
446
|
+
return {
|
|
447
|
+
indent: match[1].length,
|
|
448
|
+
marker,
|
|
449
|
+
ordered: /^\d/.test(marker),
|
|
450
|
+
number: /^\d/.test(marker) ? Number.parseInt(marker, 10) : 0,
|
|
451
|
+
content: match[3] ?? "",
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function splitTableRow(line) {
|
|
456
|
+
let text = String(line ?? "").trim();
|
|
457
|
+
if (text.startsWith("|")) text = text.slice(1);
|
|
458
|
+
if (text.endsWith("|") && !text.endsWith("\\|")) text = text.slice(0, -1);
|
|
459
|
+
const cells = [];
|
|
460
|
+
let current = "";
|
|
461
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
462
|
+
if (text[i] === "\\" && text[i + 1] === "|") {
|
|
463
|
+
current += "|";
|
|
464
|
+
i += 1;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (text[i] === "|") {
|
|
468
|
+
cells.push(current.trim());
|
|
469
|
+
current = "";
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
current += text[i];
|
|
473
|
+
}
|
|
474
|
+
cells.push(current.trim());
|
|
475
|
+
return cells;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function isDelimiterCell(cell) {
|
|
479
|
+
return /^:?-{1,}:?$/.test(String(cell ?? "").trim());
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function looksLikeTable(lines, index) {
|
|
483
|
+
if (index + 1 >= lines.length) return false;
|
|
484
|
+
if (!lines[index].includes("|")) return false;
|
|
485
|
+
const header = splitTableRow(lines[index]);
|
|
486
|
+
const delimiter = splitTableRow(lines[index + 1]);
|
|
487
|
+
return header.length >= 1 && header.length === delimiter.length && delimiter.every(isDelimiterCell);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function tableAlignment(cell) {
|
|
491
|
+
const text = String(cell ?? "").trim();
|
|
492
|
+
if (text.startsWith(":") && text.endsWith(":")) return "center";
|
|
493
|
+
if (text.endsWith(":")) return "right";
|
|
494
|
+
return "left";
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function renderTable(lines, index, depth) {
|
|
498
|
+
const header = splitTableRow(lines[index]);
|
|
499
|
+
const delimiter = splitTableRow(lines[index + 1]);
|
|
500
|
+
const align = delimiter.map(tableAlignment);
|
|
501
|
+
let i = index + 2;
|
|
502
|
+
const rows = [];
|
|
503
|
+
while (i < lines.length) {
|
|
504
|
+
const line = lines[i];
|
|
505
|
+
if (!line.trim() || !line.includes("|")) break;
|
|
506
|
+
const cells = splitTableRow(line);
|
|
507
|
+
if (cells.length === 1 && !line.trim().includes("|")) break;
|
|
508
|
+
rows.push(cells);
|
|
509
|
+
i += 1;
|
|
510
|
+
}
|
|
511
|
+
const cell = (tag, content, column) =>
|
|
512
|
+
`<${tag} class="align-${align[column] ?? "left"}">${renderInline(content, depth + 1)}</${tag}>`;
|
|
513
|
+
let html = '<div class="table-wrap"><table><thead><tr>';
|
|
514
|
+
for (let column = 0; column < header.length; column += 1) html += cell("th", header[column], column);
|
|
515
|
+
html += "</tr></thead><tbody>";
|
|
516
|
+
for (const row of rows) {
|
|
517
|
+
const padded = [...row];
|
|
518
|
+
while (padded.length < header.length) padded.push("");
|
|
519
|
+
html += "<tr>";
|
|
520
|
+
for (let column = 0; column < header.length; column += 1) html += cell("td", padded[column], column);
|
|
521
|
+
html += "</tr>";
|
|
522
|
+
}
|
|
523
|
+
html += "</tbody></table></div>";
|
|
524
|
+
return { html, next: i };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function collectQuote(lines, start) {
|
|
528
|
+
const quoteLines = [];
|
|
529
|
+
let i = start;
|
|
530
|
+
while (i < lines.length && isQuote(lines[i])) {
|
|
531
|
+
quoteLines.push(lines[i].replace(/^ {0,3}> ?/, ""));
|
|
532
|
+
i += 1;
|
|
533
|
+
}
|
|
534
|
+
return { lines: quoteLines, next: i };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function collectList(lines, start) {
|
|
538
|
+
const first = matchList(lines[start]);
|
|
539
|
+
const baseIndent = first.indent;
|
|
540
|
+
const ordered = first.ordered;
|
|
541
|
+
let html = ordered
|
|
542
|
+
? `<ol${first.number > 1 ? ` start="${first.number}"` : ""}>`
|
|
543
|
+
: "<ul>";
|
|
544
|
+
let i = start;
|
|
545
|
+
while (i < lines.length) {
|
|
546
|
+
const item = matchList(lines[i]);
|
|
547
|
+
if (!item || item.indent !== baseIndent || item.ordered !== ordered) break;
|
|
548
|
+
const contentLines = [item.content];
|
|
549
|
+
i += 1;
|
|
550
|
+
|
|
551
|
+
while (i < lines.length) {
|
|
552
|
+
const line = lines[i];
|
|
553
|
+
if (!line.trim()) {
|
|
554
|
+
let next = i + 1;
|
|
555
|
+
while (next < lines.length && !lines[next].trim()) next += 1;
|
|
556
|
+
if (next >= lines.length) {
|
|
557
|
+
i = next;
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
const following = matchList(lines[next]);
|
|
561
|
+
const followingIndent = following ? following.indent : indentOf(lines[next]);
|
|
562
|
+
if ((following && followingIndent >= baseIndent) || followingIndent > baseIndent) {
|
|
563
|
+
contentLines.push("");
|
|
564
|
+
i += 1;
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
const following = matchList(line);
|
|
570
|
+
const indent = indentOf(line);
|
|
571
|
+
if (following && indent === baseIndent) break;
|
|
572
|
+
if (indent <= baseIndent) break;
|
|
573
|
+
contentLines.push(line.slice(Math.min(indent, baseIndent + 2)));
|
|
574
|
+
i += 1;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const task = /^\[([ xX])\][ \t]+([\s\S]*)$/.exec(contentLines[0] ?? "");
|
|
578
|
+
if (task) contentLines[0] = task[2];
|
|
579
|
+
let body = renderBlocks(contentLines, 0, true);
|
|
580
|
+
if (task) {
|
|
581
|
+
const checked = task[1].toLowerCase() === "x";
|
|
582
|
+
const checkbox = `<input type="checkbox" class="task-checkbox" disabled${checked ? " checked" : ""}> `;
|
|
583
|
+
body = body.startsWith("<p>") ? `<p>${checkbox}${body.slice(3)}` : `${checkbox}${body}`;
|
|
584
|
+
}
|
|
585
|
+
html += `<li${task ? ' class="task-item"' : ""}>${body || ""}</li>`;
|
|
586
|
+
}
|
|
587
|
+
html += ordered ? "</ol>" : "</ul>";
|
|
588
|
+
return { html, next: i };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function startsBlock(lines, index) {
|
|
592
|
+
const line = lines[index];
|
|
593
|
+
if (isFence(line) || isHeading(line) || isRule(line) || isQuote(line) || matchList(line)) return true;
|
|
594
|
+
if (/^ {4}/.test(line)) return true;
|
|
595
|
+
return looksLikeTable(lines, index);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function renderBlocks(lines, depth = 0, listItem = false) {
|
|
599
|
+
if (depth > 16) return `<p>${renderInline(lines.join("\n"), 0)}</p>`;
|
|
600
|
+
let html = "";
|
|
601
|
+
let i = 0;
|
|
602
|
+
while (i < lines.length) {
|
|
603
|
+
const line = lines[i];
|
|
604
|
+
if (isBlank(line)) {
|
|
605
|
+
i += 1;
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const fence = isFence(line);
|
|
610
|
+
if (fence) {
|
|
611
|
+
const marker = fence.fence[0];
|
|
612
|
+
const length = fence.fence.length;
|
|
613
|
+
const close = new RegExp(`^ {0,3}${marker === "`" ? "`" : "~"}{${length},}[ \\t]*$`);
|
|
614
|
+
const code = [];
|
|
615
|
+
i += 1;
|
|
616
|
+
while (i < lines.length && !close.test(lines[i])) {
|
|
617
|
+
code.push(lines[i]);
|
|
618
|
+
i += 1;
|
|
619
|
+
}
|
|
620
|
+
if (i < lines.length) i += 1;
|
|
621
|
+
const language = (fence.info.split(/[ \t]+/)[0] ?? "").replace(/[^\w#+.-]/g, "");
|
|
622
|
+
html += `<pre class="code"><code${language ? ` class="language-${language}"` : ""}>${escapeHtml(code.join("\n"))}</code></pre>`;
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const heading = isHeading(line);
|
|
627
|
+
if (heading) {
|
|
628
|
+
html += `<h${heading.level}>${renderInline(heading.content, depth + 1)}</h${heading.level}>`;
|
|
629
|
+
i += 1;
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (
|
|
634
|
+
i + 1 < lines.length
|
|
635
|
+
&& !isBlank(line)
|
|
636
|
+
&& !isFence(line)
|
|
637
|
+
&& !isHeading(line)
|
|
638
|
+
&& !isRule(line)
|
|
639
|
+
&& !isQuote(line)
|
|
640
|
+
&& !matchList(line)
|
|
641
|
+
&& /^ {0,3}(=+|-+)[ \t]*$/.test(lines[i + 1])
|
|
642
|
+
) {
|
|
643
|
+
const level = lines[i + 1].trim().startsWith("=") ? 1 : 2;
|
|
644
|
+
html += `<h${level}>${renderInline(line.trim(), depth + 1)}</h${level}>`;
|
|
645
|
+
i += 2;
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (isRule(line)) {
|
|
650
|
+
html += "<hr>";
|
|
651
|
+
i += 1;
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (isQuote(line)) {
|
|
656
|
+
const quote = collectQuote(lines, i);
|
|
657
|
+
html += `<blockquote>${renderBlocks(quote.lines, depth + 1)}</blockquote>`;
|
|
658
|
+
i = quote.next;
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (looksLikeTable(lines, i)) {
|
|
663
|
+
const table = renderTable(lines, i, depth);
|
|
664
|
+
html += table.html;
|
|
665
|
+
i = table.next;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (matchList(line)) {
|
|
670
|
+
const list = collectList(lines, i);
|
|
671
|
+
html += list.html;
|
|
672
|
+
i = list.next;
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (/^ {4}/.test(line)) {
|
|
677
|
+
const code = [];
|
|
678
|
+
while (i < lines.length && (isBlank(lines[i]) || /^ {4}/.test(lines[i]))) {
|
|
679
|
+
code.push(lines[i].replace(/^ {4}/, ""));
|
|
680
|
+
i += 1;
|
|
681
|
+
}
|
|
682
|
+
while (code.length && !code[code.length - 1].trim()) code.pop();
|
|
683
|
+
html += `<pre class="code"><code>${escapeHtml(code.join("\n"))}</code></pre>`;
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const paragraph = [];
|
|
688
|
+
while (i < lines.length) {
|
|
689
|
+
if (isBlank(lines[i])) break;
|
|
690
|
+
if (paragraph.length > 0 && startsBlock(lines, i)) break;
|
|
691
|
+
paragraph.push(lines[i].trim());
|
|
692
|
+
i += 1;
|
|
693
|
+
}
|
|
694
|
+
if (paragraph.length) {
|
|
695
|
+
html += `<p>${renderInline(paragraph.join("\n"), depth + 1)}</p>`;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return html;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Render markdown to safe HTML. `listItem` is internal recursion state.
|
|
703
|
+
*/
|
|
704
|
+
export function renderMarkdown(source) {
|
|
705
|
+
const text = String(source ?? "").replace(/\r\n?/g, "\n").replace(/\t/g, " ");
|
|
706
|
+
if (!text.trim()) return "";
|
|
707
|
+
return renderBlocks(text.split("\n"));
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
export { escapeHtml, escapeText };
|