slack-term 1.39.2 → 1.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ts/urlGuard.ts CHANGED
@@ -1,20 +1,172 @@
1
1
  /** Bare Unicode URL text has an ambiguous boundary for Slack's autolinker.
2
2
  * Explicit Slack links delimit the URL, including intentional Unicode paths.
3
3
  * ASCII whitespace (especially newlines) is a safe boundary; Unicode spaces
4
- * should be made explicit too. Do not rewrite the message or its newlines. */
4
+ * should be made explicit too. Do not rewrite the message or its newlines.
5
+ *
6
+ * Both ends matter. A URL glued to the text AFTER it (`https://x/内容`) gets the
7
+ * text swallowed into the link; a URL glued to the text BEFORE it
8
+ * (`说明:https://x/`) is not autolinked at all — Slack stores it as a plain
9
+ * `text` element and every client shows it as dead text. The markdown
10
+ * autolinker (GFM rules) only starts a link at the start of a line, after
11
+ * ASCII whitespace, or after one of `( * _ ~`. */
12
+
13
+ // `\x00` is maskCode's filler: a bare URL stops at a masked span, so a URL
14
+ // on the far side of one is its own token with its own left boundary.
15
+ const URL_TOKEN = /<https?:\/\/[^\s<>|]+(?:\|[^<>\r\n]*)?>|https?:\/\/[^\t\n\v\f\r <\x00]+/gi;
16
+ /** What may sit right before a bare URL and still let it autolink. */
17
+ const SAFE_BEFORE = /[\t\n\v\f\r (*_~]/;
18
+
19
+ export type UrlBoundaryIssue = { url: string; side: "before" | "after"; char: string };
20
+
21
+ /** Code spans and fences are shown verbatim and never autolinked, so a URL in
22
+ * one is not a boundary problem; neither is a `[label](url)` link, which is
23
+ * explicit. Blanked (length-preserving, newlines kept) rather than removed so
24
+ * indices still line up with the original text; the filler is a non-space so a
25
+ * URL glued to the END of a span is still caught.
26
+ *
27
+ * A small CommonMark-shaped scanner, not a regex: a backslash escapes the
28
+ * next character (`\`` is a literal backtick, not a span opener), a span
29
+ * opened by a run of N backticks closes only at the next run of exactly N,
30
+ * and an unclosed run is literal text. A fence is ``` or ~~~ at line start
31
+ * and runs to a matching fence line, or to the end. */
32
+ function maskCode(text: string): string {
33
+ const out = text.split("");
34
+ const blank = (from: number, to: number) => {
35
+ for (let k = from; k < to; k++) if (out[k] !== "\n") out[k] = "\x00";
36
+ };
37
+ let i = 0;
38
+ while (i < text.length) {
39
+ const lineStart = i === 0 || text[i - 1] === "\n";
40
+ // A backtick fence's info string may not contain a backtick (```x``` on
41
+ // one line is an inline span, not a fence opener).
42
+ const fence = lineStart ? /^ {0,3}(?:(`{3,})[^`\n]*|(~{3,})[^\n]*)(?=\n|$)/.exec(text.slice(i)) : null;
43
+ if (fence) {
44
+ const marker = fence[1] ?? fence[2]!;
45
+ const close = new RegExp(`\n {0,3}${marker[0] === "`" ? "`" : "~"}{${marker.length},}[ \t]*\r?(?=\n|$)`, "g");
46
+ close.lastIndex = i + fence[0].length;
47
+ const m = close.exec(text);
48
+ const end = m ? m.index + m[0].length : text.length;
49
+ blank(i, end);
50
+ i = end;
51
+ continue;
52
+ }
53
+ const ch = text[i]!;
54
+ if (ch === "\\") { i += 2; continue; }
55
+ if (ch === "`") {
56
+ let n = 0;
57
+ while (text[i + n] === "`") n++;
58
+ let j = i + n;
59
+ let end = -1;
60
+ while (j < text.length) {
61
+ // A blank line ends the paragraph, and a span cannot cross it.
62
+ if (text[j] === "\n" && /^\n[ \t\r]*(?:\n|$)/.test(text.slice(j))) break;
63
+ if (text[j] !== "`") { j++; continue; }
64
+ let r = 0;
65
+ while (text[j + r] === "`") r++;
66
+ if (r === n) { end = j + r; break; }
67
+ j += r;
68
+ }
69
+ if (end < 0) { i += n; continue; }
70
+ blank(i, end);
71
+ i = end;
72
+ continue;
73
+ }
74
+ if (ch === "[") {
75
+ // Escape-aware: `\]` does not close the label.
76
+ const link = /^\[(?:\\.|[^\]\\\n])*\]\([^()\s]*\)/.exec(text.slice(i));
77
+ if (link) { blank(i, i + link[0].length); i += link[0].length; continue; }
78
+ }
79
+ i++;
80
+ }
81
+ return out.join("");
82
+ }
83
+
84
+ /** `markdown: false` is for text that is NOT parsed as markdown (drafts, whose
85
+ * rich_text is built by `linkifyRichText`): there a backtick or `[label](url)`
86
+ * hides nothing, so nothing is masked. */
87
+ export function urlBoundaryIssues(text: string, { markdown = true }: { markdown?: boolean } = {}): UrlBoundaryIssue[] {
88
+ const masked = markdown ? maskCode(text) : text;
89
+ const issues: UrlBoundaryIssue[] = [];
90
+ for (const m of masked.matchAll(URL_TOKEN)) {
91
+ const url = m[0];
92
+ if (url.startsWith("<")) continue;
93
+ const at = m.index ?? 0;
94
+ const before = at > 0 ? masked[at - 1]! : "";
95
+ if (before && !SAFE_BEFORE.test(before)) {
96
+ issues.push({ url, side: "before", char: text[at - 1]! });
97
+ }
98
+ const after = url.match(/[^\x00-\x7f]|>/);
99
+ if (after) issues.push({ url, side: "after", char: after[0] });
100
+ }
101
+ return issues;
102
+ }
103
+
5
104
  export function adjacentUrls(text: string): string[] {
6
- const tokens = text.matchAll(/<https?:\/\/[^\s<>|]+(?:\|[^<>\r\n]*)?>|https?:\/\/[^\t\n\v\f\r <]+/gi);
7
- return [...tokens]
8
- .map(([token]) => token)
9
- .filter((token) => !token.startsWith("<") && (/[^\x00-\x7f]/.test(token) || token.includes(">")));
105
+ return [...new Set(urlBoundaryIssues(text).map((i) => i.url))];
10
106
  }
