bothouse-dsh 0.1.57 → 0.1.59
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.en.md +3 -2
- package/README.md +3 -2
- package/collab/attachments.mjs +115 -1
- package/collab/cli-member.mjs +69 -11
- package/collab/cloudweb.mjs +4 -1
- package/collab/devices.html +94 -12
- package/collab/dsh-member.mjs +114 -12
- package/collab/instance-lock.mjs +135 -2
- package/collab/local-host.mjs +106 -0
- package/collab/mentions.mjs +626 -12
- package/collab/plugin.mjs +74 -20
- package/collab/roster.mjs +6 -1
- package/collab/store.mjs +9 -1
- package/collab/team-mode-rules.md +1 -1
- package/node_modules/bothouse-dsh-channels/client.js +1213 -169
- package/node_modules/bothouse-dsh-devices/client.js +70 -5
- package/package.json +4 -7
package/collab/mentions.mjs
CHANGED
|
@@ -6,6 +6,10 @@ const FENCE = '```';
|
|
|
6
6
|
// Punctuation after a bare name that counts as calling that member
|
|
7
7
|
// (bridge/platform/discord/mentions.go nameCalled).
|
|
8
8
|
const CALL_PUNCT = ',,::!!??、~~';
|
|
9
|
+
// Word characters for the mention boundaries below (#237). Same set the channel page colours
|
|
10
|
+
// with (client-ui-discord buildMentionMatcher): an @ inside an email or followed by a longer
|
|
11
|
+
// ascii word is not a call, while a Chinese name still calls when a character follows it.
|
|
12
|
+
const WORD = 'a-zA-Z0-9_';
|
|
9
13
|
|
|
10
14
|
function escape(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
|
11
15
|
|
|
@@ -14,28 +18,638 @@ function aliases(members) {
|
|
|
14
18
|
const out = [];
|
|
15
19
|
for (const m of members) {
|
|
16
20
|
out.push(['@' + m.name, m.id], ['@' + m.id, m.id]);
|
|
21
|
+
if (m.id) out.push([`<@${m.id}>`, m.id], [`<@!${m.id}>`, m.id]);
|
|
17
22
|
if (m.mentionId) out.push([`<@${m.mentionId}>`, m.id], [`<@!${m.mentionId}>`, m.id]);
|
|
18
23
|
}
|
|
19
24
|
return out.sort((a, b) => b[0].length - a[0].length);
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* The alias pattern for one member list, with the same boundaries the channel page colours with
|
|
29
|
+
* (#237, client-ui-discord buildMentionMatcher): no WORD character before an alias, and none
|
|
30
|
+
* right after it when the alias itself ends in one. So `xx@dsh.ai` and `@dsh2` are not calls
|
|
31
|
+
* while a Chinese name still calls with a character tacked on, and `<@1001>` is matched the way
|
|
32
|
+
* the page matches it too.
|
|
33
|
+
*/
|
|
34
|
+
function mentionPattern(list) {
|
|
35
|
+
const parts = list.map(([a]) => {
|
|
36
|
+
const escaped = escape(a);
|
|
37
|
+
const tail = new RegExp(`[${WORD}]$`).test(a) ? `(?![${WORD}])` : '';
|
|
38
|
+
return `(?<![${WORD}])${escaped}${tail}`;
|
|
39
|
+
});
|
|
40
|
+
return new RegExp(parts.join('|'), 'g');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** [start, end) of every ``` fence body; an unterminated fence runs to the end of the text. */
|
|
44
|
+
function fenceRanges(text) {
|
|
45
|
+
const ranges = [];
|
|
46
|
+
let open = -1;
|
|
47
|
+
let at = text.indexOf(FENCE);
|
|
48
|
+
while (at >= 0) {
|
|
49
|
+
if (open < 0) {
|
|
50
|
+
open = at + FENCE.length;
|
|
51
|
+
} else {
|
|
52
|
+
ranges.push([open, at]);
|
|
53
|
+
open = -1;
|
|
54
|
+
}
|
|
55
|
+
at = text.indexOf(FENCE, at + FENCE.length);
|
|
56
|
+
}
|
|
57
|
+
if (open >= 0) ranges.push([open, text.length]);
|
|
58
|
+
return ranges;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* True when the text holds a blank line: two line endings with only spaces or tabs between.
|
|
63
|
+
* A `code span` cannot cross one — the blank line ends the paragraph, so the renderer closes the
|
|
64
|
+
* span before it (#245: `@肥鱼` + blank line + `@G哥` inside one pair of backticks is plain text
|
|
65
|
+
* on the page, and must be plain text here too, or the page colours two and nobody is woken).
|
|
66
|
+
*/
|
|
67
|
+
function hasBlankLine(text) {
|
|
68
|
+
// CRLF is one line ending: normalize this copy so regex backtracking cannot split it (#246).
|
|
69
|
+
// The original text and span offsets stay untouched.
|
|
70
|
+
return /\n[ \t]*\n/.test(text.replace(/\r\n?/g, '\n'));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// HTML block tags that start a block on their own line (CommonMark HTML block type 6), and a
|
|
74
|
+
// lone open/closing tag (type 7), which opens one only where it cannot continue a paragraph.
|
|
75
|
+
const HTML_BLOCK_TAGS =
|
|
76
|
+
'address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|' +
|
|
77
|
+
'dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|' +
|
|
78
|
+
'header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|' +
|
|
79
|
+
'param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul';
|
|
80
|
+
const HTML_BLOCK_TAG = new RegExp(`^ {0,3}</?(?:${HTML_BLOCK_TAGS})(?:[ \\t]|/?>|$)`, 'i');
|
|
81
|
+
const HTML_ANY_TAG = /^ {0,3}<\/?[A-Za-z][A-Za-z0-9-]*(?:[ \t]+[^<>]*)?[ \t]*\/?>[ \t]*$/;
|
|
82
|
+
// A list item marker, and the line that opens a fence, used to find the container of a span.
|
|
83
|
+
const LIST_MARKER = /^ {0,3}(?:[-*+]|\d{1,9}[.)])(?=[ \t]|$)/;
|
|
84
|
+
const FENCE_LINE = /^ {0,3}(?:`{3,}|~{3,})/;
|
|
85
|
+
|
|
86
|
+
/** Columns the leading indent of `text` covers, starting from column `base`. */
|
|
87
|
+
function indentWidth(text, base = 0) {
|
|
88
|
+
let col = base;
|
|
89
|
+
for (const ch of text) {
|
|
90
|
+
if (ch === ' ') col += 1;
|
|
91
|
+
else if (ch === '\t') col += 4 - (col % 4);
|
|
92
|
+
else break;
|
|
93
|
+
}
|
|
94
|
+
return col - base;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The blockquote prefix at `line[i]` (three spaces at most, then `>` and one optional blank), read
|
|
99
|
+
* from column `col`; `{ next, col }` after it, or null when the line has none there.
|
|
100
|
+
*/
|
|
101
|
+
function quotePrefixAt(line, i, col) {
|
|
102
|
+
let at = col;
|
|
103
|
+
let spaces = 0;
|
|
104
|
+
while (spaces < 4 && line[i] === ' ') {
|
|
105
|
+
i++;
|
|
106
|
+
at++;
|
|
107
|
+
spaces++;
|
|
108
|
+
}
|
|
109
|
+
if (spaces > 3 || line[i] !== '>') return null;
|
|
110
|
+
i++;
|
|
111
|
+
at++;
|
|
112
|
+
if (line[i] === ' ' || line[i] === '\t') {
|
|
113
|
+
at += line[i] === '\t' ? 4 - (at % 4) : 1;
|
|
114
|
+
i++;
|
|
115
|
+
}
|
|
116
|
+
return { next: i, col: at };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Strips every blockquote marker of a line, so its content can be measured from column 0. */
|
|
120
|
+
function stripQuotes(line) {
|
|
121
|
+
let i = 0;
|
|
122
|
+
let col = 0;
|
|
123
|
+
let depth = 0;
|
|
124
|
+
for (;;) {
|
|
125
|
+
const prefix = quotePrefixAt(line, i, col);
|
|
126
|
+
if (!prefix) return { rest: line.slice(i), depth, col };
|
|
127
|
+
i = prefix.next;
|
|
128
|
+
col = prefix.col;
|
|
129
|
+
depth++;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Column where a list item's content begins, or null when the line is not a list item. An empty
|
|
135
|
+
* marker (`1.` alone on its line) cannot open a list inside a paragraph, so it counts as neither.
|
|
136
|
+
*/
|
|
137
|
+
function itemContentIndent(line) {
|
|
138
|
+
const { rest, col } = stripQuotes(line);
|
|
139
|
+
const lead = indentWidth(rest, col);
|
|
140
|
+
if (lead >= 4) return null; // four columns of indent is code, not a list item
|
|
141
|
+
const body = rest.slice(/^[ \t]*/.exec(rest)[0].length);
|
|
142
|
+
const marker = LIST_MARKER.exec(body);
|
|
143
|
+
if (!marker) return null;
|
|
144
|
+
if (body.slice(marker[0].length).trim() === '') return null;
|
|
145
|
+
const gap = /^[ \t]*/.exec(body.slice(marker[0].length))[0];
|
|
146
|
+
const padding = gap === '' ? 1 : Math.min(indentWidth(gap, col + lead + marker[0].length), 4);
|
|
147
|
+
return lead + marker[0].length + padding;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* True when a line whose content starts with `content` opens one of the block-level structures
|
|
152
|
+
* this rule knows, ending the paragraph before it: ATX headings, setext underlines, thematic
|
|
153
|
+
* breaks, backtick fences, list items, HTML blocks and a further quote marker. Tilde fences and
|
|
154
|
+
* math blocks also end a paragraph in CommonMark, but this backend reads neither, so treating them
|
|
155
|
+
* as spans keeps a different bug out of this change (#259 stays on the six families the report
|
|
156
|
+
* names; the deviation list above split() records both). A footnote definition is handled by
|
|
157
|
+
* footnoteRanges(), not here: it ends a body, not the paragraph a code span sits in (#271).
|
|
158
|
+
* `container` is set for a line that lazily continues an outer container: there a bare list
|
|
159
|
+
* marker or a lone HTML tag already opens a block the paragraph cannot hold.
|
|
160
|
+
*/
|
|
161
|
+
function startsBlock(content, setext = true, container = false) {
|
|
162
|
+
if (indentWidth(content) >= 4) return false; // four columns of indent is code, not a block
|
|
163
|
+
if (container && /^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)/.test(content)) return true;
|
|
164
|
+
if (container && HTML_ANY_TAG.test(content)) return true;
|
|
165
|
+
return (
|
|
166
|
+
/^ {0,3}#{1,6}(?:[ \t]|$)/.test(content) || // ATX heading
|
|
167
|
+
(setext && /^ {0,3}(?:=+|-+)[ \t]*$/.test(content)) || // setext underline
|
|
168
|
+
/^ {0,3}(?:[*\-_][ \t]*){3,}$/.test(content) || // thematic break
|
|
169
|
+
/^ {0,3}`{3,}[^`]*$/.test(content) || // backtick fence
|
|
170
|
+
/^ {0,3}>/.test(content) || // a further quote marker
|
|
171
|
+
/^ {0,3}[-*+][ \t]+\S/.test(content) || // bullet item with content
|
|
172
|
+
/^ {0,3}1[.)][ \t]+\S/.test(content) || // ordered item with content
|
|
173
|
+
/^ {0,3}<(?:script|pre|style|textarea)(?:[ \t>]|$)/i.test(content) || // HTML blocks 1-5
|
|
174
|
+
/^ {0,3}<!--/.test(content) ||
|
|
175
|
+
/^ {0,3}<\?/.test(content) ||
|
|
176
|
+
/^ {0,3}<![A-Za-z]/.test(content) ||
|
|
177
|
+
/^ {0,3}<!\[CDATA\[/.test(content) ||
|
|
178
|
+
HTML_BLOCK_TAG.test(content)
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* [start, end) offsets of every physical line; CRLF, CR and LF all end one. Read once per
|
|
184
|
+
* codeSpanRanges() call and handed to lineIndex(), not rescanned per backtick run (#271: doing
|
|
185
|
+
* that cost O(runs × lines) — 128 KiB of one-backtick lines took 54 s inside hub.post's
|
|
186
|
+
* transaction).
|
|
187
|
+
*/
|
|
188
|
+
function splitLines(text) {
|
|
189
|
+
const lines = [];
|
|
190
|
+
for (let start = 0; start <= text.length;) {
|
|
191
|
+
let end = start;
|
|
192
|
+
while (end < text.length && text[end] !== '\n' && text[end] !== '\r') end++;
|
|
193
|
+
lines.push([start, end]);
|
|
194
|
+
if (end >= text.length) break;
|
|
195
|
+
start = text[end] === '\r' && text[end + 1] === '\n' ? end + 2 : end + 1;
|
|
196
|
+
}
|
|
197
|
+
return lines;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The line holding `offset`: the last line that starts at or before it. `splitLines` covers the
|
|
202
|
+
* whole text contiguously, so that is the line `offset` sits on, or the one that ends there.
|
|
203
|
+
*/
|
|
204
|
+
function lineAt(lines, offset) {
|
|
205
|
+
let lo = 0;
|
|
206
|
+
let hi = lines.length - 1;
|
|
207
|
+
while (lo < hi) {
|
|
208
|
+
const mid = (lo + hi + 1) >> 1;
|
|
209
|
+
if (lines[mid][0] <= offset) lo = mid;
|
|
210
|
+
else hi = mid - 1;
|
|
211
|
+
}
|
|
212
|
+
return lo;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* What endsParagraph() reads off one line, computed once per line and reused by every backtick run
|
|
217
|
+
* on it (#271). `blank`/`fence` come from the raw line (a line with only a quote marker is blank
|
|
218
|
+
* too), as does `itemIndent`; `rest`/`depth`/`col` are the quote split; `atDepth` is the block
|
|
219
|
+
* test a lazy continuation uses. The setext-aware test depends on the span's list indent, so it
|
|
220
|
+
* stays in endsParagraph() — only the reads that cost a rescan of the line were hoisted.
|
|
221
|
+
*/
|
|
222
|
+
function lineFacts(text, lines, i) {
|
|
223
|
+
const line = text.slice(lines[i][0], lines[i][1]);
|
|
224
|
+
const { rest, depth, col } = stripQuotes(line);
|
|
225
|
+
return {
|
|
226
|
+
blank: line.trim() === '',
|
|
227
|
+
fence: FENCE_LINE.test(line),
|
|
228
|
+
itemIndent: itemContentIndent(line),
|
|
229
|
+
rest,
|
|
230
|
+
depth,
|
|
231
|
+
col,
|
|
232
|
+
atDepth: startsBlock(rest, false, true),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Everything endsParagraph() needs about `text`, read once per codeSpanRanges() call (#271): the
|
|
238
|
+
* line split, the per-line facts, and `contained[i]` — the {baseIndent, depth} a walk back from
|
|
239
|
+
* line i over the lines its paragraph already holds reaches, stopping at a blank line or a fence,
|
|
240
|
+
* which close the block a later paragraph cannot continue.
|
|
241
|
+
*/
|
|
242
|
+
function lineIndex(text) {
|
|
243
|
+
const lines = splitLines(text);
|
|
244
|
+
const facts = lines.map((_, i) => lineFacts(text, lines, i));
|
|
245
|
+
const contained = new Array(lines.length);
|
|
246
|
+
for (let i = 0; i < lines.length; i++) {
|
|
247
|
+
const f = facts[i];
|
|
248
|
+
if (f.blank || f.fence) {
|
|
249
|
+
contained[i] = { baseIndent: 0, depth: 0 };
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const run = i > 0 && !facts[i - 1].blank && !facts[i - 1].fence ? contained[i - 1] : null;
|
|
253
|
+
contained[i] = {
|
|
254
|
+
baseIndent: f.itemIndent !== null ? f.itemIndent : run ? run.baseIndent : 0,
|
|
255
|
+
depth: Math.max(f.depth, run ? run.depth : 0),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
return { lines, facts, contained };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* True when the text between an opening and a closing backtick run holds something that ends the
|
|
263
|
+
* paragraph, so the run cannot close the span (#259): a blank line, or a line that opens a
|
|
264
|
+
* block-level structure. The renderer decides this inside the containers the span sits in, so the
|
|
265
|
+
* list item's content indent and the blockquote depth of the opening line are carried along, and
|
|
266
|
+
* a line that lazily continues an outer container is judged by what it would start there.
|
|
267
|
+
*
|
|
268
|
+
* `index` is the lineIndex(text) of the whole text, shared by every pair (#271): rebuilt per pair
|
|
269
|
+
* it cost O(runs × lines), 128 KiB of one-backtick lines taking 54 s.
|
|
270
|
+
*/
|
|
271
|
+
function endsParagraph(text, from, to, index) {
|
|
272
|
+
if (hasBlankLine(text.slice(from, to))) return true;
|
|
273
|
+
const { lines, facts, contained } = index;
|
|
274
|
+
const openLine = lineAt(lines, from);
|
|
275
|
+
const { baseIndent, depth: openDepth } = contained[openLine];
|
|
276
|
+
for (let i = openLine + 1; i < lines.length && lines[i][0] < to; i++) {
|
|
277
|
+
const f = facts[i];
|
|
278
|
+
if (f.rest.trim() === '') return true; // a blank line, with or without a quote marker
|
|
279
|
+
if (f.depth > openDepth) return true; // a deeper quote starts a new block
|
|
280
|
+
if (f.depth < openDepth) {
|
|
281
|
+
// A lazy continuation: only a block start ends the paragraph here, never a setext underline.
|
|
282
|
+
if (f.atDepth) return true;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const indent = indentWidth(f.rest, f.col) - baseIndent;
|
|
286
|
+
// A line shallower than the item's content lazily continues it, so a setext underline there
|
|
287
|
+
// cannot turn the paragraph into a heading.
|
|
288
|
+
if (startsBlock(' '.repeat(Math.max(0, indent)) + f.rest.trimStart(), indent >= 0, indent < 0)) {
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* [start, end) of every Markdown inline code span (`@name`, ``a `b` c``), the spans the channel
|
|
297
|
+
* page renders as <code> and never colours. A span is a run of backticks closed by a run of the
|
|
298
|
+
* same length inside the same paragraph (#237, dsh/packages/client-ui-discord/src/client/
|
|
299
|
+
* mention-highlight.tsx codeSpanRanges — keep the two in step); the content may hold single line
|
|
300
|
+
* breaks but nothing that ends the paragraph (a blank line #245, or a block-level structure
|
|
301
|
+
* #259), a lone backtick stays literal text, and runs of three or more are fences, read by
|
|
302
|
+
* fenceRanges().
|
|
303
|
+
*/
|
|
304
|
+
function codeSpanRanges(text) {
|
|
305
|
+
const runs = [];
|
|
306
|
+
for (let i = 0; i < text.length;) {
|
|
307
|
+
if (text[i] !== '`') { i++; continue; }
|
|
308
|
+
let j = i;
|
|
309
|
+
while (j < text.length && text[j] === '`') j++;
|
|
310
|
+
runs.push({ start: i, len: j - i });
|
|
311
|
+
i = j;
|
|
312
|
+
}
|
|
313
|
+
const ranges = [];
|
|
314
|
+
// The line index is only built when a pair actually has to be judged, so a text without
|
|
315
|
+
// backticks (or with a single run) pays nothing (#271).
|
|
316
|
+
let index = null;
|
|
317
|
+
for (let k = 0; k < runs.length; k++) {
|
|
318
|
+
const { start, len } = runs[k];
|
|
319
|
+
if (len >= 3) continue; // a fence, not an inline span
|
|
320
|
+
for (let c = k + 1; c < runs.length; c++) {
|
|
321
|
+
if (runs[c].len !== len) continue;
|
|
322
|
+
if (!index) index = lineIndex(text);
|
|
323
|
+
// the paragraph ends before this run, so it cannot close the span (#245, #259); the opener
|
|
324
|
+
// stays literal text and the run is tried as the next opener
|
|
325
|
+
if (endsParagraph(text, start + len, runs[c].start, index)) break;
|
|
326
|
+
ranges.push([start + len, runs[c].start]);
|
|
327
|
+
k = c; // the closing run cannot open another span
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return ranges;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** True when [index, index+len) sits inside one of the excluded ranges. */
|
|
335
|
+
function excluded(ranges, index, len) {
|
|
336
|
+
return ranges.some(([start, end]) => index < end && index + len > start);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------------------------
|
|
340
|
+
// Footnote definitions (#271). The renderer hides a definition whose label nobody calls, together
|
|
341
|
+
// with the lines that continue it; a `@name` inside such a body is not on the page, so waking it
|
|
342
|
+
// is the same bug as waking a name inside a fence. A body is hidden if it holds no reference a
|
|
343
|
+
// reader can see, so the check is a fixed point: a reference inside a hidden body counts for
|
|
344
|
+
// nothing, and a definition referring to itself stays hidden.
|
|
345
|
+
|
|
346
|
+
/** A definition line: `[^label]:` at 0-3 columns, the label holding no space or bracket. */
|
|
347
|
+
const FOOTNOTE_DEF = /^ {0,3}\[\^([^\]\s]+)\]:/;
|
|
348
|
+
/** A line that opens an HTML block (CommonMark types 1-6): its lines are raw until a blank one. */
|
|
349
|
+
const HTML_OPEN = new RegExp(
|
|
350
|
+
`^ {0,3}(?:<(?:script|pre|style|textarea)(?:[ \\t>]|$)|<!--|<\\?|<![A-Za-z]|<!\\[CDATA\\[|` +
|
|
351
|
+
`</?(?:${HTML_BLOCK_TAGS})(?:[ \\t>]|/?>|$))`,
|
|
352
|
+
'i',
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* True when a line at 0-3 columns opens a block, so it ends a footnote body before it. A setext
|
|
357
|
+
* underline (`===`, `--`) is absorbed by the definition's own paragraph and does not end it, while
|
|
358
|
+
* a bare `-` or `1.` does, because it opens a list. A tilde fence is left out on purpose: it ends a
|
|
359
|
+
* body in CommonMark, but this backend reads only ``` fences (#259), and the footnote rule stays on
|
|
360
|
+
* the same side of that gap.
|
|
361
|
+
*/
|
|
362
|
+
function endsDefinitionBody(rest) {
|
|
363
|
+
if (indentWidth(rest) >= 4) return false; // four columns of indent is code, part of the body
|
|
364
|
+
const c = rest.replace(/^[ \t]*/, '');
|
|
365
|
+
if (/^(?:[*\-_][ \t]*){3,}$/.test(c)) return true; // a thematic break, `---` included
|
|
366
|
+
if (/^(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)/.test(c)) return true; // a list item, even a bare `-`
|
|
367
|
+
if (/^(?:=+|-+)[ \t]*$/.test(c)) return false; // a setext underline, not a block
|
|
368
|
+
return (
|
|
369
|
+
/^#{1,6}(?:[ \t]|$)/.test(c) || // ATX heading
|
|
370
|
+
/^\$\$/.test(c) || // math block: the page renders it as math, so the paragraph stops
|
|
371
|
+
/^ {0,3}`{3,}[^`]*$/.test(c) || // backtick fence
|
|
372
|
+
/^>/.test(c) || // a further quote marker
|
|
373
|
+
/^ {0,3}<(?:script|pre|style|textarea)(?:[ \t>]|$)/i.test(c) || // HTML blocks 1-5
|
|
374
|
+
/^ {0,3}<!--/.test(c) ||
|
|
375
|
+
/^ {0,3}<\?/.test(c) ||
|
|
376
|
+
/^ {0,3}<![A-Za-z]/.test(c) ||
|
|
377
|
+
/^ {0,3}<!\[CDATA\[/.test(c) ||
|
|
378
|
+
HTML_BLOCK_TAG.test(c) ||
|
|
379
|
+
HTML_ANY_TAG.test(c)
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Ranges whose lines the renderer does not read as Markdown at all, so a `[^x]:` inside one is
|
|
385
|
+
* plain text and not a definition: backtick fences, `~~~` fences and `$$` math blocks.
|
|
386
|
+
*/
|
|
387
|
+
function footnoteOpaqueRanges(text) {
|
|
388
|
+
const ranges = [...fenceRanges(text)];
|
|
389
|
+
for (const open of [/^ {0,3}~{3,}/, /^ {0,3}\$\$/]) {
|
|
390
|
+
let start = -1;
|
|
391
|
+
let at = 0;
|
|
392
|
+
for (const line of text.split('\n')) {
|
|
393
|
+
if (open.test(line)) {
|
|
394
|
+
if (start < 0) start = at;
|
|
395
|
+
else { ranges.push([start, at + line.length]); start = -1; }
|
|
396
|
+
}
|
|
397
|
+
at += line.length + 1;
|
|
398
|
+
}
|
|
399
|
+
if (start >= 0) ranges.push([start, text.length]);
|
|
400
|
+
}
|
|
401
|
+
return ranges;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Lines of an HTML block: from its opening line to the line before the next blank one. */
|
|
405
|
+
function htmlBlockRegions(text, lines) {
|
|
406
|
+
const regions = [];
|
|
407
|
+
for (let i = 0; i < lines.length; i++) {
|
|
408
|
+
if (!HTML_OPEN.test(text.slice(lines[i][0], lines[i][1]))) continue;
|
|
409
|
+
let end = i;
|
|
410
|
+
while (end + 1 < lines.length && text.slice(lines[end + 1][0], lines[end + 1][1]).trim() !== '') end++;
|
|
411
|
+
regions.push([lines[i][0], lines[end][1]]);
|
|
412
|
+
i = end;
|
|
413
|
+
}
|
|
414
|
+
return regions;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Every footnote definition in the text with the container it sits in: `depth` quote markers and
|
|
419
|
+
* `base` the content indent of the list item that holds it (0 at the top level). Read from the
|
|
420
|
+
* first line only, which is enough to walk the body.
|
|
421
|
+
*
|
|
422
|
+
* `marker` is where the `[^label]:` marker of the line ends. Only that prefix is invisible on the
|
|
423
|
+
* page; the text after it is the definition's own body and shows up in the footnote section, so a
|
|
424
|
+
* reference there is a reference a reader can see (#271).
|
|
425
|
+
*/
|
|
426
|
+
function footnoteDefs(text, lines) {
|
|
427
|
+
const defs = [];
|
|
428
|
+
const opaque = [...footnoteOpaqueRanges(text), ...htmlBlockRegions(text, lines)];
|
|
429
|
+
const inside = offset => opaque.some(([start, end]) => offset >= start && offset < end);
|
|
430
|
+
for (let i = 0; i < lines.length; i++) {
|
|
431
|
+
if (inside(lines[i][0])) continue;
|
|
432
|
+
const { rest, depth, col } = stripQuotes(text.slice(lines[i][0], lines[i][1]));
|
|
433
|
+
let m = FOOTNOTE_DEF.exec(rest);
|
|
434
|
+
if (m) defs.push({ line: i, label: m[1], depth, base: 0, marker: lines[i][1] - rest.length + m[0].length });
|
|
435
|
+
else {
|
|
436
|
+
const marker = LIST_MARKER.exec(rest);
|
|
437
|
+
if (!marker) continue;
|
|
438
|
+
const after = rest.slice(marker[0].length);
|
|
439
|
+
const gap = /^[ \t]*/.exec(after)[0];
|
|
440
|
+
if (gap === '') continue;
|
|
441
|
+
const indent = indentWidth(gap, col + marker[0].length);
|
|
442
|
+
const content = after.slice(gap.length);
|
|
443
|
+
const pad = content.length < 4 ? Math.min(indent, 3) : 0;
|
|
444
|
+
m = FOOTNOTE_DEF.exec(' '.repeat(pad) + content);
|
|
445
|
+
if (m && indent < 4) {
|
|
446
|
+
// The pad is a probe only: it is not in the text, so it comes back off the offset.
|
|
447
|
+
defs.push({ line: i, label: m[1], depth, base: indent, marker: lines[i][1] - content.length + m[0].length - pad });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return defs;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Last line of the body of `def`, walking the lines its paragraph covers.
|
|
456
|
+
*
|
|
457
|
+
* Two collected definitions can never sit inside one body: mdast nests a second definition inside
|
|
458
|
+
* the first only when its line is indented four or more columns past the first one's content
|
|
459
|
+
* column, and such a line is never collected as a definition (footnoteDefs() reads `[^x]:` at 0-3
|
|
460
|
+
* columns only, like every other list marker). A line it does collect therefore opens a block of
|
|
461
|
+
* its own, so the walk stops there with the last line it really covered — no shortcut needed, and
|
|
462
|
+
* no re-walking either: both bodies end before the next definition line, which keeps the whole
|
|
463
|
+
* pass linear in the text. This says nothing about a *nested* definition the page still renders:
|
|
464
|
+
* that one is not collected here at all, and its page semantics stay a known deviation below.
|
|
465
|
+
*/
|
|
466
|
+
function definitionBodyEnd(text, lines, def, next) {
|
|
467
|
+
let last = def.line;
|
|
468
|
+
// A definition line with nothing after the colon opens no paragraph, so no line can continue it
|
|
469
|
+
// lazily (`[^1]:` then `@肥鱼` is a definition and a paragraph, not one definition). Its body
|
|
470
|
+
// starts only at a line indented four columns, which is a paragraph of its own inside it.
|
|
471
|
+
let open = text.slice(def.marker, lines[def.line][1]).trim() !== '';
|
|
472
|
+
for (let j = def.line + 1; j < lines.length; j++) {
|
|
473
|
+
if (j === next) break; // the next definition opens a block of its own, so the body ended above
|
|
474
|
+
const { rest, depth, col } = stripQuotes(text.slice(lines[j][0], lines[j][1]));
|
|
475
|
+
if (rest.trim() === '') {
|
|
476
|
+
// A blank line continues the body only when an indented line of the same container follows
|
|
477
|
+
// it; anything else closes the definition.
|
|
478
|
+
let k = j;
|
|
479
|
+
while (k < lines.length) {
|
|
480
|
+
if (stripQuotes(text.slice(lines[k][0], lines[k][1])).rest.trim() !== '') break;
|
|
481
|
+
k++;
|
|
482
|
+
}
|
|
483
|
+
if (k < lines.length) {
|
|
484
|
+
const r = stripQuotes(text.slice(lines[k][0], lines[k][1]));
|
|
485
|
+
if (r.depth >= def.depth && indentWidth(r.rest, r.col) - def.base >= 4) {
|
|
486
|
+
j = k - 1;
|
|
487
|
+
last = k - 1;
|
|
488
|
+
open = true;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
if (depth > def.depth) break; // a deeper quote is a block of its own
|
|
495
|
+
if (depth < def.depth) {
|
|
496
|
+
// A lazy continuation of the definition's paragraph: judged by what it would start there.
|
|
497
|
+
if (!open || endsDefinitionBody(rest)) break;
|
|
498
|
+
last = j;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (indentWidth(rest, col) - def.base >= 4) { last = j; open = true; continue; } // code, in the body
|
|
502
|
+
if (endsDefinitionBody(rest)) break;
|
|
503
|
+
if (!open) break; // nothing to continue: the line opens the block that follows the definition
|
|
504
|
+
last = j;
|
|
505
|
+
}
|
|
506
|
+
return last;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Ranges sorted and merged, so overlapping and stacked ranges cost one pass. */
|
|
510
|
+
function mergeRanges(ranges) {
|
|
511
|
+
const sorted = [...ranges].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
512
|
+
const out = [];
|
|
513
|
+
for (const [start, end] of sorted) {
|
|
514
|
+
const last = out[out.length - 1];
|
|
515
|
+
if (last && start <= last[1]) last[1] = Math.max(last[1], end);
|
|
516
|
+
else out.push([start, end]);
|
|
517
|
+
}
|
|
518
|
+
return out;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* [start, end) of every hidden footnote definition: the definition line and the lines that
|
|
523
|
+
* continue it, for each label nothing readable refers to. A repeated label keeps only its first
|
|
524
|
+
* definition (the renderer shows that one and drops the rest), and a reference counts only where
|
|
525
|
+
* a reader can see it — not inside a fence, an inline code span, an HTML comment, the `[^x]:`
|
|
526
|
+
* marker of a definition line or another hidden body. The text after the marker of a definition
|
|
527
|
+
* line is the body the page prints in the footnote section, so a reference there does count.
|
|
528
|
+
*
|
|
529
|
+
* The early return keeps the cost off ordinary text: without `[^` nothing is scanned at all.
|
|
530
|
+
*/
|
|
531
|
+
export function footnoteRanges(text, codeSpans = null) {
|
|
532
|
+
// The early return comes first: the default parameter would otherwise build the code spans for
|
|
533
|
+
// every message, and that scan is the expensive half of split().
|
|
534
|
+
if (!text.includes('[^')) return [];
|
|
535
|
+
if (codeSpans === null) codeSpans = codeSpanRanges(text);
|
|
536
|
+
const lines = splitLines(text);
|
|
537
|
+
const defs = footnoteDefs(text, lines);
|
|
538
|
+
if (defs.length === 0) return [];
|
|
539
|
+
// Bodies are read in reading order and never overlap, so each walk stops at the next definition
|
|
540
|
+
// line without ever re-reading a line: one pass over the text, whatever the definition count.
|
|
541
|
+
const ends = defs.map((def, k) =>
|
|
542
|
+
definitionBodyEnd(text, lines, def, k + 1 < defs.length ? defs[k + 1].line : null));
|
|
543
|
+
const bodies = defs.map((def, k) => [lines[def.line][0], lines[ends[k]][1]]);
|
|
544
|
+
// Everything a reader cannot see: code, raw blocks, comments and the `[^x]:` marker itself. The
|
|
545
|
+
// rest of a definition line is its body text, which the page prints in the footnote section.
|
|
546
|
+
const blind = [
|
|
547
|
+
...codeSpans,
|
|
548
|
+
...footnoteOpaqueRanges(text),
|
|
549
|
+
...htmlBlockRegions(text, lines),
|
|
550
|
+
];
|
|
551
|
+
for (const def of defs) blind.push([lines[def.line][0], def.marker]);
|
|
552
|
+
for (const m of text.matchAll(/<!--[\s\S]*?-->/g)) blind.push([m.index, m.index + m[0].length]);
|
|
553
|
+
const searchable = maskText(text, blind);
|
|
554
|
+
// Only the first definition of a label can be shown; the rest are hidden whatever refers to them.
|
|
555
|
+
const first = new Map();
|
|
556
|
+
defs.forEach((def, i) => {
|
|
557
|
+
const key = def.label.toLowerCase();
|
|
558
|
+
if (!first.has(key)) first.set(key, i);
|
|
559
|
+
});
|
|
560
|
+
// Collect the references once, each filed under the definition body it sits in (or nowhere).
|
|
561
|
+
// Bodies are sorted and disjoint, so one forward pointer covers the whole scan.
|
|
562
|
+
const calledOutside = new Set();
|
|
563
|
+
const insideBody = defs.map(() => new Set());
|
|
564
|
+
let body = 0;
|
|
565
|
+
for (const m of searchable.matchAll(/\[\^([^\]\s]+)\]/g)) {
|
|
566
|
+
// A backslash escapes the bracket; an even number of them does not (`\\[^1]` is a call).
|
|
567
|
+
let slashes = 0;
|
|
568
|
+
for (let i = m.index - 1; i >= 0 && searchable[i] === '\\'; i--) slashes++;
|
|
569
|
+
if (slashes % 2 !== 0) continue;
|
|
570
|
+
while (body < bodies.length && bodies[body][1] <= m.index) body++;
|
|
571
|
+
const owner = body < bodies.length && m.index >= bodies[body][0] ? body : -1;
|
|
572
|
+
(owner < 0 ? calledOutside : insideBody[owner]).add(m[1].toLowerCase());
|
|
573
|
+
}
|
|
574
|
+
// Then a plain closure from the readable text outward: a body opens only when a visible
|
|
575
|
+
// reference names its label, and opening one makes the references in it readable in turn. Each
|
|
576
|
+
// definition opens once and each reference is read once, so a long chain settles in one sweep.
|
|
577
|
+
const shown = new Set();
|
|
578
|
+
const pending = [];
|
|
579
|
+
const open = labels => {
|
|
580
|
+
for (const label of labels) {
|
|
581
|
+
const i = first.get(label);
|
|
582
|
+
if (i === undefined || shown.has(i)) continue;
|
|
583
|
+
shown.add(i);
|
|
584
|
+
pending.push(i);
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
open(calledOutside);
|
|
588
|
+
while (pending.length > 0) open(insideBody[pending.pop()]);
|
|
589
|
+
const hidden = [];
|
|
590
|
+
defs.forEach((def, i) => {
|
|
591
|
+
if (!shown.has(i)) hidden.push([lines[def.line][0], lines[ends[i]][1]]);
|
|
592
|
+
});
|
|
593
|
+
return mergeRanges(hidden);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** `text` with every `[start, end)` blanked out, line endings kept so offsets stay put. */
|
|
597
|
+
function maskText(text, ranges) {
|
|
598
|
+
const chars = text.split('');
|
|
599
|
+
for (const [start, end] of mergeRanges(ranges)) {
|
|
600
|
+
for (let i = start; i < end && i < chars.length; i++) {
|
|
601
|
+
if (chars[i] !== '\n' && chars[i] !== '\r') chars[i] = ' ';
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return chars.join('');
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Known deviations from the page, kept on purpose (#259, #271). Each one showed the same answer
|
|
609
|
+
* before this code was written, so the list is the honest edge of this rule, not a regression:
|
|
610
|
+
*
|
|
611
|
+
* - `~~~` fences: the page renders them as code, this backend reads only ```, so a name inside one
|
|
612
|
+
* is coloured nowhere and called here.
|
|
613
|
+
* - `$$` math blocks: the page ends a paragraph at one, this backend does not, so a code span that
|
|
614
|
+
* crosses `$$` holds here and breaks there (nobody called, page colours both names).
|
|
615
|
+
* - A GFM table delimiter row (`|---|`): same shape — the page ends the paragraph, this backend
|
|
616
|
+
* keeps the span, so nobody is called while the page colours both names.
|
|
617
|
+
* - A four-column indented code block: the page renders it as code and colours nothing, this
|
|
618
|
+
* backend has no indented-code reader and calls the name. The footnote rule below shares it: a
|
|
619
|
+
* `[^x]:` line indented four columns is a code block on the page and hidden there, and it is
|
|
620
|
+
* not a definition here either, so a name on that line is a call here.
|
|
621
|
+
* - A nested definition — a `[^x]:` line indented four or more columns past the outer body's
|
|
622
|
+
* content column. The page nests it under the outer footnote and shows it only when its own
|
|
623
|
+
* label is called; this backend collects definitions at 0-3 columns only, so that line is never
|
|
624
|
+
* a definition here and its label is not a label. Both matchers then agree on an answer the
|
|
625
|
+
* page does not give, in both directions: `[^1]: a` / four-column `[^2]: 注 @name` /
|
|
626
|
+
* `见 [^1]` calls the name here while the page shows no badge at all (the outer footnote
|
|
627
|
+
* renders as `a`), and the same input with `见 [^2]` calls nobody here while the page does show
|
|
628
|
+
* the badge. Same on bdd67b4, so this is not a regression of #271, and #323 corrected the notes
|
|
629
|
+
* rather than widening the implementation (#323).
|
|
630
|
+
* - A code span opened on a block line (`# ` + backtick + name): the paragraph already ended
|
|
631
|
+
* before the opener, so the page never makes it a span and colours the names; endsParagraph()
|
|
632
|
+
* only reads the lines between the runs, so the span still holds here.
|
|
633
|
+
*
|
|
634
|
+
* Split text into [segment, memberId|null]; mentions inside ``` fences, inline code spans and
|
|
635
|
+
* hidden footnote definitions do not count. The text itself is kept verbatim, so the parts always
|
|
636
|
+
* join back to the input.
|
|
637
|
+
*/
|
|
23
638
|
export function split(text, members) {
|
|
24
639
|
const list = aliases(members);
|
|
25
640
|
if (list.length === 0) return [[text, null]];
|
|
26
641
|
const lookup = new Map(list);
|
|
27
|
-
const pat =
|
|
642
|
+
const pat = mentionPattern(list);
|
|
643
|
+
const codeSpans = codeSpanRanges(text);
|
|
644
|
+
const skip = [...fenceRanges(text), ...codeSpans, ...footnoteRanges(text, codeSpans)];
|
|
28
645
|
const parts = [];
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
parts.push([seg.slice(pos), null]);
|
|
38
|
-
});
|
|
646
|
+
let pos = 0;
|
|
647
|
+
for (const m of text.matchAll(pat)) {
|
|
648
|
+
if (excluded(skip, m.index, m[0].length)) continue;
|
|
649
|
+
parts.push([text.slice(pos, m.index), null], [m[0], lookup.get(m[0])]);
|
|
650
|
+
pos = m.index + m[0].length;
|
|
651
|
+
}
|
|
652
|
+
parts.push([text.slice(pos), null]);
|
|
39
653
|
return parts;
|
|
40
654
|
}
|
|
41
655
|
|