@cyanheads/openalex-mcp-server 0.8.0 → 0.9.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/AGENTS.md +20 -7
- package/CLAUDE.md +20 -7
- package/Dockerfile +17 -8
- package/README.md +20 -17
- package/changelog/0.9.x/0.9.0.md +48 -0
- package/dist/mcp-server/prompts/definitions/literature-review.prompt.js +1 -1
- package/dist/mcp-server/prompts/definitions/research-landscape.prompt.js +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts +27 -2
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js +34 -4
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +41 -4
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.js +62 -14
- package/dist/mcp-server/tools/definitions/citation-graph.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/describe-fields.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/describe-fields.tool.js +34 -4
- package/dist/mcp-server/tools/definitions/describe-fields.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts +37 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js +175 -40
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts +41 -4
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.js +71 -23
- package/dist/mcp-server/tools/definitions/search-entities.tool.js.map +1 -1
- package/dist/mcp-server/tools/render-budget.d.ts +4 -2
- package/dist/mcp-server/tools/render-budget.d.ts.map +1 -1
- package/dist/mcp-server/tools/render-budget.js +4 -2
- package/dist/mcp-server/tools/render-budget.js.map +1 -1
- package/dist/mcp-server/tools/response-budget.d.ts +2 -1
- package/dist/mcp-server/tools/response-budget.d.ts.map +1 -1
- package/dist/mcp-server/tools/response-budget.js +30 -21
- package/dist/mcp-server/tools/response-budget.js.map +1 -1
- package/dist/services/openalex/budget.d.ts +3 -1
- package/dist/services/openalex/budget.d.ts.map +1 -1
- package/dist/services/openalex/budget.js +3 -1
- package/dist/services/openalex/budget.js.map +1 -1
- package/dist/services/openalex/field-catalog.json +50 -1
- package/dist/services/openalex/filter-values.d.ts +46 -0
- package/dist/services/openalex/filter-values.d.ts.map +1 -0
- package/dist/services/openalex/filter-values.js +294 -0
- package/dist/services/openalex/filter-values.js.map +1 -0
- package/dist/services/openalex/iso-codes.json +1565 -0
- package/dist/services/openalex/openalex-service.d.ts +172 -20
- package/dist/services/openalex/openalex-service.d.ts.map +1 -1
- package/dist/services/openalex/openalex-service.js +560 -120
- package/dist/services/openalex/openalex-service.js.map +1 -1
- package/dist/services/openalex/provider-text.d.ts +7 -6
- package/dist/services/openalex/provider-text.d.ts.map +1 -1
- package/dist/services/openalex/provider-text.js +485 -61
- package/dist/services/openalex/provider-text.js.map +1 -1
- package/package.json +14 -12
- package/server.json +10 -24
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
* strings OpenAlex passes through from its sources unsanitized. Decodes HTML character
|
|
4
4
|
* references against the full WHATWG table in one pass, then, in a second single pass, removes
|
|
5
5
|
* HTML comments, unwraps a small allowlist of HTML/JATS/MathML elements, unwraps CDATA sections
|
|
6
|
-
* and IOP's `<?CDATA …?>` instructions, drops the `<?MML …?>` instructions that restate them,
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* and IOP's `<?CDATA …?>` instructions, drops the `<?MML …?>` instructions that restate them,
|
|
7
|
+
* drops MathType MTEF equation blobs and their End lines, and turns a double-escaped line break
|
|
8
|
+
* (a literal backslash + `n`) into a space. Everything else stays literal, so text such as
|
|
9
|
+
* `A < B`, `Fish <Actinopterygii>`, `95% CI`, a SICI DOI, or TeX like `\nu` survives untouched.
|
|
10
|
+
* Output is plain text; Markdown escaping for `content[]` happens at the render boundary, never
|
|
11
|
+
* here. Identifier and URL fields of a record are not provider text and pass through
|
|
12
|
+
* byte-identical, so each one resolves when passed back.
|
|
12
13
|
* @module services/openalex/provider-text
|
|
13
14
|
*/
|
|
14
15
|
import entityTable from './html-entities.json' with { type: 'json' };
|
|
@@ -148,8 +149,6 @@ const SCRIPT_OPENERS = new Map([
|
|
|
148
149
|
['sup', '^{'],
|
|
149
150
|
['jats:sup', '^{'],
|
|
150
151
|
]);
|
|
151
|
-
/** A complete tag, checked against one `<…>` candidate that holds no other `<` or `>`. */
|
|
152
|
-
const TAG = /^<(\/?)([A-Za-z][\w:.-]*)(?:\s[^<>]*)?\/?>$/;
|
|
153
152
|
function isAllowlistedElement(name) {
|
|
154
153
|
return (INLINE_ELEMENTS.has(name) ||
|
|
155
154
|
name.startsWith('mml:') ||
|
|
@@ -157,15 +156,66 @@ function isAllowlistedElement(name) {
|
|
|
157
156
|
SCRIPT_OPENERS.has(name));
|
|
158
157
|
}
|
|
159
158
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
159
|
+
* How the text from the last `<` in `out` through one element reads as the start of a tag: `<`,
|
|
160
|
+
* an optional `/`, a name — a letter, then letters, digits, `_`, `:`, `.`, or `-` — then `>`, `/>`,
|
|
161
|
+
* or whitespace and anything but `<` or `>` before the `>`. The kind sits in the low bits and the
|
|
162
|
+
* name's length, capped at `NAME_LENGTH_CAP`, above them. A `>` completes a tag only after
|
|
163
|
+
* `TAG_NAME`, `TAG_SELF_CLOSING`, or `TAG_ATTRIBUTES`.
|
|
162
164
|
*/
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
165
|
+
const TAG_NONE = 0;
|
|
166
|
+
/** `<` */
|
|
167
|
+
const TAG_OPEN = 1;
|
|
168
|
+
/** `</` */
|
|
169
|
+
const TAG_CLOSING = 2;
|
|
170
|
+
const TAG_NAME = 3;
|
|
171
|
+
/** A name, then `/`. */
|
|
172
|
+
const TAG_SELF_CLOSING = 4;
|
|
173
|
+
/** A name, whitespace, then anything but `<` or `>`. */
|
|
174
|
+
const TAG_ATTRIBUTES = 5;
|
|
175
|
+
const TAG_KIND_BITS = 3;
|
|
176
|
+
const TAG_KIND_MASK = (1 << TAG_KIND_BITS) - 1;
|
|
177
|
+
const TAG_NAME_UNIT = 1 << TAG_KIND_BITS;
|
|
178
|
+
/** One past the longest listed name: enough of any name to look it up or to see `mml:`. */
|
|
179
|
+
const NAME_LENGTH_CAP = Math.max(...[...INLINE_ELEMENTS, ...BLOCK_ELEMENTS, ...SCRIPT_OPENERS.keys()].map((name) => name.length)) + 1;
|
|
180
|
+
const isNameChar = (ch) => isAsciiLetter(ch) || isDigit(ch) || ch === '_' || ch === ':' || ch === '.' || ch === '-';
|
|
181
|
+
/** The tag shape after `ch`, given the shape before it. */
|
|
182
|
+
function nextTagShape(shape, ch) {
|
|
183
|
+
if (ch === '<')
|
|
184
|
+
return TAG_OPEN;
|
|
185
|
+
const kind = shape & TAG_KIND_MASK;
|
|
186
|
+
if (kind === TAG_ATTRIBUTES)
|
|
187
|
+
return ch === '>' ? TAG_NONE : shape;
|
|
188
|
+
if (kind === TAG_NAME) {
|
|
189
|
+
if (isNameChar(ch)) {
|
|
190
|
+
return shape >> TAG_KIND_BITS < NAME_LENGTH_CAP ? shape + TAG_NAME_UNIT : shape;
|
|
191
|
+
}
|
|
192
|
+
if (isWhitespace(ch))
|
|
193
|
+
return shape - kind + TAG_ATTRIBUTES;
|
|
194
|
+
return ch === '/' ? shape - kind + TAG_SELF_CLOSING : TAG_NONE;
|
|
195
|
+
}
|
|
196
|
+
if (kind === TAG_OPEN && ch === '/')
|
|
197
|
+
return TAG_CLOSING;
|
|
198
|
+
if ((kind === TAG_OPEN || kind === TAG_CLOSING) && isAsciiLetter(ch)) {
|
|
199
|
+
return TAG_NAME_UNIT + TAG_NAME;
|
|
200
|
+
}
|
|
201
|
+
return TAG_NONE;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* What the tag `out` ends with becomes — its `<` at `top`, its `>` the last element, `shape` the
|
|
205
|
+
* tag shape before that `>` — or `undefined` when the `<…>` span is no allowlisted tag and stays
|
|
206
|
+
* literal. Matching a whole tag name — never a prefix — is what keeps `i1<i2<` and
|
|
207
|
+
* `<Actinopterygii>`.
|
|
208
|
+
*/
|
|
209
|
+
function replaceTag(out, top, shape) {
|
|
210
|
+
const kind = shape & TAG_KIND_MASK;
|
|
211
|
+
if (kind !== TAG_NAME && kind !== TAG_SELF_CLOSING && kind !== TAG_ATTRIBUTES)
|
|
166
212
|
return;
|
|
167
|
-
const closing =
|
|
168
|
-
const
|
|
213
|
+
const closing = out[top + 1] === '/';
|
|
214
|
+
const nameStart = top + (closing ? 2 : 1);
|
|
215
|
+
const name = out
|
|
216
|
+
.slice(nameStart, nameStart + (shape >> TAG_KIND_BITS))
|
|
217
|
+
.join('')
|
|
218
|
+
.toLowerCase();
|
|
169
219
|
if (INLINE_ELEMENTS.has(name) || name.startsWith('mml:'))
|
|
170
220
|
return '';
|
|
171
221
|
if (BLOCK_ELEMENTS.has(name))
|
|
@@ -173,7 +223,8 @@ function replaceTag(candidate) {
|
|
|
173
223
|
const opener = SCRIPT_OPENERS.get(name);
|
|
174
224
|
if (opener === undefined)
|
|
175
225
|
return;
|
|
176
|
-
|
|
226
|
+
// The element before the closing `>` ends `/>` on a self-closing tag.
|
|
227
|
+
if (out.at(-2).endsWith('/'))
|
|
177
228
|
return '';
|
|
178
229
|
return closing ? '}' : opener;
|
|
179
230
|
}
|
|
@@ -202,9 +253,9 @@ const INSTRUCTION_CDATA = '<?CDATA';
|
|
|
202
253
|
const INSTRUCTION_MML = '<?MML';
|
|
203
254
|
const INSTRUCTION_CLOSE = '?>';
|
|
204
255
|
/**
|
|
205
|
-
* The longest `<?MML … ?>` removed, `<?MML` through
|
|
206
|
-
* spans at most 64 characters; one that
|
|
207
|
-
* deleting prose up to some later `?>`.
|
|
256
|
+
* The longest `<?MML … ?>` removed, `<?MML` through `?>`, as the output reads it
|
|
257
|
+
* (`SecondPassLength`). Every one in a 3,543-record sample spans at most 64 characters; one that
|
|
258
|
+
* runs longer lost its `?>` and stays literal rather than deleting prose up to some later `?>`.
|
|
208
259
|
*/
|
|
209
260
|
const MAX_MML_SPAN = 128;
|
|
210
261
|
/** A double-escaped line break: the two characters `\` and `n`, not a newline. */
|
|
@@ -220,8 +271,15 @@ function endsWith(out, suffix) {
|
|
|
220
271
|
}
|
|
221
272
|
const isBlank = (ch) => ch === ' ' || ch === '\t';
|
|
222
273
|
const isAsciiLetter = (ch) => ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'));
|
|
223
|
-
/**
|
|
224
|
-
|
|
274
|
+
/**
|
|
275
|
+
* Whitespace as `String.prototype.trim` defines it — what trimming paragraphs strips beside a
|
|
276
|
+
* block tag's break — and the break element itself, which a second pass reads as line breaks.
|
|
277
|
+
* Whatever trimming can remove or a break can become must already read as whitespace here, or a
|
|
278
|
+
* second pass would find a delimiter this one did not.
|
|
279
|
+
*/
|
|
280
|
+
const isWhitespace = (ch) => ch === ' ' || (ch >= '\t' && ch <= '\r') || (ch > '~' && ch.trim() === '');
|
|
281
|
+
/** Characters that end a processing-instruction target: any whitespace, or a `?`. */
|
|
282
|
+
const endsInstructionTarget = (ch) => ch === '?' || isWhitespace(ch);
|
|
225
283
|
/** Whether `out` ends with a formula opener whose `<` sits at `start`. */
|
|
226
284
|
function isFormulaOpener(out, start) {
|
|
227
285
|
if (out.length - start > MAX_FORMULA_OPENER_LENGTH)
|
|
@@ -231,6 +289,299 @@ function isFormulaOpener(out, start) {
|
|
|
231
289
|
const name = FORMULA_OPENER.exec(out.slice(start).join(''))?.[1];
|
|
232
290
|
return name !== undefined && isAllowlistedElement(name.toLowerCase());
|
|
233
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* MathType MTEF equation blobs, which TeX beside them restates: `% MathType!MTEF!2!1!+-`, then
|
|
294
|
+
* `%`-prefixed lines of `[A-Za-z0-9+-]` data, then a checksum of `!` and four uppercase hex
|
|
295
|
+
* digits (`!44A1!`). A `% MathType!Translator!…!` line can come just before the header, and a
|
|
296
|
+
* stray `% MathType!End!2!1!` line anywhere.
|
|
297
|
+
*/
|
|
298
|
+
const MATHTYPE_KEYWORD = 'MathType!';
|
|
299
|
+
/** What follows the keyword on a header, a Translator line, and an End line. */
|
|
300
|
+
const MATHTYPE_TAILS = ['MTEF!', 'Translator!', 'End!'];
|
|
301
|
+
/**
|
|
302
|
+
* The longest Translator body, `2!1!AMS LaTeX.tdl!TeX -- AMS-LaTeX!` and the like: four
|
|
303
|
+
* `!`-ended fields. A longer one is prose that happens to hold four `!`s, not a blob's start.
|
|
304
|
+
*/
|
|
305
|
+
const MAX_TRANSLATOR_BODY = 64;
|
|
306
|
+
const TRANSLATOR_FIELDS = 4;
|
|
307
|
+
const CHECKSUM_DIGITS = 4;
|
|
308
|
+
/** What the line the last `%` opened has matched. */
|
|
309
|
+
const LINE_NONE = 0;
|
|
310
|
+
/** `MathType!`, `count` characters of it; blanks may come before the first. */
|
|
311
|
+
const LINE_KEYWORD = 1;
|
|
312
|
+
/** The tail after the keyword, `count` characters in — `MATHTYPE_TAILS[phase - LINE_HEADER]`. */
|
|
313
|
+
const LINE_HEADER = 2;
|
|
314
|
+
const LINE_TRANSLATOR = 3;
|
|
315
|
+
const LINE_END = 4;
|
|
316
|
+
/** A Translator body, `count` characters and `bangs` `!`s in. */
|
|
317
|
+
const LINE_TRANSLATOR_BODY = 5;
|
|
318
|
+
/** A complete Translator line, then blanks up to the next `%`. */
|
|
319
|
+
const LINE_TRANSLATED = 6;
|
|
320
|
+
/** An End line's two `!`-ended numbers; `count` is 1 once the current one has a digit. */
|
|
321
|
+
const LINE_END_FIRST = 7;
|
|
322
|
+
const LINE_END_SECOND = 8;
|
|
323
|
+
/** Where the run a header opened stands. */
|
|
324
|
+
const RUN_NONE = 0;
|
|
325
|
+
/** The header's fields, up to whitespace or the next `%`. */
|
|
326
|
+
const RUN_HEADER = 1;
|
|
327
|
+
/** Whitespace after a header or data line. */
|
|
328
|
+
const RUN_GAP = 2;
|
|
329
|
+
/** A `%` and any whitespace after it, no data yet. */
|
|
330
|
+
const RUN_LINE = 3;
|
|
331
|
+
const RUN_DATA = 4;
|
|
332
|
+
/** The checksum's `!`, plus the hex digits seen so far. */
|
|
333
|
+
const RUN_CHECKSUM = 5;
|
|
334
|
+
const isDigit = (ch) => ch >= '0' && ch <= '9';
|
|
335
|
+
const isMtefData = (ch) => isAsciiLetter(ch) || isDigit(ch) || ch === '+' || ch === '-';
|
|
336
|
+
const isUpperHex = (ch) => isDigit(ch) || (ch >= 'A' && ch <= 'F');
|
|
337
|
+
/** Numbers saved per element: its index in `out`, the packed phases, `lineStart`, `runStart`. */
|
|
338
|
+
const MATHTYPE_STATE_SIZE = 4;
|
|
339
|
+
/**
|
|
340
|
+
* Recognizes MathType blobs and End lines in `out` as `stripMarkup` builds it. Its state after
|
|
341
|
+
* each element is saved, so when a removal truncates `out` the scanner resumes from exactly the
|
|
342
|
+
* state that text left it in — the same state a second pass over the output would reach — and
|
|
343
|
+
* no character is examined twice. Only states mid-match are saved: idle, with no line or run in
|
|
344
|
+
* progress, nothing else in the state matters, and every element without a saved state left it
|
|
345
|
+
* idle.
|
|
346
|
+
*
|
|
347
|
+
* Two threads run side by side. The line thread starts at every `%` and matches a header, a
|
|
348
|
+
* Translator line, or an End line. The run thread follows the blob a header opened: further
|
|
349
|
+
* header and data lines, separated by whitespace or nothing, until a checksum completes it or any
|
|
350
|
+
* other character ends it. Every header continues the run it sits in, so a blob starts at its
|
|
351
|
+
* first header — or at the Translator line just before it — and `runStart` holds that position.
|
|
352
|
+
*/
|
|
353
|
+
class MathTypeScanner {
|
|
354
|
+
saved = [];
|
|
355
|
+
linePhase = LINE_NONE;
|
|
356
|
+
count = 0;
|
|
357
|
+
bangs = 0;
|
|
358
|
+
runPhase = RUN_NONE;
|
|
359
|
+
/** The current line continues the run, or follows a Translator line; `runStart` is its start. */
|
|
360
|
+
continues = false;
|
|
361
|
+
lineStart = -1;
|
|
362
|
+
runStart = -1;
|
|
363
|
+
/** Whether the last completion was a blob, whose checksum may close with one more `!`. */
|
|
364
|
+
completedBlob = false;
|
|
365
|
+
/**
|
|
366
|
+
* Advance over the element just pushed at `index` of `out`. Returns where the blob or End line
|
|
367
|
+
* it completes starts, or -1. Only a single character completes one.
|
|
368
|
+
*/
|
|
369
|
+
push(element, index) {
|
|
370
|
+
// Only a `%` moves the scanner off idle.
|
|
371
|
+
if (this.linePhase === LINE_NONE && this.runPhase === RUN_NONE && element !== '%')
|
|
372
|
+
return -1;
|
|
373
|
+
if (element.length === 1) {
|
|
374
|
+
const start = this.step(element, index);
|
|
375
|
+
if (start >= 0)
|
|
376
|
+
return start;
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
for (const ch of element)
|
|
380
|
+
this.step(ch, index);
|
|
381
|
+
}
|
|
382
|
+
if (this.linePhase !== LINE_NONE || this.runPhase !== RUN_NONE) {
|
|
383
|
+
this.saved.push(index, this.linePhase |
|
|
384
|
+
(this.count << 4) |
|
|
385
|
+
(this.bangs << 11) |
|
|
386
|
+
(this.runPhase << 14) |
|
|
387
|
+
(this.continues ? 1 << 18 : 0), this.lineStart, this.runStart);
|
|
388
|
+
}
|
|
389
|
+
return -1;
|
|
390
|
+
}
|
|
391
|
+
/** Return to the state `out` was in at `length` elements. */
|
|
392
|
+
truncate(length) {
|
|
393
|
+
const saved = this.saved;
|
|
394
|
+
while (saved.length > 0 && saved[saved.length - MATHTYPE_STATE_SIZE] >= length) {
|
|
395
|
+
saved.length -= MATHTYPE_STATE_SIZE;
|
|
396
|
+
}
|
|
397
|
+
const at = saved.length - MATHTYPE_STATE_SIZE;
|
|
398
|
+
if (at < 0 || saved[at] !== length - 1) {
|
|
399
|
+
this.linePhase = LINE_NONE;
|
|
400
|
+
this.runPhase = RUN_NONE;
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const packed = saved[at + 1];
|
|
404
|
+
this.linePhase = packed & 0xf;
|
|
405
|
+
this.count = (packed >> 4) & 0x7f;
|
|
406
|
+
this.bangs = (packed >> 11) & 0x7;
|
|
407
|
+
this.runPhase = (packed >> 14) & 0xf;
|
|
408
|
+
this.continues = (packed & (1 << 18)) !== 0;
|
|
409
|
+
this.lineStart = saved[at + 2];
|
|
410
|
+
this.runStart = saved[at + 3];
|
|
411
|
+
}
|
|
412
|
+
step(ch, index) {
|
|
413
|
+
const run = this.runPhase;
|
|
414
|
+
let next = RUN_NONE;
|
|
415
|
+
if (run === RUN_HEADER) {
|
|
416
|
+
next = isWhitespace(ch) ? RUN_GAP : ch === '%' ? RUN_LINE : RUN_HEADER;
|
|
417
|
+
}
|
|
418
|
+
else if (run === RUN_GAP) {
|
|
419
|
+
next = isWhitespace(ch) ? RUN_GAP : ch === '%' ? RUN_LINE : RUN_NONE;
|
|
420
|
+
}
|
|
421
|
+
else if (run === RUN_LINE || run === RUN_DATA) {
|
|
422
|
+
// An empty data line ends the run; a checksum may stand on a line of its own.
|
|
423
|
+
if (isMtefData(ch))
|
|
424
|
+
next = RUN_DATA;
|
|
425
|
+
else if (ch === '!')
|
|
426
|
+
next = RUN_CHECKSUM;
|
|
427
|
+
else if (isWhitespace(ch))
|
|
428
|
+
next = run === RUN_LINE ? RUN_LINE : RUN_GAP;
|
|
429
|
+
else if (ch === '%' && run === RUN_DATA)
|
|
430
|
+
next = RUN_LINE;
|
|
431
|
+
}
|
|
432
|
+
else if (run >= RUN_CHECKSUM && isUpperHex(ch)) {
|
|
433
|
+
next = run + 1;
|
|
434
|
+
if (next === RUN_CHECKSUM + CHECKSUM_DIGITS) {
|
|
435
|
+
this.completedBlob = true;
|
|
436
|
+
return this.runStart;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
this.runPhase = next;
|
|
440
|
+
if (ch === '%') {
|
|
441
|
+
this.continues = next === RUN_LINE || this.linePhase === LINE_TRANSLATED;
|
|
442
|
+
this.linePhase = LINE_KEYWORD;
|
|
443
|
+
this.count = 0;
|
|
444
|
+
this.lineStart = index;
|
|
445
|
+
return -1;
|
|
446
|
+
}
|
|
447
|
+
switch (this.linePhase) {
|
|
448
|
+
case LINE_KEYWORD:
|
|
449
|
+
if (this.count < MATHTYPE_KEYWORD.length) {
|
|
450
|
+
if (ch === MATHTYPE_KEYWORD[this.count])
|
|
451
|
+
this.count++;
|
|
452
|
+
else if (this.count > 0 || !isWhitespace(ch))
|
|
453
|
+
this.linePhase = LINE_NONE;
|
|
454
|
+
return -1;
|
|
455
|
+
}
|
|
456
|
+
this.linePhase =
|
|
457
|
+
ch === 'M'
|
|
458
|
+
? LINE_HEADER
|
|
459
|
+
: ch === 'T'
|
|
460
|
+
? LINE_TRANSLATOR
|
|
461
|
+
: ch === 'E'
|
|
462
|
+
? LINE_END
|
|
463
|
+
: LINE_NONE;
|
|
464
|
+
this.count = 1;
|
|
465
|
+
return -1;
|
|
466
|
+
case LINE_HEADER:
|
|
467
|
+
case LINE_TRANSLATOR:
|
|
468
|
+
case LINE_END: {
|
|
469
|
+
const tail = MATHTYPE_TAILS[this.linePhase - LINE_HEADER];
|
|
470
|
+
if (ch !== tail[this.count]) {
|
|
471
|
+
this.linePhase = LINE_NONE;
|
|
472
|
+
return -1;
|
|
473
|
+
}
|
|
474
|
+
if (++this.count < tail.length)
|
|
475
|
+
return -1;
|
|
476
|
+
if (this.linePhase === LINE_HEADER) {
|
|
477
|
+
if (!this.continues)
|
|
478
|
+
this.runStart = this.lineStart;
|
|
479
|
+
this.runPhase = RUN_HEADER;
|
|
480
|
+
this.linePhase = LINE_NONE;
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
this.linePhase =
|
|
484
|
+
this.linePhase === LINE_TRANSLATOR ? LINE_TRANSLATOR_BODY : LINE_END_FIRST;
|
|
485
|
+
this.count = 0;
|
|
486
|
+
this.bangs = 0;
|
|
487
|
+
}
|
|
488
|
+
return -1;
|
|
489
|
+
}
|
|
490
|
+
case LINE_TRANSLATOR_BODY:
|
|
491
|
+
// No line break inside a body; a Translator line holds no run, so `runStart` is free.
|
|
492
|
+
if (ch === '\n' || ch === '\r' || ++this.count > MAX_TRANSLATOR_BODY) {
|
|
493
|
+
this.linePhase = LINE_NONE;
|
|
494
|
+
}
|
|
495
|
+
else if (ch === '!' && ++this.bangs === TRANSLATOR_FIELDS) {
|
|
496
|
+
this.linePhase = LINE_TRANSLATED;
|
|
497
|
+
this.runStart = this.lineStart;
|
|
498
|
+
}
|
|
499
|
+
return -1;
|
|
500
|
+
case LINE_TRANSLATED:
|
|
501
|
+
if (!isWhitespace(ch))
|
|
502
|
+
this.linePhase = LINE_NONE;
|
|
503
|
+
return -1;
|
|
504
|
+
case LINE_END_FIRST:
|
|
505
|
+
case LINE_END_SECOND:
|
|
506
|
+
if (isDigit(ch)) {
|
|
507
|
+
this.count = 1;
|
|
508
|
+
return -1;
|
|
509
|
+
}
|
|
510
|
+
if (ch !== '!' || this.count === 0) {
|
|
511
|
+
this.linePhase = LINE_NONE;
|
|
512
|
+
return -1;
|
|
513
|
+
}
|
|
514
|
+
if (this.linePhase === LINE_END_FIRST) {
|
|
515
|
+
this.linePhase = LINE_END_SECOND;
|
|
516
|
+
this.count = 0;
|
|
517
|
+
return -1;
|
|
518
|
+
}
|
|
519
|
+
this.completedBlob = false;
|
|
520
|
+
return this.lineStart;
|
|
521
|
+
}
|
|
522
|
+
return -1;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/** Whether text could hold a MathType blob or End line, before or after other removals. */
|
|
526
|
+
const mayHoldMathType = (text) => text.includes('%') && text.includes('!');
|
|
527
|
+
/** Code points in an element of `out`: one for an input character, a replacement's own count. */
|
|
528
|
+
const codePointLength = (element) => element.length > 1 && element.codePointAt(0) > 0xffff ? 1 : element.length;
|
|
529
|
+
/**
|
|
530
|
+
* How long text in `out` reads on another pass, for the `<?MML` span cap. A break element and a
|
|
531
|
+
* script opener are one element each but two characters. And when the output holds a break,
|
|
532
|
+
* trimming paragraphs collapses every whitespace run that holds a paragraph break — a break
|
|
533
|
+
* element, or two line feeds in a row — to the break alone. So each such run counts as two
|
|
534
|
+
* characters whether trimming runs or not, and every other element as its code points: a span
|
|
535
|
+
* then measures the same in `out`, in the output, and on any later pass.
|
|
536
|
+
*
|
|
537
|
+
* Every measured span starts at a `<?`, so measuring starts at the earliest `<?` in `out`, the
|
|
538
|
+
* origin: text that never forms one pays nothing. The length past the origin is saved for each
|
|
539
|
+
* element with the raw length of the whitespace run it ends, so a span's length is one
|
|
540
|
+
* subtraction and a truncation rewinds in constant time.
|
|
541
|
+
*/
|
|
542
|
+
class SecondPassLength {
|
|
543
|
+
origin;
|
|
544
|
+
/**
|
|
545
|
+
* Two numbers per prefix of `out` that reaches the origin, the one ending there first: its
|
|
546
|
+
* length past the origin, then the raw length of the whitespace run it ends — 0 for none, -1
|
|
547
|
+
* once that run holds a paragraph break.
|
|
548
|
+
*/
|
|
549
|
+
saved = [0, 0];
|
|
550
|
+
/** Measure `out` from `origin`, which no whitespace run crosses. */
|
|
551
|
+
constructor(out, origin) {
|
|
552
|
+
this.origin = origin;
|
|
553
|
+
for (let i = origin; i < out.length; i++)
|
|
554
|
+
this.push(out[i], out[i - 1]);
|
|
555
|
+
}
|
|
556
|
+
/** Advance over the element just pushed, `previous` being the one before it. */
|
|
557
|
+
push(element, previous) {
|
|
558
|
+
const saved = this.saved;
|
|
559
|
+
const through = saved[saved.length - 2];
|
|
560
|
+
if (!isWhitespace(element)) {
|
|
561
|
+
saved.push(through + codePointLength(element), 0);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const run = saved[saved.length - 1];
|
|
565
|
+
if (run < 0) {
|
|
566
|
+
saved.push(through, -1);
|
|
567
|
+
}
|
|
568
|
+
else if (element === PARAGRAPH_BREAK || (element === '\n' && previous === '\n')) {
|
|
569
|
+
saved.push(through - run + PARAGRAPH_BREAK.length, -1);
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
saved.push(through + 1, run + 1);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/** Return to the length `out` had at `length` elements, the origin still among them. */
|
|
576
|
+
truncate(length) {
|
|
577
|
+
this.saved.length = 2 * (length - this.origin + 1);
|
|
578
|
+
}
|
|
579
|
+
/** How long `out` reads from `start`, at or past the origin and in no whitespace run, to its end. */
|
|
580
|
+
from(start) {
|
|
581
|
+
const through = this.saved[this.saved.length - 2];
|
|
582
|
+
return through - this.saved[2 * (start - this.origin)];
|
|
583
|
+
}
|
|
584
|
+
}
|
|
234
585
|
/**
|
|
235
586
|
* Handle markup in one left-to-right pass, reaching the result that repeating a regex
|
|
236
587
|
* replacement until nothing changes would reach — no allowlisted tag, comment, or delimiter can
|
|
@@ -240,11 +591,14 @@ function isFormulaOpener(out, start) {
|
|
|
240
591
|
* Characters are copied to `out`. Removing a tag, comment, or delimiter can join the text on
|
|
241
592
|
* either side into a new one (`<<i>i>`, `<!-<!---->- x -->`, `<![CDA<i></i>TA[`), so every
|
|
242
593
|
* removal truncates `out` and scanning simply continues, and every delimiter is recognized by
|
|
243
|
-
* how `out` ends rather than by the input: `opens` holds the
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
594
|
+
* how `out` ends rather than by the input: `opens` holds the position of each `<` in `out`,
|
|
595
|
+
* `tagShapes` how the text from the last of them reads as a tag after each element, and
|
|
596
|
+
* `commentStart` the earliest unclosed `<!--`. A tag ends at the first `>` after its `<`, so only
|
|
597
|
+
* the last `<` in `out` can begin one, and none can while a `>` kept as text follows it. A
|
|
598
|
+
* removal that takes that `>` away puts the `<` back in play, as a second pass over the joined
|
|
599
|
+
* text would (`<i%MathType!MTEF!>%!3BD8>`, `<b<?MML >?>>`). Inside an unclosed comment only a `<`
|
|
600
|
+
* opened after it can begin a tag, since `<!` begins none. Each character is examined a bounded
|
|
601
|
+
* number of times.
|
|
248
602
|
*
|
|
249
603
|
* CDATA: the `<![CDATA[` opener is dropped where it forms and `cdataStart` marks where the
|
|
250
604
|
* section's text begins. `]]>` closes it. Sources also drop the `>`, so a bare `]]` followed by
|
|
@@ -256,7 +610,11 @@ function isFormulaOpener(out, start) {
|
|
|
256
610
|
* `<?MML`: a pending instruction closes at the next `?>` unless another `<?` forms first or the
|
|
257
611
|
* span passes `MAX_MML_SPAN`; either way it stays literal, since its `?>` was lost. A `?>` that
|
|
258
612
|
* closes nothing is dropped when another pass would pair it with such a literal `<?MML` — no
|
|
259
|
-
* `<?` between them, within the cap — so the output stays a fixed point.
|
|
613
|
+
* `<?` between them, within the cap — so the output stays a fixed point. Both caps measure the
|
|
614
|
+
* span as the output reads it, after paragraph trimming, so another pass makes the same call. A
|
|
615
|
+
* span measured past the cap at a `?>` that a later removal takes away, inside a tag or a
|
|
616
|
+
* comment, is measured again at the next `?>`, which is where another pass would measure it. A
|
|
617
|
+
* `<?` that forms first leaves the span literal even when a later removal takes that `<?` too.
|
|
260
618
|
*
|
|
261
619
|
* Literal `\n`: sources that double-escaped their line breaks leave a backslash and an `n`. One
|
|
262
620
|
* whose backslash is not itself escaped and which no ASCII letter follows becomes a single
|
|
@@ -265,13 +623,33 @@ function isFormulaOpener(out, start) {
|
|
|
265
623
|
* `\neq`, `\nabla`, a custom `\np`), and no local rule tells those from a glued `\nand`, so
|
|
266
624
|
* those stay. A backslash just before a removed opener forms one with the `n` after it; the
|
|
267
625
|
* section or instruction that opener began stays open.
|
|
626
|
+
*
|
|
627
|
+
* MathType: `MathTypeScanner` follows `out` element by element. A blob starts at the `%` of its
|
|
628
|
+
* first `MathType!MTEF!` header line, or of a Translator line just before it, and ends at its
|
|
629
|
+
* checksum: the first `!` after a data line's characters with four uppercase hex digits. Only
|
|
630
|
+
* header lines and `%`-prefixed `[A-Za-z0-9+-]` data lines sit between — a `%` with no data
|
|
631
|
+
* before the next one ends the run — so a run that meets any other character, or the end of the
|
|
632
|
+
* text, stays literal, header included. A closing `!` right after the checksum goes with it. An
|
|
633
|
+
* End line goes wherever it stands. Both are removed like an `<?MML` instruction: blanks around
|
|
634
|
+
* one merge into one, drop at either end of the text, and none is added between two other
|
|
635
|
+
* characters.
|
|
268
636
|
*/
|
|
269
637
|
function stripMarkup(text) {
|
|
270
|
-
if (!text.includes('<') && !text.includes(LITERAL_NEWLINE))
|
|
638
|
+
if (!text.includes('<') && !text.includes(LITERAL_NEWLINE) && !text.includes(MATHTYPE_KEYWORD)) {
|
|
271
639
|
return text;
|
|
640
|
+
}
|
|
272
641
|
const out = [];
|
|
642
|
+
const mathType = mayHoldMathType(text) ? new MathTypeScanner() : undefined;
|
|
273
643
|
const opens = [];
|
|
644
|
+
/** The tag shape after each element of `out`. */
|
|
645
|
+
const tagShapes = [];
|
|
646
|
+
/** Where each `<?` in `out` starts. */
|
|
647
|
+
const instructionOpens = [];
|
|
648
|
+
/** Measures `out` from the first of `instructionOpens`, while there is one. */
|
|
649
|
+
let lengths;
|
|
274
650
|
const instructions = [];
|
|
651
|
+
/** Each `<?MML` left literal for passing the cap, and where the `?>` that measured it ends. */
|
|
652
|
+
const overCap = [];
|
|
275
653
|
let commentStart = -1;
|
|
276
654
|
let cdataStart = -1;
|
|
277
655
|
/** A space standing where text was removed, dropped if nothing follows it. */
|
|
@@ -282,6 +660,8 @@ function stripMarkup(text) {
|
|
|
282
660
|
let newlinePending = false;
|
|
283
661
|
/** A pending `<?MML` was left literal, so a stray `?>` may pair with it on another pass. */
|
|
284
662
|
let literalMml = false;
|
|
663
|
+
/** A MathType blob was just removed; a `!` next closes its checksum and goes with it. */
|
|
664
|
+
let checksumClose = false;
|
|
285
665
|
/**
|
|
286
666
|
* Where the next `]]>` and `<![CDATA[` start in `text`, -1 for none. The scan position only
|
|
287
667
|
* advances, so each is searched again only once passed, and every search covers new ground.
|
|
@@ -300,10 +680,35 @@ function stripMarkup(text) {
|
|
|
300
680
|
}
|
|
301
681
|
return nextSectionOpen < 0 || nextSectionClose < nextSectionOpen;
|
|
302
682
|
};
|
|
683
|
+
/** Append to `out`; returns where a MathType blob or End line this completes starts, or -1. */
|
|
684
|
+
const push = (element) => {
|
|
685
|
+
let shape = tagShapes.at(-1) ?? TAG_NONE;
|
|
686
|
+
if (element.length === 1)
|
|
687
|
+
shape = nextTagShape(shape, element);
|
|
688
|
+
else
|
|
689
|
+
for (const ch of element)
|
|
690
|
+
shape = nextTagShape(shape, ch);
|
|
691
|
+
tagShapes.push(shape);
|
|
692
|
+
out.push(element);
|
|
693
|
+
lengths?.push(element, out.at(-2));
|
|
694
|
+
return mathType ? mathType.push(element, out.length - 1) : -1;
|
|
695
|
+
};
|
|
696
|
+
/** How long `out` reads from `start`, a `<?` still in it, to its end on another pass. */
|
|
697
|
+
const spanLength = (start) => lengths.from(start);
|
|
303
698
|
const truncate = (length) => {
|
|
304
699
|
out.length = length;
|
|
700
|
+
tagShapes.length = length;
|
|
701
|
+
mathType?.truncate(length);
|
|
305
702
|
while (opens.length > 0 && opens.at(-1) >= length)
|
|
306
703
|
opens.pop();
|
|
704
|
+
// A `<?` goes with its `?`.
|
|
705
|
+
while (instructionOpens.length > 0 && instructionOpens.at(-1) >= length - 1) {
|
|
706
|
+
instructionOpens.pop();
|
|
707
|
+
}
|
|
708
|
+
if (instructionOpens.length === 0)
|
|
709
|
+
lengths = undefined;
|
|
710
|
+
else
|
|
711
|
+
lengths?.truncate(length);
|
|
307
712
|
if (commentStart >= length)
|
|
308
713
|
commentStart = -1;
|
|
309
714
|
if (cdataStart > length)
|
|
@@ -314,6 +719,12 @@ function stripMarkup(text) {
|
|
|
314
719
|
break;
|
|
315
720
|
instructions.pop();
|
|
316
721
|
}
|
|
722
|
+
// A span measured past the cap at a `?>` this takes away is measured again at the next one.
|
|
723
|
+
while (overCap.length > 0 && overCap.at(-1)?.end >= length) {
|
|
724
|
+
const { instruction } = overCap.pop();
|
|
725
|
+
if (instruction.start < length)
|
|
726
|
+
instructions.push(instruction);
|
|
727
|
+
}
|
|
317
728
|
if (softSpace >= length)
|
|
318
729
|
softSpace = -1;
|
|
319
730
|
};
|
|
@@ -335,9 +746,14 @@ function stripMarkup(text) {
|
|
|
335
746
|
};
|
|
336
747
|
/** Replace the `\n` that ends `out` with one space, merging the blanks before it. */
|
|
337
748
|
const replaceNewline = () => {
|
|
338
|
-
|
|
749
|
+
/**
|
|
750
|
+
* Blanks before a section's or instruction's text stay, so its opener keeps its position. An
|
|
751
|
+
* `<?MML` keeps the blank that ends its target too, or it would open a second time here.
|
|
752
|
+
*/
|
|
339
753
|
const instruction = instructions.at(-1);
|
|
340
|
-
const floor = Math.max(cdataStart, instruction
|
|
754
|
+
const floor = Math.max(cdataStart, instruction === undefined
|
|
755
|
+
? -1
|
|
756
|
+
: instruction.start + (instruction.kind === 'mml' ? INSTRUCTION_MML.length + 1 : 0));
|
|
341
757
|
let length = out.length - LITERAL_NEWLINE.length;
|
|
342
758
|
while (length > floor && length > 0 && isBlank(out[length - 1]))
|
|
343
759
|
length--;
|
|
@@ -351,27 +767,24 @@ function stripMarkup(text) {
|
|
|
351
767
|
instructions.push({ kind: 'cdata', start: length });
|
|
352
768
|
skipBlanks = true;
|
|
353
769
|
if (!openInstruction(' ') && length > 0 && !isBlank(out[length - 1])) {
|
|
354
|
-
|
|
770
|
+
push(' ');
|
|
355
771
|
softSpace = length;
|
|
356
772
|
}
|
|
357
773
|
};
|
|
358
774
|
/**
|
|
359
775
|
* Whether the `?>` ending `out` would close a literal `<?MML` on another pass: the nearest `<?`
|
|
360
|
-
* before it, within `MAX_MML_SPAN` of its end
|
|
776
|
+
* before it begins one, within `MAX_MML_SPAN` of its end.
|
|
361
777
|
*/
|
|
362
778
|
const pairsWithLiteralMml = () => {
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
return false;
|
|
370
|
-
}
|
|
371
|
-
const separator = out[at + INSTRUCTION_MML.length];
|
|
372
|
-
return separator !== undefined && endsInstructionTarget(separator);
|
|
779
|
+
const at = instructionOpens.at(-1);
|
|
780
|
+
if (at === undefined)
|
|
781
|
+
return false;
|
|
782
|
+
for (let i = 2; i < INSTRUCTION_MML.length; i++) {
|
|
783
|
+
if (out[at + i] !== INSTRUCTION_MML[i])
|
|
784
|
+
return false;
|
|
373
785
|
}
|
|
374
|
-
|
|
786
|
+
const separator = out[at + INSTRUCTION_MML.length];
|
|
787
|
+
return (separator !== undefined && endsInstructionTarget(separator) && spanLength(at) <= MAX_MML_SPAN);
|
|
375
788
|
};
|
|
376
789
|
/** After removing a span: merge the blanks around it, and drop it cleanly at either end. */
|
|
377
790
|
const settleRemoval = () => {
|
|
@@ -391,7 +804,7 @@ function stripMarkup(text) {
|
|
|
391
804
|
start -= CDATA_BARE_CLOSE.length + gap;
|
|
392
805
|
truncate(start);
|
|
393
806
|
if (lostClose && gap > 0 && out.length > 0 && !isBlank(out.at(-1)) && !openInstruction(' ')) {
|
|
394
|
-
|
|
807
|
+
push(' ');
|
|
395
808
|
}
|
|
396
809
|
// Sections do not nest; a second opener inside one only goes.
|
|
397
810
|
if (cdataStart < 0)
|
|
@@ -401,6 +814,11 @@ function stripMarkup(text) {
|
|
|
401
814
|
for (const ch of text) {
|
|
402
815
|
const at = offset;
|
|
403
816
|
offset += ch.length;
|
|
817
|
+
if (checksumClose) {
|
|
818
|
+
checksumClose = false;
|
|
819
|
+
if (ch === '!')
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
404
822
|
if (newlinePending) {
|
|
405
823
|
newlinePending = false;
|
|
406
824
|
if (!isAsciiLetter(ch))
|
|
@@ -425,7 +843,13 @@ function stripMarkup(text) {
|
|
|
425
843
|
}
|
|
426
844
|
if (endsInstructionTarget(ch) && openInstruction(ch))
|
|
427
845
|
continue;
|
|
428
|
-
|
|
846
|
+
const mathTypeStart = push(ch);
|
|
847
|
+
if (mathTypeStart >= 0) {
|
|
848
|
+
checksumClose = mathType?.completedBlob === true;
|
|
849
|
+
truncate(mathTypeStart);
|
|
850
|
+
settleRemoval();
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
429
853
|
if (ch === 'n' && out.at(-2) === '\\' && out.at(-3) !== '\\') {
|
|
430
854
|
newlinePending = true;
|
|
431
855
|
continue;
|
|
@@ -445,9 +869,13 @@ function stripMarkup(text) {
|
|
|
445
869
|
continue;
|
|
446
870
|
}
|
|
447
871
|
if (ch === '?') {
|
|
448
|
-
if (out.at(-2) === '<'
|
|
449
|
-
|
|
450
|
-
|
|
872
|
+
if (out.at(-2) === '<') {
|
|
873
|
+
instructionOpens.push(out.length - 2);
|
|
874
|
+
lengths ??= new SecondPassLength(out, out.length - 2);
|
|
875
|
+
if (instructions.at(-1)?.kind === 'mml') {
|
|
876
|
+
instructions.pop();
|
|
877
|
+
literalMml = true;
|
|
878
|
+
}
|
|
451
879
|
}
|
|
452
880
|
continue;
|
|
453
881
|
}
|
|
@@ -462,8 +890,9 @@ function stripMarkup(text) {
|
|
|
462
890
|
}
|
|
463
891
|
if (endsWith(out, INSTRUCTION_CLOSE)) {
|
|
464
892
|
let instruction = instructions.at(-1);
|
|
465
|
-
if (instruction?.kind === 'mml' &&
|
|
893
|
+
if (instruction?.kind === 'mml' && spanLength(instruction.start) > MAX_MML_SPAN) {
|
|
466
894
|
instructions.pop();
|
|
895
|
+
overCap.push({ end: out.length - 1, instruction });
|
|
467
896
|
instruction = instructions.at(-1);
|
|
468
897
|
}
|
|
469
898
|
if (instruction) {
|
|
@@ -489,27 +918,22 @@ function stripMarkup(text) {
|
|
|
489
918
|
truncate(commentStart);
|
|
490
919
|
continue;
|
|
491
920
|
}
|
|
492
|
-
// Like a tag, a formula opener goes even inside an unclosed comment.
|
|
493
921
|
const top = opens.at(-1);
|
|
494
|
-
if (top
|
|
922
|
+
if (top === undefined)
|
|
923
|
+
continue;
|
|
924
|
+
// Like a tag, a formula opener goes even inside an unclosed comment.
|
|
925
|
+
if (isFormulaOpener(out, top)) {
|
|
495
926
|
truncate(top);
|
|
496
927
|
continue;
|
|
497
928
|
}
|
|
498
|
-
|
|
499
|
-
if (
|
|
929
|
+
const replacement = replaceTag(out, top, tagShapes[out.length - 2]);
|
|
930
|
+
if (replacement === undefined)
|
|
500
931
|
continue;
|
|
501
|
-
opens.pop();
|
|
502
|
-
const replacement = replaceTag(out.slice(top).join(''));
|
|
503
|
-
if (replacement === undefined) {
|
|
504
|
-
while (opens.length > 0 && opens.at(-1) > commentStart)
|
|
505
|
-
opens.pop();
|
|
506
|
-
continue;
|
|
507
|
-
}
|
|
508
932
|
truncate(top);
|
|
509
933
|
if (replacement === PARAGRAPH_BREAK && openInstruction(replacement))
|
|
510
934
|
continue;
|
|
511
935
|
if (replacement)
|
|
512
|
-
|
|
936
|
+
push(replacement);
|
|
513
937
|
}
|
|
514
938
|
if (newlinePending)
|
|
515
939
|
replaceNewline();
|