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.
@@ -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
- /** Split text into [segment, memberId|null]; mentions inside ``` fences do not count. */
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 = new RegExp(list.map(([a]) => escape(a)).join('|'), 'g');
642
+ const pat = mentionPattern(list);
643
+ const codeSpans = codeSpanRanges(text);
644
+ const skip = [...fenceRanges(text), ...codeSpans, ...footnoteRanges(text, codeSpans)];
28
645
  const parts = [];
29
- text.split(FENCE).forEach((seg, i) => {
30
- if (i) parts.push([FENCE, null]);
31
- if (i % 2) { parts.push([seg, null]); return; }
32
- let pos = 0;
33
- for (const m of seg.matchAll(pat)) {
34
- parts.push([seg.slice(pos, m.index), null], [m[0], lookup.get(m[0])]);
35
- pos = m.index + m[0].length;
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