@cyanheads/openalex-mcp-server 0.8.0 → 0.10.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.
Files changed (67) hide show
  1. package/AGENTS.md +25 -7
  2. package/CLAUDE.md +25 -7
  3. package/Dockerfile +17 -8
  4. package/README.md +28 -19
  5. package/changelog/0.10.x/0.10.0.md +30 -0
  6. package/changelog/0.9.x/0.9.0.md +48 -0
  7. package/dist/config/server-config.d.ts +2 -0
  8. package/dist/config/server-config.d.ts.map +1 -1
  9. package/dist/config/server-config.js +10 -0
  10. package/dist/config/server-config.js.map +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/mcp-server/prompts/definitions/literature-review.prompt.js +1 -1
  14. package/dist/mcp-server/prompts/definitions/research-landscape.prompt.js +1 -1
  15. package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts +35 -4
  16. package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts.map +1 -1
  17. package/dist/mcp-server/tools/definitions/analyze-trends.tool.js +86 -41
  18. package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
  19. package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +48 -5
  20. package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts.map +1 -1
  21. package/dist/mcp-server/tools/definitions/citation-graph.tool.js +77 -24
  22. package/dist/mcp-server/tools/definitions/citation-graph.tool.js.map +1 -1
  23. package/dist/mcp-server/tools/definitions/describe-fields.tool.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/definitions/describe-fields.tool.js +34 -4
  25. package/dist/mcp-server/tools/definitions/describe-fields.tool.js.map +1 -1
  26. package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts +43 -1
  27. package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts.map +1 -1
  28. package/dist/mcp-server/tools/definitions/resolve-name.tool.js +182 -40
  29. package/dist/mcp-server/tools/definitions/resolve-name.tool.js.map +1 -1
  30. package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts +64 -6
  31. package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts.map +1 -1
  32. package/dist/mcp-server/tools/definitions/search-entities.tool.js +138 -35
  33. package/dist/mcp-server/tools/definitions/search-entities.tool.js.map +1 -1
  34. package/dist/mcp-server/tools/page-size.d.ts +18 -0
  35. package/dist/mcp-server/tools/page-size.d.ts.map +1 -0
  36. package/dist/mcp-server/tools/page-size.js +28 -0
  37. package/dist/mcp-server/tools/page-size.js.map +1 -0
  38. package/dist/mcp-server/tools/render-budget.d.ts +4 -2
  39. package/dist/mcp-server/tools/render-budget.d.ts.map +1 -1
  40. package/dist/mcp-server/tools/render-budget.js +4 -2
  41. package/dist/mcp-server/tools/render-budget.js.map +1 -1
  42. package/dist/mcp-server/tools/response-budget.d.ts +3 -3
  43. package/dist/mcp-server/tools/response-budget.d.ts.map +1 -1
  44. package/dist/mcp-server/tools/response-budget.js +64 -35
  45. package/dist/mcp-server/tools/response-budget.js.map +1 -1
  46. package/dist/services/openalex/budget.d.ts +3 -1
  47. package/dist/services/openalex/budget.d.ts.map +1 -1
  48. package/dist/services/openalex/budget.js +3 -1
  49. package/dist/services/openalex/budget.js.map +1 -1
  50. package/dist/services/openalex/field-catalog.json +50 -1
  51. package/dist/services/openalex/filter-values.d.ts +48 -0
  52. package/dist/services/openalex/filter-values.d.ts.map +1 -0
  53. package/dist/services/openalex/filter-values.js +350 -0
  54. package/dist/services/openalex/filter-values.js.map +1 -0
  55. package/dist/services/openalex/iso-codes.json +1565 -0
  56. package/dist/services/openalex/openalex-service.d.ts +176 -20
  57. package/dist/services/openalex/openalex-service.d.ts.map +1 -1
  58. package/dist/services/openalex/openalex-service.js +573 -127
  59. package/dist/services/openalex/openalex-service.js.map +1 -1
  60. package/dist/services/openalex/provider-text.d.ts +7 -6
  61. package/dist/services/openalex/provider-text.d.ts.map +1 -1
  62. package/dist/services/openalex/provider-text.js +485 -61
  63. package/dist/services/openalex/provider-text.js.map +1 -1
  64. package/dist/services/openalex/types.d.ts +5 -2
  65. package/dist/services/openalex/types.d.ts.map +1 -1
  66. package/package.json +14 -12
  67. package/server.json +35 -21
@@ -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, and
7
- * turns a double-escaped line break (a literal backslash + `n`) into a space. Everything else
8
- * stays literal, so text such as `A < B`, `Fish <Actinopterygii>`, a SICI DOI, or TeX like `\nu`
9
- * survives untouched. Output is plain text; Markdown escaping for `content[]` happens at the
10
- * render boundary, never here. Identifier and URL fields of a record are not provider text and
11
- * pass through byte-identical, so each one resolves when passed back.
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
- * What an allowlisted tag becomes, or `undefined` for any other `<…>` span, which stays literal.
161
- * Matching a whole tag name — never a prefix — is what keeps `i1<i2<` and `<Actinopterygii>`.
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
- function replaceTag(candidate) {
164
- const tag = TAG.exec(candidate);
165
- if (!tag)
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 = tag[1] === '/';
168
- const name = tag[2].toLowerCase();
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
- if (candidate.endsWith('/>'))
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 `?>`. Every one in a 3,543-record sample
206
- * spans at most 64 characters; one that runs longer lost its `?>` and stays literal rather than
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
- /** Characters that end a processing-instruction target. */
224
- const endsInstructionTarget = (ch) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '?';
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 positions of each `<` that could
244
- * still begin a tag, and `commentStart` the earliest unclosed `<!--`. A tag ends at the first `>`
245
- * after its `<`, so a `>` settles the innermost open `<`: kept as text, it also blocks every `<`
246
- * below it, except those before an unclosed comment, which come back into play if that comment
247
- * closes. Each character is examined a bounded number of times.
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
- // Blanks before a section or instruction opener stay, so the opener keeps its position.
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?.start ?? -1);
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
- out.push(' ');
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, begins one.
776
+ * before it begins one, within `MAX_MML_SPAN` of its end.
361
777
  */
362
778
  const pairsWithLiteralMml = () => {
363
- const floor = Math.max(0, out.length - MAX_MML_SPAN);
364
- for (let at = out.length - INSTRUCTION_CLOSE.length - 1; at >= floor; at--) {
365
- if (out[at] !== '<' || out[at + 1] !== '?')
366
- continue;
367
- for (let i = 2; i < INSTRUCTION_MML.length; i++) {
368
- if (out[at + i] !== INSTRUCTION_MML[i])
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
- return false;
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
- out.push(' ');
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
- out.push(ch);
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) === '<' && instructions.at(-1)?.kind === 'mml') {
449
- instructions.pop();
450
- literalMml = true;
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' && out.length - instruction.start > MAX_MML_SPAN) {
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 !== undefined && isFormulaOpener(out, 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
- // Inside an unclosed comment, only a `<` opened after it can form a tag.
499
- if (top === undefined || top <= commentStart)
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
- out.push(replacement);
936
+ push(replacement);
513
937
  }
514
938
  if (newlinePending)
515
939
  replaceNewline();