Open History to see the last commit
1import { markdownMathRanges } from "./markdownMath.ts";
2
3export type ComposerInlineToken =
4 | {
5 readonly type: "mention";
6 readonly value: string;
7 readonly source: string;
8 readonly start: number;
9 readonly end: number;
10 }
11 | {
12 readonly type: "skill";
13 readonly value: string;
14 readonly source: string;
15 readonly start: number;
16 readonly end: number;
17 };
18
19export interface CollectComposerInlineTokensOptions {
20 readonly preserveTrailingFrom?: ReadonlyArray<ComposerInlineToken>;
21}
22
23const SKILL_MENTION_SOURCE =
24 /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)/u
25 .source;
26const SKILL_TOKEN_REGEX = new RegExp(`${SKILL_MENTION_SOURCE}(?=\\s)`, "gu");
27export const SKILL_MENTION_PATTERN = new RegExp(`${SKILL_MENTION_SOURCE}(?=\\s|$)`, "gu");
28const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;
29// Bound unmatched-bracket rescans to avoid quadratic work; only basenames survive, and common filesystems cap them at 255.
30const MAX_FILE_LINK_LABEL_LENGTH = 512;
31const FILE_LINK_TOKEN_REGEX = new RegExp(
32 `(^|\\s)\\[((?:\\\\.|[^\\]\\\\]){0,${MAX_FILE_LINK_LABEL_LENGTH}})\\]\\(([^)\\s]+)\\)(?=\\s)`,
33 "g",
34);
35const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/;
36const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/;
37const SCOPED_PACKAGE_REFERENCE_REGEX =
38 /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?:\/[^\s@"]+)*$/;
39const DECORATOR_CALL_REFERENCE_REGEX = /^[\p{ID_Start}_$][\p{ID_Continue}.$\u200c\u200d]*\(/u;
40
41function collectMentionTokens(text: string): ComposerInlineToken[] {
42 const matches: ComposerInlineToken[] = [];
43
44 for (const match of text.matchAll(FILE_LINK_TOKEN_REGEX)) {
45 const fullMatch = match[0];
46 const prefix = match[1] ?? "";
47 const label = (match[2] ?? "").replace(/\\(.)/g, "$1");
48 const encodedPath = match[3] ?? "";
49 let path = encodedPath;
50 try {
51 path = decodeURIComponent(encodedPath);
52 } catch {}
53 const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
54 const basename = separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;
55 const hasExternalScheme = URI_SCHEME_REGEX.test(path) && !WINDOWS_DRIVE_PATH_REGEX.test(path);
56 if (!path || hasExternalScheme || label !== basename) {
57 continue;
58 }
59 const start = (match.index ?? 0) + prefix.length;
60 const end = start + fullMatch.length - prefix.length;
61 matches.push({
62 type: "mention",
63 value: path,
64 source: text.slice(start, end),
65 start,
66 end,
67 });
68 }
69
70 for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
71 const fullMatch = match[0];
72 const prefix = match[1] ?? "";
73 const quotedPath = match[2];
74 const path = quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (match[3] ?? "");
75 if (
76 !path ||
77 (quotedPath === undefined &&
78 (SCOPED_PACKAGE_REFERENCE_REGEX.test(path) || DECORATOR_CALL_REFERENCE_REGEX.test(path)))
79 ) {
80 continue;
81 }
82 const start = (match.index ?? 0) + prefix.length;
83 const end = start + fullMatch.length - prefix.length;
84 matches.push({
85 type: "mention",
86 value: path,
87 source: text.slice(start, end),
88 start,
89 end,
90 });
91 }
92
93 return matches;
94}
95
96/**
97 * Formulas in composer text, ignoring `$` and `\[` inside file links such as `posts.$postId.tsx`.
98 * Links are masked with letters, not spaces, so a leading link cannot become an indented code
99 * block. Bare `@path` mentions stay unmasked so `@README.md$` can still close a formula.
100 */
101export function composerMathRanges(
102 text: string,
103 mentions: ReadonlyArray<ComposerInlineToken> = collectMentionTokens(text),
104) {
105 let masked = text;
106 for (const mention of mentions) {
107 if (mention.type !== "mention" || !mention.source.startsWith("[")) continue;
108 masked =
109 masked.slice(0, mention.start) +
110 "x".repeat(mention.end - mention.start) +
111 masked.slice(mention.end);
112 }
113 return markdownMathRanges(masked).map(({ start, end }) => ({
114 start,
115 end,
116 source: text.slice(start, end),
117 }));
118}
119
120export function collectComposerInlineTokens(
121 text: string,
122 options: CollectComposerInlineTokensOptions = {},
123): ReadonlyArray<ComposerInlineToken> {
124 const matches = collectMentionTokens(text);
125
126 for (const match of text.matchAll(SKILL_TOKEN_REGEX)) {
127 const fullMatch = match[0];
128 const prefix = match[1] ?? "";
129 const value = match[2] ?? "";
130 if (!value) {
131 continue;
132 }
133 const start = (match.index ?? 0) + prefix.length;
134 const end = start + fullMatch.length - prefix.length;
135 matches.push({
136 type: "skill",
137 value,
138 source: text.slice(start, end),
139 start,
140 end,
141 });
142 }
143
144 for (const token of options.preserveTrailingFrom ?? []) {
145 if (
146 token.end === text.length &&
147 text.slice(token.start, token.end) === token.source &&
148 !matches.some(
149 (match) =>
150 match.type === token.type && match.start === token.start && match.end === token.end,
151 )
152 ) {
153 matches.push(token);
154 }
155 }
156
157 // TeX can contain literal skill and file tokens, especially inside \text{}.
158 // They remain formula source rather than becoming composer context chips.
159 // Confirmed trailing file links are masked too, so a kept chip is not read as TeX.
160 const math = matches.length > 0 ? composerMathRanges(text, matches) : [];
161 return matches
162 .filter(
163 (match) => !math.some((formula) => match.start < formula.end && match.end > formula.start),
164 )
165 .sort((left, right) => left.start - right.start);
166}