@oh-my-pi/pi-utils 18.4.9 → 18.4.11
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/CHANGELOG.md +27 -0
- package/dist/types/dirs.d.ts +5 -1
- package/dist/types/marked/core.d.ts +24 -0
- package/dist/types/marked/inline-closers.d.ts +119 -0
- package/dist/types/marked/inline-text.d.ts +70 -0
- package/dist/types/math-delimiters.d.ts +22 -0
- package/package.json +2 -2
- package/src/dirs.ts +68 -9
- package/src/marked/core.ts +234 -166
- package/src/marked/inline-closers.ts +434 -0
- package/src/marked/inline-text.ts +321 -0
- package/src/math-delimiters.ts +121 -19
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plain-text step of inline lexing over one inline source: where text can end ({@link TextStops}, which also
|
|
3
|
+
* tells the bare-URL rule where it can match) and how it is appended ({@link TextRun}).
|
|
4
|
+
*/
|
|
5
|
+
import type { Lexer, Token, TokenizerExtension, TokenizerStartFromFunction, Tokens } from "./core";
|
|
6
|
+
|
|
7
|
+
// Where plain text can end: the characters that start other inline tokens, and
|
|
8
|
+
// the scheme alternatives of the bare-URL rule. Global so a search can resume
|
|
9
|
+
// at `lastIndex`.
|
|
10
|
+
const TOKEN_START_CHAR = /[\\`<[!*_~\n]/g;
|
|
11
|
+
const BARE_URL_SCHEME = /https?:\/\/|ftp:\/\/|www\./gi;
|
|
12
|
+
// What the bare-URL rule's e-mail alternative requires after its "@", sticky to test one offset.
|
|
13
|
+
const DOTTED_DOMAIN = /[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+/iy;
|
|
14
|
+
/** A stop not searched for yet lies before every search offset. */
|
|
15
|
+
const UNSEARCHED = -1;
|
|
16
|
+
|
|
17
|
+
/** Whether `code` is in `[A-Za-z0-9._+-]`, the local part of a bare e-mail address. */
|
|
18
|
+
function isMailLocalChar(code: number): boolean {
|
|
19
|
+
return (
|
|
20
|
+
(code >= 0x61 && code <= 0x7a) ||
|
|
21
|
+
(code >= 0x41 && code <= 0x5a) ||
|
|
22
|
+
(code >= 0x30 && code <= 0x39) ||
|
|
23
|
+
code === 0x2e ||
|
|
24
|
+
code === 0x5f ||
|
|
25
|
+
code === 0x2b ||
|
|
26
|
+
code === 0x2d
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Where `startFrom` answers at or after `from` in `src`, `Infinity` for none. Any other answer throws. */
|
|
31
|
+
function askStartFrom(
|
|
32
|
+
extension: TokenizerExtension,
|
|
33
|
+
startFrom: TokenizerStartFromFunction,
|
|
34
|
+
lexer: Lexer,
|
|
35
|
+
src: string,
|
|
36
|
+
from: number,
|
|
37
|
+
): number {
|
|
38
|
+
const found = startFrom.call({ lexer }, src, from);
|
|
39
|
+
// Only undefined or a number at or past `from` answers: a -1 or NaN "none" would be searched for again at every
|
|
40
|
+
// text step, and `null >= 0` holds only at offset 0.
|
|
41
|
+
if (found !== undefined && !(typeof found === "number" && found >= from)) {
|
|
42
|
+
throw new Error(`inline extension "${extension.name}": startFrom returned ${found} for offset ${from}`);
|
|
43
|
+
}
|
|
44
|
+
return found ?? Infinity;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The places where plain text can end in one root inline source, as offsets into it, shared by every source lexed
|
|
49
|
+
* inside it (a link label, the text of emphasis). Each stop is the first offset at or after a search offset where a
|
|
50
|
+
* test passes, and each test reads the source only from its own offset on, so a found stop stays the answer for every
|
|
51
|
+
* later search offset up to it. A stop is searched for again only after lexing passes it, starting past it, so all
|
|
52
|
+
* the text steps of a paragraph, at every nesting level, read it a bounded number of times instead of once per step.
|
|
53
|
+
* Search offsets never decrease: the lexer asks at its position for the bare-URL rule and one past it for the text
|
|
54
|
+
* step, its position only grows, and a nested source is lexed from its start to its end before the lexer goes on
|
|
55
|
+
* past it.
|
|
56
|
+
*
|
|
57
|
+
* A nested source is the part of the root source before `end`, and a stop that holds there is a stop of the root
|
|
58
|
+
* source whose match ends by `end`. The first root stop after an offset tells whether there is one: when its match
|
|
59
|
+
* runs past `end`, no stop that ends by `end` follows it, for the reason each search gives.
|
|
60
|
+
*/
|
|
61
|
+
class RootTextStops {
|
|
62
|
+
readonly #src: string;
|
|
63
|
+
// The next character in TOKEN_START_CHAR.
|
|
64
|
+
#tokenChar = UNSEARCHED;
|
|
65
|
+
// The next bare-URL scheme and the end of its match.
|
|
66
|
+
#scheme = UNSEARCHED;
|
|
67
|
+
#schemeEnd = UNSEARCHED;
|
|
68
|
+
// The next offset in a run of e-mail local characters that ends at "@", and that "@".
|
|
69
|
+
#mail = UNSEARCHED;
|
|
70
|
+
#mailAt = UNSEARCHED;
|
|
71
|
+
// The next hard break (two or more spaces, or a backslash, before "\n") and its "\n".
|
|
72
|
+
#hardBreak = UNSEARCHED;
|
|
73
|
+
#hardBreakAt = UNSEARCHED;
|
|
74
|
+
// Per inline extension, the offset its `startFrom` last returned. Keyed by the extension itself: `Marked.use`
|
|
75
|
+
// can add extensions to the live registry during a lex, shifting every index.
|
|
76
|
+
#extensionStarts: Map<TokenizerExtension, number> | undefined;
|
|
77
|
+
|
|
78
|
+
constructor(src: string) {
|
|
79
|
+
this.#src = src;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The first character that can start another token at or after `from`. */
|
|
83
|
+
tokenCharFrom(from: number): number {
|
|
84
|
+
if (this.#tokenChar < from) {
|
|
85
|
+
TOKEN_START_CHAR.lastIndex = from;
|
|
86
|
+
this.#tokenChar = TOKEN_START_CHAR.test(this.#src) ? TOKEN_START_CHAR.lastIndex - 1 : Infinity;
|
|
87
|
+
}
|
|
88
|
+
return this.#tokenChar;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The first bare-URL scheme at or after `from` that ends by `end`. */
|
|
92
|
+
schemeFrom(from: number, end: number): number {
|
|
93
|
+
if (this.#scheme < from) {
|
|
94
|
+
BARE_URL_SCHEME.lastIndex = from;
|
|
95
|
+
const match = BARE_URL_SCHEME.exec(this.#src);
|
|
96
|
+
this.#scheme = match ? match.index : Infinity;
|
|
97
|
+
this.#schemeEnd = match ? BARE_URL_SCHEME.lastIndex : Infinity;
|
|
98
|
+
}
|
|
99
|
+
// Two scheme matches never overlap (inside one, no later character starts another), so when this one runs
|
|
100
|
+
// past `end`, the next starts past it.
|
|
101
|
+
return this.#schemeEnd <= end ? this.#scheme : Infinity;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The "@" of the run {@link mailFrom} last found. */
|
|
105
|
+
get mailAt(): number {
|
|
106
|
+
return this.#mailAt;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The first offset at or after `from` that starts a match of `/[A-Za-z0-9._+-]+@/` whose "@" is before `end`. */
|
|
110
|
+
mailFrom(from: number, end: number): number {
|
|
111
|
+
// Every offset in the run before the found "@" starts a match of its own, and reaches no other "@".
|
|
112
|
+
if (this.#mail < from) this.#mail = from < this.#mailAt ? from : this.#seekMail(from);
|
|
113
|
+
return this.#mailAt < end ? this.#mail : Infinity;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The first offset at or after `from` that starts a match of `/(?: {2,}|\\)\n/` whose "\n" is before `end`. */
|
|
117
|
+
hardBreakFrom(from: number, end: number): number {
|
|
118
|
+
// A text step never starts where a hard break still matches (the `br` rule takes it first), so a search
|
|
119
|
+
// after passing a found break starts at or past its "\n". A match ends at the first "\n" after it starts.
|
|
120
|
+
if (this.#hardBreak < from) this.#hardBreak = this.#seekHardBreak(from);
|
|
121
|
+
return this.#hardBreakAt < end ? this.#hardBreak : Infinity;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Where `extension`'s `startFrom` hint points at or after `from`, asked again only once lexing passes it. */
|
|
125
|
+
startFrom(extension: TokenizerExtension, startFrom: TokenizerStartFromFunction, lexer: Lexer, from: number): number {
|
|
126
|
+
const starts = (this.#extensionStarts ??= new Map());
|
|
127
|
+
const cached = starts.get(extension) ?? UNSEARCHED;
|
|
128
|
+
if (cached >= from) return cached;
|
|
129
|
+
const start = askStartFrom(extension, startFrom, lexer, this.#src, from);
|
|
130
|
+
starts.set(extension, start);
|
|
131
|
+
return start;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The first offset at or after `from` that starts a match of `/[A-Za-z0-9._+-]+@/`, recording its "@". */
|
|
135
|
+
#seekMail(from: number): number {
|
|
136
|
+
const src = this.#src;
|
|
137
|
+
for (let at = src.indexOf("@", from + 1); at !== -1; at = src.indexOf("@", at + 1)) {
|
|
138
|
+
let start = at;
|
|
139
|
+
while (start > from && isMailLocalChar(src.charCodeAt(start - 1))) start--;
|
|
140
|
+
if (start < at) {
|
|
141
|
+
this.#mailAt = at;
|
|
142
|
+
return start;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return Infinity;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The first offset at or after `from` that starts a match of `/(?: {2,}|\\)\n/`, recording its "\n". */
|
|
149
|
+
#seekHardBreak(from: number): number {
|
|
150
|
+
const src = this.#src;
|
|
151
|
+
for (let end = src.indexOf("\n", from + 1); end !== -1; end = src.indexOf("\n", end + 1)) {
|
|
152
|
+
let start = end;
|
|
153
|
+
while (start > from && src.charCodeAt(start - 1) === 0x20 /* space */) start--;
|
|
154
|
+
if (end - start < 2) {
|
|
155
|
+
if (src.charCodeAt(end - 1) !== 0x5c /* \ */) continue;
|
|
156
|
+
start = end - 1;
|
|
157
|
+
}
|
|
158
|
+
this.#hardBreakAt = end;
|
|
159
|
+
return start;
|
|
160
|
+
}
|
|
161
|
+
return Infinity;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The places where one inline source's plain text can end, as offsets into that source: its root source's stops
|
|
167
|
+
* that hold before the source's end. The source is a root source, or a part of one lexed as its own source (a link
|
|
168
|
+
* label, the text of emphasis), which ends right before the delimiter that closes it.
|
|
169
|
+
*/
|
|
170
|
+
export class TextStops {
|
|
171
|
+
readonly #src: string;
|
|
172
|
+
readonly #lexer: Lexer;
|
|
173
|
+
readonly #root: RootTextStops;
|
|
174
|
+
// Where the source starts and ends in its root source, and whether it is the root source itself.
|
|
175
|
+
readonly #start: number;
|
|
176
|
+
readonly #end: number;
|
|
177
|
+
readonly #isRoot: boolean;
|
|
178
|
+
// The last "@" whose domain was tested, and whether a dotted domain follows it in the source.
|
|
179
|
+
#domainAt = UNSEARCHED;
|
|
180
|
+
#dottedDomain = false;
|
|
181
|
+
// In a nested source, per inline extension, the offset its `startFrom` hint last pointed to in the source.
|
|
182
|
+
#extensionStarts: Map<TokenizerExtension, number> | undefined;
|
|
183
|
+
|
|
184
|
+
/** The stops of `src`, a root source, or else the part of `root`'s source that ends at `end`. */
|
|
185
|
+
constructor(src: string, lexer: Lexer, root?: RootTextStops, end = src.length) {
|
|
186
|
+
this.#src = src;
|
|
187
|
+
this.#lexer = lexer;
|
|
188
|
+
this.#root = root ?? new RootTextStops(src);
|
|
189
|
+
this.#start = end - src.length;
|
|
190
|
+
this.#end = end;
|
|
191
|
+
this.#isRoot = root === undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The stops of `src`, the part of this source's root source that ends at `end`, lexed as its own source. */
|
|
195
|
+
nested(src: string, end: number): TextStops {
|
|
196
|
+
return new TextStops(src, this.#lexer, this.#root, end);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Whether the bare-URL rule can match at `pos`: its first alternative needs a scheme there, and its second a
|
|
201
|
+
* run of e-mail local characters from `pos` to an "@" (the only "@" such a run can reach) with a dotted domain
|
|
202
|
+
* right after that "@", which is the same test for every offset of the run.
|
|
203
|
+
*/
|
|
204
|
+
bareUrlCanStart(pos: number): boolean {
|
|
205
|
+
const root = this.#root;
|
|
206
|
+
const at = this.#start + pos;
|
|
207
|
+
if (root.schemeFrom(at, this.#end) === at) return true;
|
|
208
|
+
if (root.mailFrom(at, this.#end) !== at) return false;
|
|
209
|
+
const mailAt = root.mailAt - this.#start;
|
|
210
|
+
if (this.#domainAt !== mailAt) {
|
|
211
|
+
this.#domainAt = mailAt;
|
|
212
|
+
DOTTED_DOMAIN.lastIndex = mailAt + 1;
|
|
213
|
+
this.#dottedDomain = DOTTED_DOMAIN.test(this.#src);
|
|
214
|
+
}
|
|
215
|
+
return this.#dottedDomain;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Length of the plain text at the start of `rest`, a suffix of the source: the distance to the nearest later
|
|
220
|
+
* offset where another token could start or an inline extension's start hint points.
|
|
221
|
+
*/
|
|
222
|
+
textLength(rest: string): number {
|
|
223
|
+
const root = this.#root;
|
|
224
|
+
const end = this.#end;
|
|
225
|
+
const pos = this.#src.length - rest.length;
|
|
226
|
+
const at = this.#start + pos;
|
|
227
|
+
const from = at + 1;
|
|
228
|
+
let next =
|
|
229
|
+
Math.min(
|
|
230
|
+
end,
|
|
231
|
+
root.tokenCharFrom(from),
|
|
232
|
+
root.schemeFrom(from, end),
|
|
233
|
+
root.mailFrom(from, end),
|
|
234
|
+
root.hardBreakFrom(from, end),
|
|
235
|
+
) - at;
|
|
236
|
+
const lexer = this.#lexer;
|
|
237
|
+
const extensions = lexer.extensions.inline;
|
|
238
|
+
// Only a lexer with inline extensions enters the loop. The JIT can hoist a hint it inlined from another lexer's
|
|
239
|
+
// extension, such as a search of `rest`, ahead of the loop, where it would run at every text step.
|
|
240
|
+
if (extensions.length !== 0) {
|
|
241
|
+
for (const extension of extensions) {
|
|
242
|
+
// A hint at `pos` itself yields 0, which is ignored exactly like `start` returning 0.
|
|
243
|
+
const hint = extension.startFrom
|
|
244
|
+
? this.#startFrom(extension, extension.startFrom, pos) - pos
|
|
245
|
+
: extension.start?.call({ lexer }, rest);
|
|
246
|
+
if (typeof hint === "number" && hint > 0 && hint < next) next = hint;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// `slice` reads a fractional hint from `start` as its integer part.
|
|
250
|
+
return Math.trunc(next);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Where `extension`'s `startFrom` hint points at or after `pos`. A nested source takes the root source's answer
|
|
255
|
+
* and asks the hint in the source at it: a candidate of the source is one of the root source, since the hint's
|
|
256
|
+
* test at an offset reads only from there on and still passes where the source goes on, so none lies before the
|
|
257
|
+
* root's answer.
|
|
258
|
+
*/
|
|
259
|
+
#startFrom(extension: TokenizerExtension, startFrom: TokenizerStartFromFunction, pos: number): number {
|
|
260
|
+
const root = this.#root;
|
|
261
|
+
const lexer = this.#lexer;
|
|
262
|
+
if (this.#isRoot) return root.startFrom(extension, startFrom, lexer, pos);
|
|
263
|
+
const starts = (this.#extensionStarts ??= new Map());
|
|
264
|
+
const cached = starts.get(extension) ?? UNSEARCHED;
|
|
265
|
+
if (cached >= pos) return cached;
|
|
266
|
+
const candidate = root.startFrom(extension, startFrom, lexer, this.#start + pos) - this.#start;
|
|
267
|
+
const start =
|
|
268
|
+
candidate < this.#src.length ? askStartFrom(extension, startFrom, lexer, this.#src, candidate) : Infinity;
|
|
269
|
+
starts.set(extension, start);
|
|
270
|
+
return start;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Appends plain text to one inline source's token list, merging into a text token before it as marked does. A text
|
|
276
|
+
* token this run created grows by taking a longer slice of the source instead of by concatenation, so its raw stays
|
|
277
|
+
* a flat string: reading its last character (the emphasis rule's look-behind) does not copy the whole token.
|
|
278
|
+
*/
|
|
279
|
+
export class TextRun {
|
|
280
|
+
readonly #src: string;
|
|
281
|
+
// The text token this run created, the source range it covers, and the one string held as its raw and text.
|
|
282
|
+
#token: Tokens.Text | undefined;
|
|
283
|
+
#start = 0;
|
|
284
|
+
#end = 0;
|
|
285
|
+
#value = "";
|
|
286
|
+
|
|
287
|
+
constructor(src: string) {
|
|
288
|
+
this.#src = src;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Appends the source text from `start` to `end`. */
|
|
292
|
+
append(tokens: Token[], start: number, end: number): void {
|
|
293
|
+
if (end <= start) return;
|
|
294
|
+
const previous = tokens.at(-1);
|
|
295
|
+
if (previous?.type === "text" && previous.tokens === undefined && previous.escaped === false) {
|
|
296
|
+
// An extension may have rewritten this token; then append by concatenation as marked does.
|
|
297
|
+
if (
|
|
298
|
+
previous === this.#token &&
|
|
299
|
+
start === this.#end &&
|
|
300
|
+
previous.raw === this.#value &&
|
|
301
|
+
previous.text === this.#value
|
|
302
|
+
) {
|
|
303
|
+
this.#end = end;
|
|
304
|
+
this.#value = this.#src.slice(this.#start, end);
|
|
305
|
+
previous.raw = this.#value;
|
|
306
|
+
previous.text = this.#value;
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const raw = this.#src.slice(start, end);
|
|
310
|
+
previous.raw += raw;
|
|
311
|
+
previous.text += raw;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const raw = this.#src.slice(start, end);
|
|
315
|
+
this.#token = { type: "text", raw, text: raw, escaped: false };
|
|
316
|
+
this.#start = start;
|
|
317
|
+
this.#end = end;
|
|
318
|
+
this.#value = raw;
|
|
319
|
+
tokens.push(this.#token);
|
|
320
|
+
}
|
|
321
|
+
}
|
package/src/math-delimiters.ts
CHANGED
|
@@ -41,16 +41,12 @@ const MATH_BLOCK_BRACKET = /^ {0,3}\\\[[ \t]*\r?\n([\s\S]+?)\r?\n {0,3}\\\][ \t]
|
|
|
41
41
|
* not a decision: whether that candidate is really math — escaped, currency,
|
|
42
42
|
* unclosed — is decided by {@link mathSpanAt}.
|
|
43
43
|
*/
|
|
44
|
-
//
|
|
45
|
-
// this on the remaining source at every inline position, where the alternation
|
|
46
|
-
// showed up in CPU profiles (part of a ~4.3% start() tail).
|
|
44
|
+
// One forward pass that stops at the first candidate.
|
|
47
45
|
export function mathStartIndex(source: string, from = 0): number | undefined {
|
|
48
|
-
let
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (bracket !== -1 && (best === -1 || bracket < best)) best = bracket;
|
|
53
|
-
return best === -1 ? undefined : best;
|
|
46
|
+
for (let at = from; at < source.length; at++) {
|
|
47
|
+
if (mathOpenerAt(source, at) !== undefined) return at;
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
54
50
|
}
|
|
55
51
|
|
|
56
52
|
/** Math opener at `at`, or `undefined` when no delimiter starts there. */
|
|
@@ -78,10 +74,107 @@ export function mathSpanAt(source: string, at: number, from = 0): MathSpan | und
|
|
|
78
74
|
const opener = mathOpenerAt(source, at);
|
|
79
75
|
if (opener === undefined || escapedAt(source, at, from)) return undefined;
|
|
80
76
|
const bodyStart = at + opener.length;
|
|
81
|
-
|
|
77
|
+
return spanOf(
|
|
78
|
+
source,
|
|
79
|
+
opener,
|
|
80
|
+
bodyStart,
|
|
81
|
+
opener === "$" ? scanDollar(source, at).close : closerIndex(source, opener, bodyStart),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The math spans of one source, found as {@link mathSpanAt} finds them with the escape scan stopping at the opener
|
|
87
|
+
* (the caller has consumed the escapes before it). A closer scan also answers for the openers of its kind that it
|
|
88
|
+
* passed, which meet the same closer or none, so asking at every opener of a long run reads the run once.
|
|
89
|
+
*/
|
|
90
|
+
export class MathSpans {
|
|
91
|
+
readonly #source: string;
|
|
92
|
+
// The last `$` whose closer scan ran, where that scan stopped, and the `$` it closed at (-1 for none): a scan from
|
|
93
|
+
// a `$` between the two reads what that scan read past it. When it closed, the last offset before its closer that
|
|
94
|
+
// is not whitespace, which the body of a span needs.
|
|
95
|
+
#dollarOpen = -1;
|
|
96
|
+
#dollarStop = -1;
|
|
97
|
+
#dollarClose = -1;
|
|
98
|
+
#dollarSolid = -1;
|
|
99
|
+
// Per opener other than `$`, the last body start whose closer search ran and the closer it found (-1 for none).
|
|
100
|
+
readonly #searchedFrom: Record<Exclude<MathOpener, "$">, number> = {
|
|
101
|
+
$$: Infinity,
|
|
102
|
+
"\\(": Infinity,
|
|
103
|
+
"\\[": Infinity,
|
|
104
|
+
};
|
|
105
|
+
readonly #closer: Record<Exclude<MathOpener, "$">, number> = { $$: -1, "\\(": -1, "\\[": -1 };
|
|
106
|
+
|
|
107
|
+
constructor(source: string) {
|
|
108
|
+
this.#source = source;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The span opened at `at` that ends by `end`, or `undefined`. */
|
|
112
|
+
spanAt(at: number, end = this.#source.length): MathSpan | undefined {
|
|
113
|
+
const source = this.#source;
|
|
114
|
+
const opener = mathOpenerAt(source, at);
|
|
115
|
+
if (opener === undefined) return undefined;
|
|
116
|
+
const bodyStart = at + opener.length;
|
|
117
|
+
const close = opener === "$" ? this.#dollarCloser(at) : this.#closerFrom(opener, bodyStart);
|
|
118
|
+
if (close === -1 || close + opener.length > end) return undefined;
|
|
119
|
+
return spanOf(source, opener, bodyStart, close);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The `$` closing the span that the `$` at `open` opens, or -1. */
|
|
123
|
+
#dollarCloser(open: number): number {
|
|
124
|
+
const source = this.#source;
|
|
125
|
+
if (this.#dollarOpen < open && open < this.#dollarStop) {
|
|
126
|
+
// That scan passed this `$` and went on from the offset after it, where this one's scan starts.
|
|
127
|
+
if (this.#dollarClose === -1 || !dollarOpens(source, open)) return -1;
|
|
128
|
+
return this.#dollarSolid > open ? this.#dollarClose : -1;
|
|
129
|
+
}
|
|
130
|
+
const { close, stop } = scanDollar(source, open);
|
|
131
|
+
this.#dollarOpen = open;
|
|
132
|
+
this.#dollarStop = stop;
|
|
133
|
+
this.#dollarClose = close;
|
|
134
|
+
if (close !== -1) {
|
|
135
|
+
let solid = close - 1;
|
|
136
|
+
while (WHITESPACE.test(source[solid]!)) solid--;
|
|
137
|
+
this.#dollarSolid = solid;
|
|
138
|
+
}
|
|
139
|
+
return close;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The closer of `opener` at or after `bodyStart`, the start of its body, or -1. */
|
|
143
|
+
#closerFrom(opener: Exclude<MathOpener, "$">, bodyStart: number): number {
|
|
144
|
+
// A body start follows its opener's `$`, `(` or `[`, so the backslashes before a closer after it never reach
|
|
145
|
+
// back past it: every later body start up to the closer found finds that same closer.
|
|
146
|
+
const found = this.#closer[opener];
|
|
147
|
+
if (this.#searchedFrom[opener] <= bodyStart && (found === -1 || bodyStart <= found)) return found;
|
|
148
|
+
const close = closerIndex(this.#source, opener, bodyStart);
|
|
149
|
+
this.#searchedFrom[opener] = bodyStart;
|
|
150
|
+
this.#closer[opener] = close;
|
|
151
|
+
return close;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const spansByContext = new WeakMap<object, MathSpans>();
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The span opened at the start of `src`, with `end` counted from there, for a marked inline tokenizer: `src` is the
|
|
159
|
+
* part of `context.source` before `context.end` that the tokenizer received, and the scans are remembered per
|
|
160
|
+
* context, which marked keeps for one inline source and the link labels and emphasis inside it. Only a span that ends
|
|
161
|
+
* by `context.end` counts. Inside a label or emphasis, `context.end` holds its closer (`]`, `*` or `_`), which is not
|
|
162
|
+
* a digit, so a `$` right before it closes there as it would at the end of the text.
|
|
163
|
+
*/
|
|
164
|
+
export function mathSpanInContext(context: { source?: string; end?: number }, src: string): MathSpan | undefined {
|
|
165
|
+
const source = context.source ?? src;
|
|
166
|
+
const end = context.end ?? source.length;
|
|
167
|
+
let spans = spansByContext.get(context);
|
|
168
|
+
if (!spans) spansByContext.set(context, (spans = new MathSpans(source)));
|
|
169
|
+
const at = end - src.length;
|
|
170
|
+
const span = spans.spanAt(at, end);
|
|
171
|
+
return span && { ...span, end: span.end - at };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function spanOf(source: string, opener: MathOpener, bodyStart: number, closeAt: number): MathSpan | undefined {
|
|
82
175
|
if (closeAt === -1) return undefined;
|
|
83
176
|
const body = source.slice(bodyStart, closeAt);
|
|
84
|
-
// `
|
|
177
|
+
// `scanDollar` already rejects an all-space `$…$`; `$$ $$` needs the
|
|
85
178
|
// same guard here, while `\(\)` and `\[\]` are unambiguous enough to keep.
|
|
86
179
|
if (opener === "$$" && body.trim() === "") return undefined;
|
|
87
180
|
return { opener, display: opener === "$$" || opener === "\\[", end: closeAt + opener.length, body };
|
|
@@ -115,29 +208,38 @@ function escapedAt(source: string, index: number, from: number): boolean {
|
|
|
115
208
|
return backslashes % 2 === 1;
|
|
116
209
|
}
|
|
117
210
|
|
|
211
|
+
/** What `trim` removes: a span's body needs something else. */
|
|
212
|
+
const WHITESPACE = /\s/;
|
|
213
|
+
|
|
214
|
+
/** Whether the `$` at `open` can open a span: it is followed by something other than a space, tab, line break or `$`. */
|
|
215
|
+
function dollarOpens(source: string, open: number): boolean {
|
|
216
|
+
const after = source[open + 1];
|
|
217
|
+
return !(after === undefined || after === " " || after === "\t" || after === "\n" || after === "$");
|
|
218
|
+
}
|
|
219
|
+
|
|
118
220
|
/**
|
|
119
|
-
* Offset of the `$` that closes an inline span opened at `open`, or -1
|
|
221
|
+
* Offset of the `$` that closes an inline span opened at `open`, or -1, and where
|
|
222
|
+
* the scan stopped: at that `$`, or where it ruled a closer out. Pandoc's
|
|
120
223
|
* anti-currency heuristics: the opener must not be followed by whitespace, the
|
|
121
224
|
* closer must not be preceded by whitespace nor followed by a digit, `\$` is a
|
|
122
225
|
* literal dollar, and the span may not cross a newline — so "$5 and $10" is
|
|
123
226
|
* prose, not math.
|
|
124
227
|
*/
|
|
125
|
-
function
|
|
126
|
-
|
|
127
|
-
if (after === undefined || after === " " || after === "\t" || after === "\n" || after === "$") return -1;
|
|
228
|
+
function scanDollar(source: string, open: number): { close: number; stop: number } {
|
|
229
|
+
if (!dollarOpens(source, open)) return { close: -1, stop: open + 1 };
|
|
128
230
|
for (let at = open + 1; at < source.length; at++) {
|
|
129
231
|
const char = source[at];
|
|
130
232
|
if (char === "\\") {
|
|
131
233
|
at++;
|
|
132
234
|
continue;
|
|
133
235
|
}
|
|
134
|
-
if (char === "\n") return -1;
|
|
236
|
+
if (char === "\n") return { close: -1, stop: at };
|
|
135
237
|
if (char !== "$") continue;
|
|
136
238
|
const before = source[at - 1];
|
|
137
|
-
if (before === " " || before === "\t") return -1;
|
|
239
|
+
if (before === " " || before === "\t") return { close: -1, stop: at };
|
|
138
240
|
const next = source[at + 1];
|
|
139
241
|
if (next !== undefined && next >= "0" && next <= "9") continue; // currency: keep scanning
|
|
140
|
-
return source.slice(open + 1, at).trim().length > 0 ? at : -1;
|
|
242
|
+
return { close: source.slice(open + 1, at).trim().length > 0 ? at : -1, stop: at };
|
|
141
243
|
}
|
|
142
|
-
return -1;
|
|
244
|
+
return { close: -1, stop: source.length };
|
|
143
245
|
}
|