@stratusagent/channel-slack 0.11.4 → 0.11.6
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 +28 -13
- package/dist/index.d.ts +24 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +418 -361
- package/dist/index.js.map +1 -1
- package/dist/mrkdwn.d.ts +62 -0
- package/dist/mrkdwn.d.ts.map +1 -0
- package/dist/mrkdwn.js +1081 -0
- package/dist/mrkdwn.js.map +1 -0
- package/manifest/stratus-agent.manifest.json +5 -0
- package/package.json +3 -3
package/dist/mrkdwn.js
ADDED
|
@@ -0,0 +1,1081 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown as a model writes it, in the spelling Slack actually renders.
|
|
3
|
+
*
|
|
4
|
+
* Slack's mrkdwn is not Markdown, and the gap is not cosmetic: `**bold**`
|
|
5
|
+
* reaches a reader as four literal asterisks, and `## Heading` as a line
|
|
6
|
+
* that starts with two hashes. Models write Markdown because that is what
|
|
7
|
+
* every other surface here renders, and a soul should not have to know
|
|
8
|
+
* which channel is carrying it, so the translation happens at the edge that
|
|
9
|
+
* does know.
|
|
10
|
+
*
|
|
11
|
+
* This reads the reply once into tokens and renders from those. It replaced
|
|
12
|
+
* a chain of regexes over the text, which was correct in the end and got
|
|
13
|
+
* there the hard way: nine rounds of review, four of them the same defect —
|
|
14
|
+
* a character disappearing from inside a code span — reached from four
|
|
15
|
+
* unrelated directions. Nothing in a chain of rewrites holds a model of
|
|
16
|
+
* what is code, so every rule had to rediscover it, and each could be wrong
|
|
17
|
+
* on its own; one pair of rules in one commit turned out to disagree about
|
|
18
|
+
* whether Slack pairs backticks across a newline. Deciding that once, here,
|
|
19
|
+
* is the whole point of the shape.
|
|
20
|
+
*
|
|
21
|
+
* Three properties fall out of it rather than being enforced by a rule
|
|
22
|
+
* each, and they are the ones every one of those defects violated:
|
|
23
|
+
*
|
|
24
|
+
* - **A code run is one token.** Nothing below can see inside it, rewrite
|
|
25
|
+
* part of it, or cut a line out of it — the newlines in a fence are not
|
|
26
|
+
* line breaks in this stream, so a `#` on a line of somebody's shell
|
|
27
|
+
* script is not a heading to be found.
|
|
28
|
+
* - **A delimiter is matched to its partner, not to a pattern.** An
|
|
29
|
+
* emphasis run pairs across whatever sits between it and its closer, code
|
|
30
|
+
* included, so ``**the `fs.read` tool**`` is one bold run rather than two
|
|
31
|
+
* halves that never meet.
|
|
32
|
+
* - **A wrapper is emitted only when its own character is not already loose
|
|
33
|
+
* in what it wraps.** Slack has one delimiter per style and no way to
|
|
34
|
+
* nest it, so `*a *b* c*` renders as neither bold nor text. One rule
|
|
35
|
+
* covers the heading that already contains bold, the bold run with a
|
|
36
|
+
* stray asterisk in it, and every pairing of the two.
|
|
37
|
+
*
|
|
38
|
+
* Only what differs is touched. Lists, block quotes, and inline code
|
|
39
|
+
* already mean in mrkdwn what they mean in Markdown; rewriting them would
|
|
40
|
+
* add ways to be wrong and fix nothing.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* The delimiters worth reading, and how many of each a style can spend.
|
|
44
|
+
*
|
|
45
|
+
* `least` is the shortest run that means anything. Two of them start at
|
|
46
|
+
* two: a single `_` is already italic to Slack, and a single `~` is not a
|
|
47
|
+
* delimiter to either dialect, so a run of one is text in both cases —
|
|
48
|
+
* converting it could only change what a reader sees. A single `*` is the
|
|
49
|
+
* one that has to move, since Markdown reads it as italic and Slack as
|
|
50
|
+
* bold, so leaving it alone is the one thing that is certainly wrong.
|
|
51
|
+
*
|
|
52
|
+
* `most` is where the styles stop. Asterisks and underscores reach three,
|
|
53
|
+
* which is bold and italic together; tildes have only the one style and
|
|
54
|
+
* stop at two. Spending more than a style has is how characters go
|
|
55
|
+
* missing: read as three, `~~~obsolete~~~` had two of its tildes charged
|
|
56
|
+
* to a style that does not exist and simply vanished.
|
|
57
|
+
*/
|
|
58
|
+
const DELIMITERS = {
|
|
59
|
+
'*': { least: 1, most: 3 },
|
|
60
|
+
_: { least: 2, most: 3 },
|
|
61
|
+
'~': { least: 2, most: 2 },
|
|
62
|
+
};
|
|
63
|
+
/** Where a link's two halves begin and end. Nothing else reads brackets. */
|
|
64
|
+
const BRACKETS = new Set(['[', ']', '(', ')']);
|
|
65
|
+
/**
|
|
66
|
+
* A destination Slack will make a link of: a scheme it follows, and no
|
|
67
|
+
* whitespace or parenthesis to end it early. A bare `[1](2)` in prose is
|
|
68
|
+
* not a link anybody meant to follow.
|
|
69
|
+
*/
|
|
70
|
+
const LINK_DESTINATION = /^(?:https?:\/\/|mailto:)[^\s()]+$/;
|
|
71
|
+
/**
|
|
72
|
+
* A heading's opening syntax: up to three spaces, its hashes, then air.
|
|
73
|
+
*
|
|
74
|
+
* What follows the air is deliberately not this pattern's business. The
|
|
75
|
+
* text token holding the hashes ends wherever the first marker or snippet
|
|
76
|
+
* begins, so `## **Title**` has nothing after the space to look at, and a
|
|
77
|
+
* pattern that demanded some would read that line as prose. Whether a
|
|
78
|
+
* heading has any content is asked later, of the rendered line, where the
|
|
79
|
+
* answer does not depend on which token the content landed in.
|
|
80
|
+
*/
|
|
81
|
+
const HEADING_OPENER = /^ {0,3}#{1,6}[ \t]+/;
|
|
82
|
+
/**
|
|
83
|
+
* A heading's closing hashes, which Markdown allows and mrkdwn has no use
|
|
84
|
+
* for. They only count as syntax when spaced off the text — without that,
|
|
85
|
+
* `# C#` is a heading whose name loses its last character.
|
|
86
|
+
*/
|
|
87
|
+
const HEADING_CLOSER = /(?:[ \t]+#+)?\s*$/;
|
|
88
|
+
/** The characters a token was written with, whatever it came to mean. */
|
|
89
|
+
const sourceOf = (token) => {
|
|
90
|
+
switch (token.kind) {
|
|
91
|
+
case 'run':
|
|
92
|
+
return token.char.repeat(token.length);
|
|
93
|
+
case 'break':
|
|
94
|
+
return '\n';
|
|
95
|
+
default:
|
|
96
|
+
return token.text;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Where the run of exactly `length` backticks that closes an open one
|
|
101
|
+
* begins, or -1. Exactly: a longer run is not a closer, which is the whole
|
|
102
|
+
* point of writing a span as ``code with a ` in it`` and a fence as ````
|
|
103
|
+
* when the block itself contains ```.
|
|
104
|
+
*/
|
|
105
|
+
const closingBacktickRun = (text, from, length) => {
|
|
106
|
+
const run = '`'.repeat(length);
|
|
107
|
+
for (let at = text.indexOf(run, from); at !== -1; at = text.indexOf(run, at + 1)) {
|
|
108
|
+
if (text[at - 1] !== '`' && text[at + length] !== '`') {
|
|
109
|
+
return at;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return -1;
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* The reply as tokens.
|
|
116
|
+
*
|
|
117
|
+
* A code run may close on a later line and is one token the whole way.
|
|
118
|
+
* That looks wasteful — two stray backticks paragraphs apart become one
|
|
119
|
+
* long "span" whose contents go unconverted — and it was once changed to
|
|
120
|
+
* end a span at its line for exactly that reason. That was wrong, and the
|
|
121
|
+
* reason is the rule this file answers to: what Slack does with the message
|
|
122
|
+
* decides, not what Markdown says. Slack pairs those backticks too, so the
|
|
123
|
+
* text between them is what it renders as code, and rewriting a `**bold**`
|
|
124
|
+
* in there would alter the contents of somebody's snippet. Prose left
|
|
125
|
+
* unconverted is a cosmetic loss; code whose contents change is not, so the
|
|
126
|
+
* doubt resolves toward protecting more.
|
|
127
|
+
*
|
|
128
|
+
* A run of three or more with no closer takes the rest of the reply, which
|
|
129
|
+
* is what a fence still being streamed looks like on every edit before its
|
|
130
|
+
* last. One or two unmatched backticks are the opposite case — text, since
|
|
131
|
+
* "use the ` character" must not swallow what follows.
|
|
132
|
+
*/
|
|
133
|
+
const scan = (text) => {
|
|
134
|
+
const tokens = [];
|
|
135
|
+
let plain = '';
|
|
136
|
+
const flush = () => {
|
|
137
|
+
if (plain.length > 0) {
|
|
138
|
+
tokens.push({ kind: 'text', text: plain });
|
|
139
|
+
plain = '';
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
let at = 0;
|
|
143
|
+
while (at < text.length) {
|
|
144
|
+
const char = text[at] ?? '';
|
|
145
|
+
if (char === '`') {
|
|
146
|
+
let length = 1;
|
|
147
|
+
while (text[at + length] === '`') {
|
|
148
|
+
length += 1;
|
|
149
|
+
}
|
|
150
|
+
const close = closingBacktickRun(text, at + length, length);
|
|
151
|
+
if (close === -1 && length < 3) {
|
|
152
|
+
plain += '`'.repeat(length);
|
|
153
|
+
at += length;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
flush();
|
|
157
|
+
if (close === -1) {
|
|
158
|
+
tokens.push({ kind: 'code', text: text.slice(at), closed: false });
|
|
159
|
+
return tokens;
|
|
160
|
+
}
|
|
161
|
+
tokens.push({ kind: 'code', text: text.slice(at, close + length), closed: true });
|
|
162
|
+
at = close + length;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (char === '\n') {
|
|
166
|
+
flush();
|
|
167
|
+
tokens.push({ kind: 'break' });
|
|
168
|
+
at += 1;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const delimiter = DELIMITERS[char];
|
|
172
|
+
if (delimiter !== undefined) {
|
|
173
|
+
let length = 1;
|
|
174
|
+
while (text[at + length] === char) {
|
|
175
|
+
length += 1;
|
|
176
|
+
}
|
|
177
|
+
const before = text[at - 1];
|
|
178
|
+
const after = text[at + length];
|
|
179
|
+
// A delimiter has to hug what it marks: `3 * 4 * 5` is arithmetic.
|
|
180
|
+
// An underscore has to stand clear of a word as well, so that
|
|
181
|
+
// `snake_case_name` is a name — which is why the two are separate
|
|
182
|
+
// questions rather than one, since `a**b**c` is bold in Markdown and
|
|
183
|
+
// reads that way in Slack too.
|
|
184
|
+
const tight = length >= delimiter.least;
|
|
185
|
+
const inWord = (side) => side !== undefined && /[\p{L}\p{N}]/u.test(side);
|
|
186
|
+
flush();
|
|
187
|
+
tokens.push({
|
|
188
|
+
kind: 'run',
|
|
189
|
+
char,
|
|
190
|
+
length,
|
|
191
|
+
opens: tight && after !== undefined && !/\s/.test(after) && (char !== '_' || !inWord(before)),
|
|
192
|
+
closes: tight && before !== undefined && !/\s/.test(before) && (char !== '_' || !inWord(after)),
|
|
193
|
+
});
|
|
194
|
+
at += length;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (BRACKETS.has(char)) {
|
|
198
|
+
flush();
|
|
199
|
+
tokens.push({ kind: 'punct', text: char });
|
|
200
|
+
at += 1;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
plain += char;
|
|
204
|
+
at += 1;
|
|
205
|
+
}
|
|
206
|
+
flush();
|
|
207
|
+
return tokens;
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* Each closing run matched to the nearest opener still standing.
|
|
211
|
+
*
|
|
212
|
+
* A break clears the openers because emphasis does not cross a line in
|
|
213
|
+
* either dialect — one stray marker at the end of a paragraph must not
|
|
214
|
+
* italicize the next one. Nothing else clears them, and code in particular
|
|
215
|
+
* does not: a run reaches its partner across a snippet, which is the
|
|
216
|
+
* difference between ``**the `fs.read` tool**`` arriving as bold and
|
|
217
|
+
* arriving as four asterisks.
|
|
218
|
+
*
|
|
219
|
+
* `inert` holds the characters of a link's destination, which are no more
|
|
220
|
+
* markup than a snippet's are. A URL is written to be followed, and a `*`
|
|
221
|
+
* in a path is part of the path: left pairable, the one in
|
|
222
|
+
* `**[files](https://host/files/*.txt)**` closed the bold run that opened
|
|
223
|
+
* before the link, and the address arrived as `/files/_.txt` — a URL
|
|
224
|
+
* altered, which is the same defect as a snippet altered and was missed in
|
|
225
|
+
* the same place, by not asking whether anything else in a reply is as
|
|
226
|
+
* literal as code.
|
|
227
|
+
*/
|
|
228
|
+
const pairEmphasis = (tokens, inert, labelled) => {
|
|
229
|
+
// In creation order, which is innermost first at any run they share: a
|
|
230
|
+
// closer answers the nearest opener before it reaches the one further
|
|
231
|
+
// out, and an opener is spent from the end nearest what it marks.
|
|
232
|
+
const made = [];
|
|
233
|
+
// What each run has left to spend. A run is not one marker but a purse:
|
|
234
|
+
// `**bold and *italic***` ends on three asterisks that have two jobs, and
|
|
235
|
+
// answering only the nearest opener left the outer `**` unanswered and
|
|
236
|
+
// its asterisks on the line.
|
|
237
|
+
const left = new Map();
|
|
238
|
+
// One stack per delimiter character rather than one for all of them. A
|
|
239
|
+
// closer only ever answers an opener spelled the same way, so a single
|
|
240
|
+
// stack means walking past every opener that is not: 32 000 asterisks
|
|
241
|
+
// followed by 32 000 `~~` spent ten seconds discovering, each time, that
|
|
242
|
+
// none of them was a tilde. Each stack is in position order, so its last
|
|
243
|
+
// entry is the nearest opener of that character, and everything the
|
|
244
|
+
// closer invalidates is at the end of some stack.
|
|
245
|
+
const openers = new Map(Object.keys(DELIMITERS).map((char) => [char, []]));
|
|
246
|
+
// How far down a closer of a given kind still has to look. A closer that
|
|
247
|
+
// found nothing tells every later closer of its kind that there is
|
|
248
|
+
// nothing below it either, because the stack under it only ever shrinks
|
|
249
|
+
// — without which the rule below would put back the walk over every
|
|
250
|
+
// standing opener that the one-stack-per-character change took out.
|
|
251
|
+
const floor = new Map();
|
|
252
|
+
tokens.forEach((token, index) => {
|
|
253
|
+
if (token.kind === 'break') {
|
|
254
|
+
for (const stack of openers.values()) {
|
|
255
|
+
stack.length = 0;
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (token.kind !== 'run' || inert.has(index)) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const own = openers.get(token.char);
|
|
263
|
+
const least = DELIMITERS[token.char]?.least ?? 1;
|
|
264
|
+
const most = DELIMITERS[token.char]?.most ?? 1;
|
|
265
|
+
let purse = token.length;
|
|
266
|
+
let spent = false;
|
|
267
|
+
// Each opener answers this closer at most once. Without that, a run
|
|
268
|
+
// with characters to spare pairs with itself again — `****quad****`
|
|
269
|
+
// would buy a second style with the asterisks it could not spend the
|
|
270
|
+
// first time, where today they stay on the line as written.
|
|
271
|
+
let ceiling = tokens.length;
|
|
272
|
+
if (token.closes && own !== undefined) {
|
|
273
|
+
while (purse >= least) {
|
|
274
|
+
// Markdown's rule of three: where either half could face both ways,
|
|
275
|
+
// runs whose lengths add to a multiple of three do not pair, unless
|
|
276
|
+
// both are multiples of three. It is what keeps the `*` in
|
|
277
|
+
// `**cost 2*3**` a multiplication sign — answered by the `**` before
|
|
278
|
+
// it, it italicised `cost 2`, text the reply never marked at all, and
|
|
279
|
+
// left the closing `**` with nothing.
|
|
280
|
+
const kind = `${token.char}${token.length % 3}${token.opens ? 'o' : ''}`;
|
|
281
|
+
const bottom = floor.get(kind) ?? -1;
|
|
282
|
+
let candidate = -1;
|
|
283
|
+
for (let slot = own.length - 1; slot >= 0; slot -= 1) {
|
|
284
|
+
const standing = own[slot];
|
|
285
|
+
if (standing === undefined || standing <= bottom) {
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
if (standing >= ceiling) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
// A pair lies wholly inside a link's label or wholly outside it,
|
|
292
|
+
// never half of each. There is no way to render one that straddles
|
|
293
|
+
// the boundary — the label is written as `<url|label>`, so a
|
|
294
|
+
// wrapper cannot begin outside it and end inside — and every
|
|
295
|
+
// attempt to leaves characters behind: `*a [**b***](https://x)`
|
|
296
|
+
// lost the link outright, and `*a [**b](https://x)***` lost the
|
|
297
|
+
// asterisks the unrenderable pair had claimed.
|
|
298
|
+
if (labelled[standing] !== labelled[index]) {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const waiting = tokens[standing];
|
|
302
|
+
if (waiting?.kind !== 'run') {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const faces = token.opens || waiting.closes;
|
|
306
|
+
const thirds = (waiting.length + token.length) % 3 === 0;
|
|
307
|
+
const both = waiting.length % 3 === 0 && token.length % 3 === 0;
|
|
308
|
+
if (!(faces && thirds && !both)) {
|
|
309
|
+
candidate = standing;
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (candidate === -1) {
|
|
314
|
+
// Everything *before* this closer, not including it: a closer the
|
|
315
|
+
// rule turned away is still an opener for what comes after, and
|
|
316
|
+
// shutting the door on itself is what stopped `2*3 and 4*5`
|
|
317
|
+
// pairing. Only worth recording while nothing has been spent —
|
|
318
|
+
// once it has, this closer is not the one a later one can learn
|
|
319
|
+
// from, because what it could not reach it never looked for.
|
|
320
|
+
if (!spent) {
|
|
321
|
+
floor.set(kind, index - 1);
|
|
322
|
+
}
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
const purseOfOpener = left.get(candidate) ?? 0;
|
|
326
|
+
const use = Math.min(purseOfOpener, purse, most);
|
|
327
|
+
if (use < least) {
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
made.push({ open: candidate, close: index, use });
|
|
331
|
+
left.set(candidate, purseOfOpener - use);
|
|
332
|
+
purse -= use;
|
|
333
|
+
spent = true;
|
|
334
|
+
ceiling = candidate;
|
|
335
|
+
// Everything opened inside the pair and never closed is text —
|
|
336
|
+
// including whatever the rule above stepped over, which sits above
|
|
337
|
+
// this one. The opener itself stays only while it has something
|
|
338
|
+
// left to spend on a closer further along. Each entry is dropped
|
|
339
|
+
// once however many closers pass over it, so the pass stays linear.
|
|
340
|
+
for (const stack of openers.values()) {
|
|
341
|
+
while (stack.length > 0 && (stack[stack.length - 1] ?? -1) > candidate) {
|
|
342
|
+
stack.pop();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if ((left.get(candidate) ?? 0) < least && own[own.length - 1] === candidate) {
|
|
346
|
+
own.pop();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
left.set(index, purse);
|
|
351
|
+
// A run that spent anything closing does not go on to open: the
|
|
352
|
+
// renderer enters a pair at its opening run and leaves past its
|
|
353
|
+
// closing one, so a run that is both would have to be stopped at
|
|
354
|
+
// twice, and the second stop is the one that does not exist yet.
|
|
355
|
+
// `**bold***italic*` is bold then italic in Markdown and comes out as
|
|
356
|
+
// two bold runs here, as it did before this branch.
|
|
357
|
+
if (!spent && token.opens) {
|
|
358
|
+
own?.push(index);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
// What is left of a run is text, and belongs to the outermost pair that
|
|
362
|
+
// touches it — the last one made there, since pairs are made from the
|
|
363
|
+
// inside out.
|
|
364
|
+
const pairs = new Map();
|
|
365
|
+
const outermost = new Map();
|
|
366
|
+
made.forEach((entry, at) => {
|
|
367
|
+
outermost.set(entry.open, at);
|
|
368
|
+
outermost.set(entry.close, at);
|
|
369
|
+
});
|
|
370
|
+
made.forEach((entry, at) => {
|
|
371
|
+
const pair = {
|
|
372
|
+
open: entry.open,
|
|
373
|
+
close: entry.close,
|
|
374
|
+
use: entry.use,
|
|
375
|
+
before: outermost.get(entry.open) === at ? left.get(entry.open) ?? 0 : 0,
|
|
376
|
+
after: outermost.get(entry.close) === at ? left.get(entry.close) ?? 0 : 0,
|
|
377
|
+
};
|
|
378
|
+
const opening = pairs.get(entry.open);
|
|
379
|
+
if (opening === undefined) {
|
|
380
|
+
pairs.set(entry.open, [pair]);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
// Outermost first, so the range it covers is entered before the ones
|
|
384
|
+
// inside it and its markup is written to the outside of theirs.
|
|
385
|
+
opening.unshift(pair);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
return pairs;
|
|
389
|
+
};
|
|
390
|
+
/**
|
|
391
|
+
* `[label](destination)`, the one construct Slack spells backwards.
|
|
392
|
+
*
|
|
393
|
+
* The destination is read as the characters it was written with, so a
|
|
394
|
+
* snippet inside one is judged by what it says: `https://host/`a b`` has a
|
|
395
|
+
* space in it and is not a destination, while `https://host/`b`` is. The
|
|
396
|
+
* old rewrite could not see either — it matched a masked stand-in and had
|
|
397
|
+
* to be told separately whether the thing behind it held whitespace.
|
|
398
|
+
*
|
|
399
|
+
* `<url|label>` is one line's worth of markup, so a link is one line. A
|
|
400
|
+
* break between the brackets already ends the search, and a snippet with a
|
|
401
|
+
* newline inside it has to as well: the newline is hidden in a single
|
|
402
|
+
* token there, which is exactly what makes it easy to miss.
|
|
403
|
+
*/
|
|
404
|
+
const findLinks = (tokens) => {
|
|
405
|
+
const links = new Map();
|
|
406
|
+
// Where the `)` that could close a destination starting here is, and how
|
|
407
|
+
// many snippets with a newline in them have been passed. Both were walked
|
|
408
|
+
// per candidate, and a reply can hold a great many candidates that come to
|
|
409
|
+
// nothing: `[x](` sixteen thousand times sent every one of them looking
|
|
410
|
+
// through the whole of the rest for a `)` that is not there.
|
|
411
|
+
//
|
|
412
|
+
// The scan stops at whitespace and at a `(` as well as at a break, because
|
|
413
|
+
// a destination holds none of the three — reaching a `)` past one of them
|
|
414
|
+
// only builds a string for the pattern below to reject, and building it is
|
|
415
|
+
// the expensive half: `[x](a` eight thousand times, with a single `)` at
|
|
416
|
+
// the end, had every candidate reach that `)` and join the whole remainder
|
|
417
|
+
// into a destination before throwing it away.
|
|
418
|
+
const closeFrom = new Array(tokens.length + 1).fill(-1);
|
|
419
|
+
const wrapped = new Array(tokens.length + 1).fill(0);
|
|
420
|
+
for (let at = tokens.length - 1; at >= 0; at -= 1) {
|
|
421
|
+
const token = tokens[at];
|
|
422
|
+
const opens = token?.kind === 'punct' && token.text === '(';
|
|
423
|
+
const stops = token === undefined || token.kind === 'break' || opens || /\s/.test(sourceOf(token));
|
|
424
|
+
const closes = token?.kind === 'punct' && token.text === ')';
|
|
425
|
+
closeFrom[at] = closes ? at : (stops ? -1 : (closeFrom[at + 1] ?? -1));
|
|
426
|
+
wrapped[at] = (wrapped[at + 1] ?? 0) + (token?.kind === 'code' && token.text.includes('\n') ? 1 : 0);
|
|
427
|
+
}
|
|
428
|
+
let opener = -1;
|
|
429
|
+
for (let at = 0; at < tokens.length; at += 1) {
|
|
430
|
+
const token = tokens[at];
|
|
431
|
+
if (token?.kind === 'break') {
|
|
432
|
+
opener = -1;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (token?.kind !== 'punct') {
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (token.text === '[') {
|
|
439
|
+
opener = at;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (token.text !== ']') {
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
// A closing bracket spends its opener whether or not a destination
|
|
446
|
+
// follows, the way Markdown's own does. Kept, it would let a `](…)`
|
|
447
|
+
// further along the line reach back past the bracket that already
|
|
448
|
+
// answered it: `[not a link] text](https://example.com)` became one
|
|
449
|
+
// link over the lot, hiding the text in between inside a label.
|
|
450
|
+
const candidate = opener;
|
|
451
|
+
opener = -1;
|
|
452
|
+
const after = tokens[at + 1];
|
|
453
|
+
if (candidate === -1 || after?.kind !== 'punct' || after.text !== '(') {
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
// Markdown allows an empty label; Slack's spelling has nowhere to put
|
|
457
|
+
// one. `[](https://example.com)` became `<https://example.com|>`, which
|
|
458
|
+
// is a link with no text for the reader to see or click, so the line
|
|
459
|
+
// stays as it was written and the address stays visible in it.
|
|
460
|
+
if (candidate + 1 === at) {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
const close = closeFrom[at + 2] ?? -1;
|
|
464
|
+
if (close === -1) {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if ((wrapped[candidate + 1] ?? 0) - (wrapped[close] ?? 0) > 0) {
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
const destination = tokens.slice(at + 2, close).map(sourceOf).join('');
|
|
471
|
+
if (!LINK_DESTINATION.test(destination)) {
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
links.set(candidate, { label: [candidate + 1, at], destination, through: close });
|
|
475
|
+
at = close;
|
|
476
|
+
}
|
|
477
|
+
return links;
|
|
478
|
+
};
|
|
479
|
+
/** What each style is spelled with once it is Slack's. */
|
|
480
|
+
const wrapperFor = (char, use) => {
|
|
481
|
+
if (char === '~') {
|
|
482
|
+
return ['~', '~'];
|
|
483
|
+
}
|
|
484
|
+
// Three is both styles at once, for either character that reaches it —
|
|
485
|
+
// read as bold alone, `___important___` lost the italic half it asked
|
|
486
|
+
// for while `***important***` kept it.
|
|
487
|
+
if (use >= 3) {
|
|
488
|
+
return ['*_', '_*'];
|
|
489
|
+
}
|
|
490
|
+
return char === '_' || use === 2 ? ['*', '*'] : ['_', '_'];
|
|
491
|
+
};
|
|
492
|
+
/**
|
|
493
|
+
* The tokens from `from` to `to`, rendered.
|
|
494
|
+
*
|
|
495
|
+
* What a pair encloses is a range in its own right, and so is a link's
|
|
496
|
+
* label, which reads as recursion and was written that way. It cannot be:
|
|
497
|
+
* nesting deep enough — a few thousand pairs one inside the next, which is
|
|
498
|
+
* a runaway model or a pasted file, not a person — exhausted the call stack
|
|
499
|
+
* and threw, and the throw lands in the edit timer, evaluated synchronously
|
|
500
|
+
* outside anything catching, so the reply took the adapter down with it.
|
|
501
|
+
* The depth lives in this array instead, where it costs an entry.
|
|
502
|
+
*
|
|
503
|
+
* Every range writes into one chunk list and nothing re-reads what it
|
|
504
|
+
* wrote. Collecting each range's text and joining it into its enclosing
|
|
505
|
+
* range copies everything below once per level, which is quadratic in the
|
|
506
|
+
* nesting depth — 80 000 pairs took forty seconds of the adapter's event
|
|
507
|
+
* loop, and the converter this replaced took milliseconds. A range reserves
|
|
508
|
+
* its opening chunk before it starts and fills it in when it ends, so the
|
|
509
|
+
* markup reaches the front of the range without moving what follows.
|
|
510
|
+
*/
|
|
511
|
+
const renderRange = (context, from, to) => {
|
|
512
|
+
const chunks = [];
|
|
513
|
+
const stack = [{ kind: 'range', to, loose: new Set(), unclosed: false, wraps: false }];
|
|
514
|
+
let at = from;
|
|
515
|
+
/** Text that is prose: it contributes whatever delimiters stand in it. */
|
|
516
|
+
const write = (frame, text) => {
|
|
517
|
+
chunks.push(text);
|
|
518
|
+
for (const char of text) {
|
|
519
|
+
if (char in DELIMITERS) {
|
|
520
|
+
frame.loose.add(char);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
for (;;) {
|
|
525
|
+
const frame = stack[stack.length - 1];
|
|
526
|
+
if (frame === undefined) {
|
|
527
|
+
return { text: '', loose: new Set(), unclosed: false, wraps: false };
|
|
528
|
+
}
|
|
529
|
+
const token = at < frame.to ? context.tokens[at] : undefined;
|
|
530
|
+
if (token === undefined) {
|
|
531
|
+
stack.pop();
|
|
532
|
+
const parent = stack[stack.length - 1];
|
|
533
|
+
// Only the range this call was asked for has nothing around it.
|
|
534
|
+
if (frame.kind === 'range' || parent === undefined) {
|
|
535
|
+
return { text: chunks.join(''), loose: frame.loose, unclosed: frame.unclosed, wraps: frame.wraps };
|
|
536
|
+
}
|
|
537
|
+
if (frame.kind === 'pair') {
|
|
538
|
+
const [open, close] = wrapperFor(frame.open.char, frame.use);
|
|
539
|
+
// The characters of a run nothing spent are text, and stay on the
|
|
540
|
+
// outside of the pair where they were written. A run two pairs
|
|
541
|
+
// share hands them to the outer one, so they are counted here
|
|
542
|
+
// rather than taken from the run's own length.
|
|
543
|
+
const before = frame.open.char.repeat(frame.spare[0]);
|
|
544
|
+
const after = frame.close.char.repeat(frame.spare[1]);
|
|
545
|
+
// Every character the wrapper is spelled with, not just its first:
|
|
546
|
+
// `***both***` is written `*_…_*`, and an underscore already loose
|
|
547
|
+
// inside it would break the italic half exactly as a stray asterisk
|
|
548
|
+
// breaks the bold one.
|
|
549
|
+
const nests = [...open].some((char) => frame.loose.has(char)) || frame.unclosed;
|
|
550
|
+
// Only what this pair spends, for the same reason: the run may have
|
|
551
|
+
// written the other half of somebody else's markup already.
|
|
552
|
+
const kept = frame.open.char.repeat(frame.use);
|
|
553
|
+
chunks[frame.slot] = nests ? before + kept : before + open;
|
|
554
|
+
// Both halves are prose to whatever encloses this, wrappers this
|
|
555
|
+
// pass emitted included — which is why a heading that already holds
|
|
556
|
+
// bold is not bolded again.
|
|
557
|
+
write(parent, nests ? frame.close.char.repeat(frame.use) + after : close + after);
|
|
558
|
+
for (const char of chunks[frame.slot] ?? '') {
|
|
559
|
+
if (char in DELIMITERS) {
|
|
560
|
+
parent.loose.add(char);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
chunks[frame.slot] = `<${frame.link.destination}|`;
|
|
566
|
+
chunks.push('>');
|
|
567
|
+
// The destination is an address rather than markup, so its
|
|
568
|
+
// delimiters pair with nothing — but they are still standing there,
|
|
569
|
+
// and a wrapper spelled with one of them would break on them.
|
|
570
|
+
for (const char of frame.link.destination) {
|
|
571
|
+
if (char in DELIMITERS) {
|
|
572
|
+
parent.loose.add(char);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
for (const char of frame.loose) {
|
|
577
|
+
parent.loose.add(char);
|
|
578
|
+
}
|
|
579
|
+
parent.unclosed = parent.unclosed || frame.unclosed;
|
|
580
|
+
parent.wraps = parent.wraps || frame.wraps;
|
|
581
|
+
at = frame.after;
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
const link = context.links.get(at);
|
|
585
|
+
if (link !== undefined && link.through < frame.to) {
|
|
586
|
+
chunks.push('');
|
|
587
|
+
stack.push({
|
|
588
|
+
kind: 'label',
|
|
589
|
+
to: link.label[1],
|
|
590
|
+
loose: new Set(),
|
|
591
|
+
unclosed: false,
|
|
592
|
+
wraps: false,
|
|
593
|
+
slot: chunks.length - 1,
|
|
594
|
+
after: link.through + 1,
|
|
595
|
+
link,
|
|
596
|
+
});
|
|
597
|
+
at = link.label[0];
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (token.kind === 'run') {
|
|
601
|
+
// Every pair this run opens, outermost first, so each is entered
|
|
602
|
+
// before the ones it contains. A run can open more than one: the
|
|
603
|
+
// three asterisks of `***a** b*` buy an italic that ends at the last
|
|
604
|
+
// one and a bold that ends before it.
|
|
605
|
+
// `<=`, not `<`: a pair inside another that ends on the same run
|
|
606
|
+
// closes where its parent does. Nothing is expected to be filtered
|
|
607
|
+
// out here — a pair never crosses a line, never leaves the pair
|
|
608
|
+
// around it, and since pairing kept them out of link labels, never
|
|
609
|
+
// leaves one of those either — but a pair dropped here would take
|
|
610
|
+
// the characters it had claimed with it, so the condition earns its
|
|
611
|
+
// place by making that impossible rather than by catching it.
|
|
612
|
+
const here = (context.pairs.get(at) ?? []).filter((pair) => {
|
|
613
|
+
const closer = context.tokens[pair.close];
|
|
614
|
+
return pair.close <= frame.to && closer?.kind === 'run';
|
|
615
|
+
});
|
|
616
|
+
if (here.length > 0) {
|
|
617
|
+
for (const pair of here) {
|
|
618
|
+
const closer = context.tokens[pair.close];
|
|
619
|
+
if (closer?.kind !== 'run') {
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
chunks.push('');
|
|
623
|
+
stack.push({
|
|
624
|
+
kind: 'pair',
|
|
625
|
+
to: pair.close,
|
|
626
|
+
loose: new Set(),
|
|
627
|
+
unclosed: false,
|
|
628
|
+
wraps: false,
|
|
629
|
+
slot: chunks.length - 1,
|
|
630
|
+
// Past the closing run for every pair that ends on it, the
|
|
631
|
+
// ones inside included: whatever encloses a pair sharing a run
|
|
632
|
+
// ends on that same run, so it is finished either way and takes
|
|
633
|
+
// its closing markup from the frame rather than from the token.
|
|
634
|
+
after: pair.close + 1,
|
|
635
|
+
open: token,
|
|
636
|
+
close: closer,
|
|
637
|
+
use: pair.use,
|
|
638
|
+
spare: [pair.before, pair.after],
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
at += 1;
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
write(frame, sourceOf(token));
|
|
645
|
+
at += 1;
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
if (token.kind === 'code') {
|
|
649
|
+
// Not through `write`: what stands in a snippet is not markup.
|
|
650
|
+
chunks.push(token.text);
|
|
651
|
+
frame.unclosed = frame.unclosed || !token.closed;
|
|
652
|
+
frame.wraps = frame.wraps || token.text.includes('\n');
|
|
653
|
+
at += 1;
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
write(frame, context.edits.get(at) ?? sourceOf(token));
|
|
657
|
+
at += 1;
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
/**
|
|
661
|
+
* One line, as a heading if it is one.
|
|
662
|
+
*
|
|
663
|
+
* A line here is what a reader sees, which is why the hashes inside a fence
|
|
664
|
+
* were never a question: the fence is a single token and its newlines are
|
|
665
|
+
* not breaks, so those lines do not exist to be mistaken for headings.
|
|
666
|
+
*
|
|
667
|
+
* The syntax is shed from the text tokens that actually hold it — the
|
|
668
|
+
* opening hashes from the first, the closing ones from the last. When the
|
|
669
|
+
* line ends inside a code run there is no last text token to take them
|
|
670
|
+
* from, and nothing is taken: the `#` at the end of `` # show `value # ``
|
|
671
|
+
* belongs to whoever wrote the snippet, and so does the whitespace beside
|
|
672
|
+
* it. That is a property of where the characters are rather than a rule
|
|
673
|
+
* about which of them are safe, which is what the rewrite it replaced kept
|
|
674
|
+
* getting wrong — it measured the strip with a pattern that knew `[ \t]`
|
|
675
|
+
* while the trim it guarded took a no-break space too.
|
|
676
|
+
*
|
|
677
|
+
* Bolding is the wrapper rule and two refusals of its own: a line that runs
|
|
678
|
+
* past its own end, through a snippet with a newline in it, is no longer
|
|
679
|
+
* the one line a heading is; and a line holding a fence nobody closed has
|
|
680
|
+
* no prose left at its end to put the closing marker in.
|
|
681
|
+
*/
|
|
682
|
+
const renderLine = (context, from, to) => {
|
|
683
|
+
const first = context.tokens[from];
|
|
684
|
+
if (first?.kind !== 'text') {
|
|
685
|
+
return renderRange(context, from, to).text;
|
|
686
|
+
}
|
|
687
|
+
const opener = HEADING_OPENER.exec(first.text);
|
|
688
|
+
if (opener === null) {
|
|
689
|
+
return renderRange(context, from, to).text;
|
|
690
|
+
}
|
|
691
|
+
const edits = new Map(context.edits);
|
|
692
|
+
edits.set(from, first.text.slice(opener[0].length));
|
|
693
|
+
const last = to - 1;
|
|
694
|
+
const tail = last > from ? context.tokens[last] : undefined;
|
|
695
|
+
if (last === from) {
|
|
696
|
+
edits.set(from, (edits.get(from) ?? '').replace(HEADING_CLOSER, ''));
|
|
697
|
+
}
|
|
698
|
+
else if (tail?.kind === 'text') {
|
|
699
|
+
edits.set(last, tail.text.replace(HEADING_CLOSER, ''));
|
|
700
|
+
}
|
|
701
|
+
const heading = renderRange({ ...context, edits }, from, to);
|
|
702
|
+
if (heading.text.length === 0) {
|
|
703
|
+
return renderRange(context, from, to).text;
|
|
704
|
+
}
|
|
705
|
+
const plain = !heading.loose.has('*') && !heading.unclosed && !heading.wraps;
|
|
706
|
+
return plain ? `*${heading.text}*` : heading.text;
|
|
707
|
+
};
|
|
708
|
+
/**
|
|
709
|
+
* Whether two lines are the header and rule `renderTable` writes: a rule of
|
|
710
|
+
* `─` crossed by `┼`, and a header whose `│` separators stand in exactly
|
|
711
|
+
* those columns. The shape, not a single box-drawing line, because a log
|
|
712
|
+
* that opens with a title and a `────` underline is somebody's text, and
|
|
713
|
+
* repeating its first lines on every part would change it.
|
|
714
|
+
*/
|
|
715
|
+
const isGridHeader = (header, rule) => {
|
|
716
|
+
if (!/^─[─┼]*─$/.test(rule) || !rule.includes('┼')) {
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
// In display columns, the unit the grid was padded in: counted any other
|
|
720
|
+
// way, an emoji or a CJK character before a separator moved it.
|
|
721
|
+
const crossings = columnsOf(rule, '┼');
|
|
722
|
+
const separators = columnsOf(header, '│');
|
|
723
|
+
return crossings.length === separators.length && crossings.every((column, index) => separators[index] === column);
|
|
724
|
+
};
|
|
725
|
+
/**
|
|
726
|
+
* A fence's first line — and, for a table grid, the header and rule after
|
|
727
|
+
* it, so a grid split across messages names its columns in every part
|
|
728
|
+
* rather than leaving the later ones as rows nobody can read. A grid always
|
|
729
|
+
* has two columns or more (see `renderTable`), so its rule always has a `┼`.
|
|
730
|
+
*/
|
|
731
|
+
const fenceOpener = (source, lineEnd) => {
|
|
732
|
+
const headerEnd = source.indexOf('\n', lineEnd + 1);
|
|
733
|
+
const ruleEnd = headerEnd === -1 ? -1 : source.indexOf('\n', headerEnd + 1);
|
|
734
|
+
const rule = ruleEnd === -1 ? '' : source.slice(headerEnd + 1, ruleEnd);
|
|
735
|
+
const header = headerEnd === -1 ? '' : source.slice(lineEnd + 1, headerEnd);
|
|
736
|
+
return isGridHeader(header, rule) ? source.slice(0, ruleEnd + 1) : source.slice(0, lineEnd + 1);
|
|
737
|
+
};
|
|
738
|
+
/**
|
|
739
|
+
* Where the code runs sit in already-converted text, read by the same scan
|
|
740
|
+
* that converts it — so the splitter agrees with the converter about what
|
|
741
|
+
* is code, rather than keeping a second idea of it that drifts. Conversion
|
|
742
|
+
* never touches a code token, so the runs of its output are the runs of its
|
|
743
|
+
* input, and scanning it again finds them where they are.
|
|
744
|
+
*/
|
|
745
|
+
export const codeRunsOf = (text) => {
|
|
746
|
+
const runs = [];
|
|
747
|
+
let at = 0;
|
|
748
|
+
for (const token of scan(text)) {
|
|
749
|
+
const source = sourceOf(token);
|
|
750
|
+
if (token.kind === 'code') {
|
|
751
|
+
let ticks = 0;
|
|
752
|
+
while (source[ticks] === '`') {
|
|
753
|
+
ticks += 1;
|
|
754
|
+
}
|
|
755
|
+
const fence = '`'.repeat(ticks);
|
|
756
|
+
const lineEnd = source.indexOf('\n');
|
|
757
|
+
runs.push(ticks >= 3 && lineEnd !== -1
|
|
758
|
+
? { start: at, end: at + source.length, opener: fenceOpener(source, lineEnd), closer: `\n${fence}` }
|
|
759
|
+
: { start: at, end: at + source.length, opener: fence, closer: fence });
|
|
760
|
+
}
|
|
761
|
+
at += source.length;
|
|
762
|
+
}
|
|
763
|
+
return runs;
|
|
764
|
+
};
|
|
765
|
+
/**
|
|
766
|
+
* Slack's mrkdwn has no tables, so a GFM pipe table reached a thread as rows
|
|
767
|
+
* of literal `|` and a `|---|---|` line (#217). A table becomes a code block
|
|
768
|
+
* with its columns padded to line up — monospace reads as a grid in every
|
|
769
|
+
* client — or, when that grid is wider than a phone shows without wrapping,
|
|
770
|
+
* one line per row naming each value by its header.
|
|
771
|
+
*/
|
|
772
|
+
const TABLE_MAX_WIDTH = 60;
|
|
773
|
+
/**
|
|
774
|
+
* A row's cells: outer pipes dropped, split on the pipes that are not
|
|
775
|
+
* escaped. Read a character at a time, because escaping is a matter of
|
|
776
|
+
* parity: in `a\\|b` the backslashes escape each other and the pipe
|
|
777
|
+
* separates, which no single-character lookbehind can see.
|
|
778
|
+
*/
|
|
779
|
+
const readRow = (line) => {
|
|
780
|
+
const row = line.trim();
|
|
781
|
+
const cells = [];
|
|
782
|
+
let cell = '';
|
|
783
|
+
let separated = false;
|
|
784
|
+
let endedOnSeparator = false;
|
|
785
|
+
for (let at = 0; at < row.length; at += 1) {
|
|
786
|
+
const char = row[at] ?? '';
|
|
787
|
+
endedOnSeparator = false;
|
|
788
|
+
if (char === '\\' && at + 1 < row.length) {
|
|
789
|
+
const next = row[at + 1] ?? '';
|
|
790
|
+
cell += next === '|' ? '|' : `${char}${next}`;
|
|
791
|
+
at += 1;
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
if (char === '|') {
|
|
795
|
+
cells.push(cell.trim());
|
|
796
|
+
cell = '';
|
|
797
|
+
separated = true;
|
|
798
|
+
endedOnSeparator = true;
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
cell += char;
|
|
802
|
+
}
|
|
803
|
+
if (!endedOnSeparator) {
|
|
804
|
+
cells.push(cell.trim());
|
|
805
|
+
}
|
|
806
|
+
if (row.startsWith('|')) {
|
|
807
|
+
cells.shift();
|
|
808
|
+
}
|
|
809
|
+
return { cells, separated };
|
|
810
|
+
};
|
|
811
|
+
const tableCells = (line) => readRow(line).cells;
|
|
812
|
+
/**
|
|
813
|
+
* Whether a line has a pipe the row reader splits on. A line whose only
|
|
814
|
+
* pipe is escaped is prose, whatever follows it: `a\|b` over a `---`
|
|
815
|
+
* underline is a sentence, not a one-column table.
|
|
816
|
+
*/
|
|
817
|
+
const isTableRow = (line) => readRow(line).separated;
|
|
818
|
+
/** The delimiter row's alignments, or nothing when the line is not one. */
|
|
819
|
+
const tableAlignments = (line) => {
|
|
820
|
+
if (!line.includes('-')) {
|
|
821
|
+
return undefined;
|
|
822
|
+
}
|
|
823
|
+
const cells = tableCells(line);
|
|
824
|
+
if (!cells.every((cell) => /^:?-+:?$/.test(cell))) {
|
|
825
|
+
return undefined;
|
|
826
|
+
}
|
|
827
|
+
return cells.map((cell) => (cell.startsWith(':') && cell.endsWith(':') ? 'center' : cell.endsWith(':') ? 'right' : 'left'));
|
|
828
|
+
};
|
|
829
|
+
/**
|
|
830
|
+
* A cell as the grid shows it. Inside a code block Slack renders nothing,
|
|
831
|
+
* so the emphasis and code markers a model wraps a cell in would show as
|
|
832
|
+
* characters; they are dropped, and the words they marked stay. Which
|
|
833
|
+
* markers are emphasis is the converter's own reading (`readEmphasis`), not
|
|
834
|
+
* a second one: patterns written here missed italics and `***both***`, and
|
|
835
|
+
* then stripped `2 ** 3 ** 4`, each a rule the converter already had.
|
|
836
|
+
*/
|
|
837
|
+
const plainCell = (cell) => {
|
|
838
|
+
const tokens = scan(cell);
|
|
839
|
+
const spent = new Map();
|
|
840
|
+
for (const pairs of readEmphasis(tokens).pairs.values()) {
|
|
841
|
+
for (const pair of pairs) {
|
|
842
|
+
spent.set(pair.open, (spent.get(pair.open) ?? 0) + pair.use);
|
|
843
|
+
spent.set(pair.close, (spent.get(pair.close) ?? 0) + pair.use);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return tokens
|
|
847
|
+
.map((token, index) => {
|
|
848
|
+
if (token.kind === 'run') {
|
|
849
|
+
return token.char.repeat(token.length - (spent.get(index) ?? 0));
|
|
850
|
+
}
|
|
851
|
+
if (token.kind === 'code' && token.closed) {
|
|
852
|
+
let ticks = 0;
|
|
853
|
+
while (token.text[ticks] === '`') {
|
|
854
|
+
ticks += 1;
|
|
855
|
+
}
|
|
856
|
+
// CommonMark's rule, not a trim: one space comes off each side when
|
|
857
|
+
// both sides have one and the code is not all spaces, so ` a `
|
|
858
|
+
// keeps the ` a ` it was written to show.
|
|
859
|
+
const code = token.text.slice(ticks, -ticks);
|
|
860
|
+
return code.startsWith(' ') && code.endsWith(' ') && code.trim().length > 0 ? code.slice(1, -1) : code;
|
|
861
|
+
}
|
|
862
|
+
return sourceOf(token);
|
|
863
|
+
})
|
|
864
|
+
.join('')
|
|
865
|
+
// The one italic the converter leaves alone, because Slack already
|
|
866
|
+
// reads `_x_` as italic outside code — inside the grid it would show.
|
|
867
|
+
// Same hugging and word-boundary rule the converter applies to runs.
|
|
868
|
+
.replace(/(?<![\p{L}\p{N}_])_(?=[^\s_])(.*?[^\s_])_(?![\p{L}\p{N}_])/gu, '$1');
|
|
869
|
+
};
|
|
870
|
+
/**
|
|
871
|
+
* How many monospace columns text takes, counted per displayed character
|
|
872
|
+
* (grapheme cluster): two for wide East Asian characters and any emoji —
|
|
873
|
+
* `👨👩👧👦` is one glyph, not four — none for a stray combining mark, one
|
|
874
|
+
* for the rest. A grid
|
|
875
|
+
* padded by UTF-16 length put `漢` in one column and drew two, and every
|
|
876
|
+
* separator after it moved. An approximation of Unicode's width tables,
|
|
877
|
+
* which Slack's own font does not follow exactly either.
|
|
878
|
+
*/
|
|
879
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
880
|
+
/** One displayed character's columns: what its first code point takes, or two for any emoji. */
|
|
881
|
+
const clusterWidth = (cluster) => {
|
|
882
|
+
// Flags (regional-indicator pairs), keycaps, and anything asking for
|
|
883
|
+
// emoji presentation are emoji too, though none of them is pictographic.
|
|
884
|
+
if (/[\p{Extended_Pictographic}\u{1f1e6}-\u{1f1ff}\u20e3\ufe0f]/u.test(cluster)) {
|
|
885
|
+
return 2;
|
|
886
|
+
}
|
|
887
|
+
const code = cluster.codePointAt(0) ?? 0;
|
|
888
|
+
if (/^[\p{Mn}\p{Me}\u200b-\u200f\ufe00-\ufe0f]/u.test(cluster)) {
|
|
889
|
+
return 0;
|
|
890
|
+
}
|
|
891
|
+
const wide = (code >= 0x1100 && code <= 0x115f)
|
|
892
|
+
|| (code >= 0x2e80 && code <= 0x303e)
|
|
893
|
+
|| (code >= 0x3041 && code <= 0x33ff)
|
|
894
|
+
|| (code >= 0x3400 && code <= 0x4dbf)
|
|
895
|
+
|| (code >= 0x4e00 && code <= 0x9fff)
|
|
896
|
+
|| (code >= 0xa000 && code <= 0xa4cf)
|
|
897
|
+
|| (code >= 0xac00 && code <= 0xd7a3)
|
|
898
|
+
|| (code >= 0xf900 && code <= 0xfaff)
|
|
899
|
+
|| (code >= 0xfe30 && code <= 0xfe4f)
|
|
900
|
+
|| (code >= 0xff00 && code <= 0xff60)
|
|
901
|
+
|| (code >= 0xffe0 && code <= 0xffe6)
|
|
902
|
+
|| (code >= 0x20000 && code <= 0x3fffd);
|
|
903
|
+
return wide ? 2 : 1;
|
|
904
|
+
};
|
|
905
|
+
const displayWidth = (text) => {
|
|
906
|
+
let width = 0;
|
|
907
|
+
for (const { segment } of graphemes.segment(text)) {
|
|
908
|
+
width += clusterWidth(segment);
|
|
909
|
+
}
|
|
910
|
+
return width;
|
|
911
|
+
};
|
|
912
|
+
/** The display columns at which `mark` stands in a line. */
|
|
913
|
+
const columnsOf = (line, mark) => {
|
|
914
|
+
const columns = [];
|
|
915
|
+
let column = 0;
|
|
916
|
+
for (const { segment } of graphemes.segment(line)) {
|
|
917
|
+
if (segment === mark) {
|
|
918
|
+
columns.push(column);
|
|
919
|
+
}
|
|
920
|
+
column += clusterWidth(segment);
|
|
921
|
+
}
|
|
922
|
+
return columns;
|
|
923
|
+
};
|
|
924
|
+
const pad = (text, width, alignment) => {
|
|
925
|
+
const gap = width - displayWidth(text);
|
|
926
|
+
if (alignment === 'right') {
|
|
927
|
+
return `${' '.repeat(gap)}${text}`;
|
|
928
|
+
}
|
|
929
|
+
if (alignment === 'center') {
|
|
930
|
+
const left = Math.floor(gap / 2);
|
|
931
|
+
return `${' '.repeat(left)}${text}${' '.repeat(gap - left)}`;
|
|
932
|
+
}
|
|
933
|
+
return `${text}${' '.repeat(gap)}`;
|
|
934
|
+
};
|
|
935
|
+
/**
|
|
936
|
+
* A header as a label in the list and one-column forms, which mrkdwn reads
|
|
937
|
+
* like prose. Bold only when it holds no marker character at all — the
|
|
938
|
+
* heading renderer's rule, since a loose `*` in `glob *.ts` pairs with the
|
|
939
|
+
* wrapper's own; otherwise the
|
|
940
|
+
* header goes as written, so its own markup is converted rather than
|
|
941
|
+
* unwrapped — a header showing ``` in a code span kept only the three
|
|
942
|
+
* backticks, and they opened a fence that ran to the end of the reply.
|
|
943
|
+
*/
|
|
944
|
+
const tableLabel = (header) => header.length === 0 ? '' : /[*_~`]/.test(header) ? header : `**${header}**`;
|
|
945
|
+
const renderTable = (header, alignments, rows) => {
|
|
946
|
+
const columns = header.length;
|
|
947
|
+
const fit = (row) => Array.from({ length: columns }, (_, index) => row[index] ?? '');
|
|
948
|
+
// One column has nothing to line up, so it is a list under its header.
|
|
949
|
+
// It is also what keeps every grid recognisable when split: a grid always
|
|
950
|
+
// has a `┼` in its rule, which a log's `────` underline never does.
|
|
951
|
+
if (columns === 1) {
|
|
952
|
+
const label = tableLabel(header[0] ?? '');
|
|
953
|
+
const items = rows.map((row) => row[0] ?? '').filter((cell) => cell.length > 0).map((cell) => `• ${cell}`);
|
|
954
|
+
return [...(label.length > 0 ? [label] : []), ...items].join('\n');
|
|
955
|
+
}
|
|
956
|
+
const grid = [header, ...rows.map(fit)].map((row) => row.map(plainCell));
|
|
957
|
+
const widths = Array.from({ length: columns }, (_, index) => Math.max(...grid.map((row) => displayWidth(row[index] ?? ''))));
|
|
958
|
+
const width = widths.reduce((sum, each) => sum + each, 0) + (columns - 1) * 3;
|
|
959
|
+
// A backtick run in a cell could close the fence early, and a link inside
|
|
960
|
+
// a code block is its source text rather than something to click — so a
|
|
961
|
+
// table with either takes the list form, where mrkdwn reads the cells as
|
|
962
|
+
// it reads prose. Links are found by the converter's own reader.
|
|
963
|
+
// A bare address counts too, and so does Slack's own markup — `<@U…>`,
|
|
964
|
+
// `<#C…>`, `<!here>`: Slack acts on all of them in prose and on none of
|
|
965
|
+
// them inside a code block.
|
|
966
|
+
const linked = [header, ...rows].some((row) => row.some((cell) => readEmphasis(scan(cell)).links.size > 0
|
|
967
|
+
|| /\b(?:https?:\/\/|mailto:)\S|<[a-z][a-z0-9+.-]*:[^>\s]+>|<[@#!][^>\s]+>/i.test(cell)));
|
|
968
|
+
if (width <= TABLE_MAX_WIDTH && !linked && !grid.some((row) => row.some((cell) => cell.includes('```')))) {
|
|
969
|
+
const line = (row) => row.map((cell, index) => pad(cell, widths[index] ?? 0, alignments[index] ?? 'left')).join(' │ ').trimEnd();
|
|
970
|
+
const rule = widths.map((each) => '─'.repeat(each)).join('─┼─');
|
|
971
|
+
return ['```', line(grid[0] ?? []), rule, ...grid.slice(1).map(line), '```'].join('\n');
|
|
972
|
+
}
|
|
973
|
+
// The list form names each value by its header, so a table with no rows
|
|
974
|
+
// has nothing to name; its header is what it says, and it stays said.
|
|
975
|
+
const headerOnly = header.map(tableLabel).filter((label) => label.length > 0).join(' · ');
|
|
976
|
+
if (rows.length === 0) {
|
|
977
|
+
return headerOnly;
|
|
978
|
+
}
|
|
979
|
+
const listed = rows
|
|
980
|
+
.map(fit)
|
|
981
|
+
.map((row) => row
|
|
982
|
+
.map((cell, index) => ({ label: tableLabel(header[index] ?? ''), cell }))
|
|
983
|
+
.filter(({ cell }) => cell.length > 0)
|
|
984
|
+
.map(({ label, cell }) => (label.length > 0 ? `${label}: ${cell}` : cell))
|
|
985
|
+
.join(' · '))
|
|
986
|
+
.filter((line) => line.length > 0);
|
|
987
|
+
return listed.length > 0 ? listed.join('\n') : headerOnly;
|
|
988
|
+
};
|
|
989
|
+
/** Four spaces or a tab: an indented code block, which no table row may be. */
|
|
990
|
+
const isIndentedCode = (line) => /^(?: {4,}|\t)/.test(line);
|
|
991
|
+
/**
|
|
992
|
+
* The reply with every pipe table outside code rendered for Slack. A table
|
|
993
|
+
* inside a fence is somebody's text and stays as written — found by the
|
|
994
|
+
* converter's own scan, so this agrees with it about what is code.
|
|
995
|
+
*/
|
|
996
|
+
const renderTables = (text) => {
|
|
997
|
+
// Only a run that spans lines hides a table; an inline span in a cell is
|
|
998
|
+
// part of the row, the way GFM reads it.
|
|
999
|
+
const code = codeRunsOf(text).filter((run) => text.slice(run.start, run.end).includes('\n'));
|
|
1000
|
+
const lines = text.split('\n');
|
|
1001
|
+
// Runs and lines are both in order, so one pointer walks the runs: asking
|
|
1002
|
+
// every run about every line was quadratic in a reply of many snippets.
|
|
1003
|
+
const inCode = [];
|
|
1004
|
+
let offset = 0;
|
|
1005
|
+
let next = 0;
|
|
1006
|
+
for (const line of lines) {
|
|
1007
|
+
const start = offset;
|
|
1008
|
+
const end = offset + line.length;
|
|
1009
|
+
while (next < code.length && (code[next]?.end ?? 0) <= start) {
|
|
1010
|
+
next += 1;
|
|
1011
|
+
}
|
|
1012
|
+
const run = code[next];
|
|
1013
|
+
inCode.push(run !== undefined && run.start < end + 1 && start < run.end);
|
|
1014
|
+
offset = end + 1;
|
|
1015
|
+
}
|
|
1016
|
+
const out = [];
|
|
1017
|
+
let at = 0;
|
|
1018
|
+
while (at < lines.length) {
|
|
1019
|
+
const headerLine = lines[at] ?? '';
|
|
1020
|
+
const alignments = inCode[at] || inCode[at + 1] ? undefined : tableAlignments(lines[at + 1] ?? '');
|
|
1021
|
+
const header = tableCells(headerLine);
|
|
1022
|
+
// Four spaces or a tab of indentation is an indented code block, not a
|
|
1023
|
+
// table — GFM allows a table at most three.
|
|
1024
|
+
const indented = isIndentedCode(headerLine) || isIndentedCode(lines[at + 1] ?? '');
|
|
1025
|
+
if (alignments === undefined || indented || !isTableRow(headerLine) || header.length !== alignments.length) {
|
|
1026
|
+
out.push(headerLine);
|
|
1027
|
+
at += 1;
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
const rows = [];
|
|
1031
|
+
let next = at + 2;
|
|
1032
|
+
while (next < lines.length && !inCode[next] && !isIndentedCode(lines[next] ?? '') && isTableRow(lines[next] ?? '')) {
|
|
1033
|
+
rows.push(tableCells(lines[next] ?? ''));
|
|
1034
|
+
next += 1;
|
|
1035
|
+
}
|
|
1036
|
+
out.push(renderTable(header, alignments, rows));
|
|
1037
|
+
at = next;
|
|
1038
|
+
}
|
|
1039
|
+
return out.join('\n');
|
|
1040
|
+
};
|
|
1041
|
+
/**
|
|
1042
|
+
* The links in a token stream and the emphasis pairs around them — the one
|
|
1043
|
+
* reading of emphasis, shared by conversion and by a table cell shedding its
|
|
1044
|
+
* markers, so the two cannot disagree about what a `*` is.
|
|
1045
|
+
*/
|
|
1046
|
+
const readEmphasis = (tokens) => {
|
|
1047
|
+
// Links are found before emphasis is paired, because what they turn out
|
|
1048
|
+
// to cover decides what is left for a delimiter to pair with: the
|
|
1049
|
+
// characters of a destination are the address, not markup.
|
|
1050
|
+
const links = findLinks(tokens);
|
|
1051
|
+
const inert = new Set();
|
|
1052
|
+
for (const link of links.values()) {
|
|
1053
|
+
for (let at = link.label[1] + 2; at < link.through; at += 1) {
|
|
1054
|
+
inert.add(at);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
// Where the label each token sits in begins, so pairing can tell that
|
|
1058
|
+
// reaching further back would cross out of a link.
|
|
1059
|
+
const labelled = new Array(tokens.length).fill(-1);
|
|
1060
|
+
for (const [opener, link] of links) {
|
|
1061
|
+
for (let at = link.label[0]; at < link.label[1]; at += 1) {
|
|
1062
|
+
labelled[at] = opener;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
return { links, pairs: pairEmphasis(tokens, inert, labelled) };
|
|
1066
|
+
};
|
|
1067
|
+
export const toSlackMrkdwn = (text) => {
|
|
1068
|
+
const tokens = scan(renderTables(text));
|
|
1069
|
+
const { links, pairs } = readEmphasis(tokens);
|
|
1070
|
+
const context = { tokens, pairs, links, edits: new Map() };
|
|
1071
|
+
const lines = [];
|
|
1072
|
+
let start = 0;
|
|
1073
|
+
for (let at = 0; at <= tokens.length; at += 1) {
|
|
1074
|
+
if (at === tokens.length || tokens[at]?.kind === 'break') {
|
|
1075
|
+
lines.push(renderLine(context, start, at));
|
|
1076
|
+
start = at + 1;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return lines.join('\n');
|
|
1080
|
+
};
|
|
1081
|
+
//# sourceMappingURL=mrkdwn.js.map
|