11
107
 
12
- export function guardUrlBoundaries(text: string, allow = false, warn: (message: string) => void = console.error): void {
13
- const urls = adjacentUrls(text);
14
- if (!urls.length) return;
15
- const message = `Ambiguous URL boundary: ${urls.map((url) => JSON.stringify(url)).join(", ")}. ` +
16
- "Put the URL on its own line, or wrap it as <url> / <url|label>. " +
108
+ export function guardUrlBoundaries(
109
+ text: string, allow = false, warn: (message: string) => void = console.error,
110
+ { sides = ["before", "after"], markdown = true }: {
111
+ sides?: ReadonlyArray<UrlBoundaryIssue["side"]>; markdown?: boolean;
112
+ } = {},
113
+ ): void {
114
+ const issues = urlBoundaryIssues(text, { markdown }).filter((i) => sides.includes(i.side));
115
+ if (!issues.length) return;
116
+ const described = issues.map((i) => i.side === "before"
117
+ ? `${JSON.stringify(i.url)} (glued to ${JSON.stringify(i.char)} before it — Slack will NOT make it a link)`
118
+ : `${JSON.stringify(i.url)} (runs into ${JSON.stringify(i.char)} after it)`);
119
+ const fixes = issues.some((i) => i.side === "before")
120
+ ? "Put an ASCII space (or a newline) before the URL, " : "";
121
+ const message = `Ambiguous URL boundary: ${described.join(", ")}. ` +
122
+ `${fixes}Put the URL on its own line, or wrap it as <url> / <url|label>. ` +
17
123
  "Use --allow-url-adjacent to warn only.";
18
124
  if (!allow) throw new Error(message);
19
125
  warn(`Warning: ${message}`);
20
126
  }
127
+
128
+ type RichTextElement =
129
+ | { type: "text"; text: string }
130
+ | { type: "link"; url: string; text?: string };
131
+
132
+ /** Elements for a `rich_text_section` with every URL as a real `link` element.
133
+ * Drafts are posted as raw rich_text, which Slack never autolinks: a URL left
134
+ * in a `text` element stays dead text however it is spaced. A bare URL ends at
135
+ * whitespace, `<`, or the first non-ASCII character (`https://x/内容` links
136
+ * `https://x/` only), and trailing sentence punctuation is left outside it.
137
+ * Whether that cut is the intended one is ambiguous, so callers gate it with
138
+ * `guardUrlBoundaries(text, allow, warn, { sides: ["after"], markdown: false })`
139
+ * first. */
140
+ export function linkifyRichText(text: string): RichTextElement[] {
141
+ const out: RichTextElement[] = [];
142
+ const pushText = (s: string) => {
143
+ if (!s) return;
144
+ const last = out[out.length - 1];
145
+ if (last?.type === "text") last.text += s;
146
+ else out.push({ type: "text", text: s });
147
+ };
148
+ const re = /<(https?:\/\/[^\s<>|]+)(?:\|([^<>\r\n]*))?>|https?:\/\/[\x21-\x3b\x3d\x3f-\x7e]+/gi;
149
+ let pos = 0;
150
+ for (const m of text.matchAll(re)) {
151
+ const at = m.index ?? 0;
152
+ pushText(text.slice(pos, at));
153
+ if (m[1]) {
154
+ out.push(m[2] ? { type: "link", url: m[1], text: m[2] } : { type: "link", url: m[1] });
155
+ pos = at + m[0].length;
156
+ continue;
157
+ }
158
+ let url = m[0];
159
+ // GFM-style trailing trim: sentence punctuation, and `)` with no `(` to match.
160
+ while (/[.,:;!?'"*_~]$/.test(url) || (url.endsWith(")") && count(url, "(") < count(url, ")"))) {
161
+ url = url.slice(0, -1);
162
+ }
163
+ out.push({ type: "link", url });
164
+ pos = at + url.length;
165
+ }
166
+ pushText(text.slice(pos));
167
+ return out;
168
+ }
169
+
170
+ function count(s: string, ch: string): number {
171
+ return s.split(ch).length - 1;
172
+ }