@cyanheads/openalex-mcp-server 0.7.16 → 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.
Files changed (63) hide show
  1. package/AGENTS.md +48 -33
  2. package/CLAUDE.md +48 -33
  3. package/Dockerfile +98 -33
  4. package/README.md +23 -16
  5. package/changelog/0.8.x/0.8.0.md +38 -0
  6. package/changelog/0.9.x/0.9.0.md +48 -0
  7. package/dist/index.js +1 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp-server/prompts/definitions/literature-review.prompt.js +1 -1
  10. package/dist/mcp-server/prompts/definitions/research-landscape.prompt.js +1 -1
  11. package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts +28 -3
  12. package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts.map +1 -1
  13. package/dist/mcp-server/tools/definitions/analyze-trends.tool.js +57 -10
  14. package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
  15. package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +62 -5
  16. package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts.map +1 -1
  17. package/dist/mcp-server/tools/definitions/citation-graph.tool.js +100 -52
  18. package/dist/mcp-server/tools/definitions/citation-graph.tool.js.map +1 -1
  19. package/dist/mcp-server/tools/definitions/describe-fields.tool.d.ts.map +1 -1
  20. package/dist/mcp-server/tools/definitions/describe-fields.tool.js +34 -4
  21. package/dist/mcp-server/tools/definitions/describe-fields.tool.js.map +1 -1
  22. package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts +38 -2
  23. package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/definitions/resolve-name.tool.js +176 -41
  25. package/dist/mcp-server/tools/definitions/resolve-name.tool.js.map +1 -1
  26. package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts +75 -4
  27. package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts.map +1 -1
  28. package/dist/mcp-server/tools/definitions/search-entities.tool.js +169 -73
  29. package/dist/mcp-server/tools/definitions/search-entities.tool.js.map +1 -1
  30. package/dist/mcp-server/tools/render-budget.d.ts +4 -2
  31. package/dist/mcp-server/tools/render-budget.d.ts.map +1 -1
  32. package/dist/mcp-server/tools/render-budget.js +4 -2
  33. package/dist/mcp-server/tools/render-budget.js.map +1 -1
  34. package/dist/mcp-server/tools/render-entity-record.d.ts +12 -1
  35. package/dist/mcp-server/tools/render-entity-record.d.ts.map +1 -1
  36. package/dist/mcp-server/tools/render-entity-record.js +23 -8
  37. package/dist/mcp-server/tools/render-entity-record.js.map +1 -1
  38. package/dist/mcp-server/tools/response-budget.d.ts +160 -0
  39. package/dist/mcp-server/tools/response-budget.d.ts.map +1 -0
  40. package/dist/mcp-server/tools/response-budget.js +489 -0
  41. package/dist/mcp-server/tools/response-budget.js.map +1 -0
  42. package/dist/services/openalex/budget.d.ts +3 -1
  43. package/dist/services/openalex/budget.d.ts.map +1 -1
  44. package/dist/services/openalex/budget.js +3 -1
  45. package/dist/services/openalex/budget.js.map +1 -1
  46. package/dist/services/openalex/field-catalog.json +50 -1
  47. package/dist/services/openalex/filter-values.d.ts +46 -0
  48. package/dist/services/openalex/filter-values.d.ts.map +1 -0
  49. package/dist/services/openalex/filter-values.js +294 -0
  50. package/dist/services/openalex/filter-values.js.map +1 -0
  51. package/dist/services/openalex/iso-codes.json +1565 -0
  52. package/dist/services/openalex/openalex-service.d.ts +181 -16
  53. package/dist/services/openalex/openalex-service.d.ts.map +1 -1
  54. package/dist/services/openalex/openalex-service.js +593 -141
  55. package/dist/services/openalex/openalex-service.js.map +1 -1
  56. package/dist/services/openalex/provider-text.d.ts +13 -5
  57. package/dist/services/openalex/provider-text.d.ts.map +1 -1
  58. package/dist/services/openalex/provider-text.js +799 -51
  59. package/dist/services/openalex/provider-text.js.map +1 -1
  60. package/dist/services/openalex/types.d.ts +1 -1
  61. package/dist/services/openalex/types.js +1 -1
  62. package/package.json +16 -13
  63. package/server.json +10 -24
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * @fileoverview Normalization of provider text — the titles, abstracts, names, and affiliation
3
3
  * strings OpenAlex passes through from its sources unsanitized. Decodes HTML character
4
- * references against the full WHATWG table in one pass, then removes HTML comments and unwraps
5
- * a small allowlist of HTML/JATS/MathML elements. Everything else stays literal, so text such as
6
- * `A < B`, `Fish <Actinopterygii>`, or a SICI DOI survives untouched. Output is plain text;
7
- * Markdown escaping for `content[]` happens at the render boundary, never here.
4
+ * references against the full WHATWG table in one pass, then, in a second single pass, removes
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
+ * 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.
8
13
  * @module services/openalex/provider-text
9
14
  */
10
15
  import entityTable from './html-entities.json' with { type: 'json' };
@@ -144,18 +149,73 @@ const SCRIPT_OPENERS = new Map([
144
149
  ['sup', '^{'],
145
150
  ['jats:sup', '^{'],
146
151
  ]);
147
- /** A complete tag, checked against one `<…>` candidate that holds no other `<` or `>`. */
148
- const TAG = /^<(\/?)([A-Za-z][\w:.-]*)(?:\s[^<>]*)?\/?>$/;
152
+ function isAllowlistedElement(name) {
153
+ return (INLINE_ELEMENTS.has(name) ||
154
+ name.startsWith('mml:') ||
155
+ BLOCK_ELEMENTS.has(name) ||
156
+ SCRIPT_OPENERS.has(name));
157
+ }
158
+ /**
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`.
164
+ */
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
+ }
149
203
  /**
150
- * What an allowlisted tag becomes, or `undefined` for any other `<…>` span, which stays literal.
151
- * Matching a whole tag name — never a prefix — is what keeps `i1<i2<` and `<Actinopterygii>`.
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>`.
152
208
  */
153
- function replaceTag(candidate) {
154
- const tag = TAG.exec(candidate);
155
- if (!tag)
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)
156
212
  return;
157
- const closing = tag[1] === '/';
158
- 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();
159
219
  if (INLINE_ELEMENTS.has(name) || name.startsWith('mml:'))
160
220
  return '';
161
221
  if (BLOCK_ELEMENTS.has(name))
@@ -163,13 +223,43 @@ function replaceTag(candidate) {
163
223
  const opener = SCRIPT_OPENERS.get(name);
164
224
  if (opener === undefined)
165
225
  return;
166
- if (candidate.endsWith('/>'))
226
+ // The element before the closing `>` ends `/>` on a self-closing tag.
227
+ if (out.at(-2).endsWith('/'))
167
228
  return '';
168
229
  return closing ? '}' : opener;
169
230
  }
170
231
  const COMMENT_OPEN = '<!--';
171
232
  /** `<!---->` — the shortest span that closes a comment it opened. */
172
233
  const MIN_COMMENT_LENGTH = 7;
234
+ /**
235
+ * `<!--inline-formula>`: an A&A/JATS formula opener written as a comment opener that never
236
+ * closes. An allowlisted name straight after `<!--` makes it a tag to remove, not a comment —
237
+ * left open, it would delete everything up to the next `-->`, which math text supplies as an
238
+ * arrow (`--->`). A space after `<!--`, as every real comment in the corpus has, keeps it a
239
+ * comment. The length cap bounds the check to the one `>` that can end such a name.
240
+ */
241
+ const FORMULA_OPENER = /^<!--([A-Za-z][\w:.-]*)>$/;
242
+ const MAX_FORMULA_OPENER_LENGTH = 64;
243
+ const CDATA_OPEN = '<![CDATA[';
244
+ const CDATA_CLOSE = ']]>';
245
+ /** The closer without its `>`, which some sources end a section with. */
246
+ const CDATA_BARE_CLOSE = ']]';
247
+ /**
248
+ * Processing instructions IOP wraps inline math in: `<?CDATA x?>` carries the TeX and is
249
+ * unwrapped; the `<?MML y?>` that always follows restates it flattened (`10^4` reads `10 4`) and
250
+ * is removed whole. Only these two targets — a generic `<?name` rule would eat `<?php` text.
251
+ */
252
+ const INSTRUCTION_CDATA = '<?CDATA';
253
+ const INSTRUCTION_MML = '<?MML';
254
+ const INSTRUCTION_CLOSE = '?>';
255
+ /**
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 `?>`.
259
+ */
260
+ const MAX_MML_SPAN = 128;
261
+ /** A double-escaped line break: the two characters `\` and `n`, not a newline. */
262
+ const LITERAL_NEWLINE = '\\n';
173
263
  function endsWith(out, suffix) {
174
264
  if (out.length < suffix.length)
175
265
  return false;
@@ -179,63 +269,681 @@ function endsWith(out, suffix) {
179
269
  }
180
270
  return true;
181
271
  }
272
+ const isBlank = (ch) => ch === ' ' || ch === '\t';
273
+ const isAsciiLetter = (ch) => ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'));
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);
283
+ /** Whether `out` ends with a formula opener whose `<` sits at `start`. */
284
+ function isFormulaOpener(out, start) {
285
+ if (out.length - start > MAX_FORMULA_OPENER_LENGTH)
286
+ return false;
287
+ if (out[start + 1] !== '!' || !isAsciiLetter(out[start + COMMENT_OPEN.length]))
288
+ return false;
289
+ const name = FORMULA_OPENER.exec(out.slice(start).join(''))?.[1];
290
+ return name !== undefined && isAllowlistedElement(name.toLowerCase());
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;
182
529
  /**
183
- * Remove HTML comments and rewrite allowlisted tags in one left-to-right pass, reaching the
184
- * result that repeating a regex replacement until nothing changes would reach — no allowlisted
185
- * tag can re-form in the output — in linear time. A replace-until-stable loop costs one full
186
- * pass per nesting level, which is quadratic on nested input such as `<<<i>i>i>`.
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.
187
536
  *
188
- * Characters are copied to `out`. Removing a tag or comment can join the text on either side
189
- * into a new one (`<<i>i>`, `<!-<!---->- x -->`), so every removal truncates `out` and scanning
190
- * simply continues: `opens` holds the positions of each `<` that could still begin a tag, and
191
- * `commentStart` the earliest unclosed `<!--`. A tag ends at the first `>` after its `<`, so a
192
- * `>` settles the innermost open `<`: kept as text, it also blocks every `<` below it, except
193
- * those before an unclosed comment, which come back into play if that comment closes. Each
194
- * character is examined a bounded number of times.
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
+ }
585
+ /**
586
+ * Handle markup in one left-to-right pass, reaching the result that repeating a regex
587
+ * replacement until nothing changes would reach — no allowlisted tag, comment, or delimiter can
588
+ * re-form in the output — in linear time. A replace-until-stable loop costs one full pass per
589
+ * nesting level, which is quadratic on nested input such as `<<<i>i>i>`.
590
+ *
591
+ * Characters are copied to `out`. Removing a tag, comment, or delimiter can join the text on
592
+ * either side into a new one (`<<i>i>`, `<!-<!---->- x -->`, `<![CDA<i></i>TA[`), so every
593
+ * removal truncates `out` and scanning simply continues, and every delimiter is recognized by
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.
602
+ *
603
+ * CDATA: the `<![CDATA[` opener is dropped where it forms and `cdataStart` marks where the
604
+ * section's text begins. `]]>` closes it. Sources also drop the `>`, so a bare `]]` followed by
605
+ * anything but `>` or `]` — the end of the text included — closes it as well, unless the text
606
+ * still holds a `]]>` before its next opener: then the `]]` belongs to the section, as in TeX's
607
+ * `k[[x]]`. A bare `]]` at most one space before an opener closed a section whose opener was
608
+ * lost, and goes with it. With no open section, `]]` and `]]>` are text.
609
+ *
610
+ * `<?MML`: a pending instruction closes at the next `?>` unless another `<?` forms first or the
611
+ * span passes `MAX_MML_SPAN`; either way it stays literal, since its `?>` was lost. A `?>` that
612
+ * closes nothing is dropped when another pass would pair it with such a literal `<?MML` — no
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.
618
+ *
619
+ * Literal `\n`: sources that double-escaped their line breaks leave a backslash and an `n`. One
620
+ * whose backslash is not itself escaped and which no ASCII letter follows becomes a single
621
+ * space, merged with the spaces around it and dropped at either end of the text. Most wrap
622
+ * mid-sentence, so it is never a paragraph break. A letter after it reads as TeX (`\nu`,
623
+ * `\neq`, `\nabla`, a custom `\np`), and no local rule tells those from a glued `\nand`, so
624
+ * those stay. A backslash just before a removed opener forms one with the `n` after it; the
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.
195
636
  */
196
637
  function stripMarkup(text) {
197
- if (!text.includes('<'))
638
+ if (!text.includes('<') && !text.includes(LITERAL_NEWLINE) && !text.includes(MATHTYPE_KEYWORD)) {
198
639
  return text;
640
+ }
199
641
  const out = [];
642
+ const mathType = mayHoldMathType(text) ? new MathTypeScanner() : undefined;
200
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;
650
+ const instructions = [];
651
+ /** Each `<?MML` left literal for passing the cap, and where the `?>` that measured it ends. */
652
+ const overCap = [];
201
653
  let commentStart = -1;
654
+ let cdataStart = -1;
655
+ /** A space standing where text was removed, dropped if nothing follows it. */
656
+ let softSpace = -1;
657
+ /** Swallow spaces and tabs up to the next other character. */
658
+ let skipBlanks = false;
659
+ /** An unescaped `\n` was just copied; the next character decides what it is. */
660
+ let newlinePending = false;
661
+ /** A pending `<?MML` was left literal, so a stray `?>` may pair with it on another pass. */
662
+ let literalMml = false;
663
+ /** A MathType blob was just removed; a `!` next closes its checksum and goes with it. */
664
+ let checksumClose = false;
665
+ /**
666
+ * Where the next `]]>` and `<![CDATA[` start in `text`, -1 for none. The scan position only
667
+ * advances, so each is searched again only once passed, and every search covers new ground.
668
+ */
669
+ let nextSectionClose = -2;
670
+ let nextSectionOpen = -2;
671
+ /** Whether `text` holds a `]]>` at or after `from`, before any further section opener. */
672
+ const sectionCloseAhead = (from) => {
673
+ if (nextSectionClose !== -1 && nextSectionClose < from) {
674
+ nextSectionClose = text.indexOf(CDATA_CLOSE, from);
675
+ }
676
+ if (nextSectionClose < 0)
677
+ return false;
678
+ if (nextSectionOpen !== -1 && nextSectionOpen < from) {
679
+ nextSectionOpen = text.indexOf(CDATA_OPEN, from);
680
+ }
681
+ return nextSectionOpen < 0 || nextSectionClose < nextSectionOpen;
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);
698
+ const truncate = (length) => {
699
+ out.length = length;
700
+ tagShapes.length = length;
701
+ mathType?.truncate(length);
702
+ while (opens.length > 0 && opens.at(-1) >= length)
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);
712
+ if (commentStart >= length)
713
+ commentStart = -1;
714
+ if (cdataStart > length)
715
+ cdataStart = -1;
716
+ while (instructions.length > 0) {
717
+ const { kind, start } = instructions.at(-1);
718
+ if (kind === 'mml' ? start < length : start <= length)
719
+ break;
720
+ instructions.pop();
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
+ }
728
+ if (softSpace >= length)
729
+ softSpace = -1;
730
+ };
731
+ /**
732
+ * Open a processing instruction when `out` ends with its target and `separator` ends that
733
+ * target — whitespace, a `?`, or the space or break a removal leaves. Returns whether the
734
+ * separator is consumed: the whitespace after a CDATA target only separates it from the content.
735
+ */
736
+ const openInstruction = (separator) => {
737
+ if (endsWith(out, INSTRUCTION_CDATA)) {
738
+ truncate(out.length - INSTRUCTION_CDATA.length);
739
+ instructions.push({ kind: 'cdata', start: out.length });
740
+ return separator !== '?';
741
+ }
742
+ if (endsWith(out, INSTRUCTION_MML)) {
743
+ instructions.push({ kind: 'mml', start: out.length - INSTRUCTION_MML.length });
744
+ }
745
+ return false;
746
+ };
747
+ /** Replace the `\n` that ends `out` with one space, merging the blanks before it. */
748
+ const replaceNewline = () => {
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
+ */
753
+ const instruction = instructions.at(-1);
754
+ const floor = Math.max(cdataStart, instruction === undefined
755
+ ? -1
756
+ : instruction.start + (instruction.kind === 'mml' ? INSTRUCTION_MML.length + 1 : 0));
757
+ let length = out.length - LITERAL_NEWLINE.length;
758
+ while (length > floor && length > 0 && isBlank(out[length - 1]))
759
+ length--;
760
+ // The backslash sits before a removed opener whose section or instruction the `n` began.
761
+ const reopenSection = cdataStart > length;
762
+ const reopenInstruction = instruction?.kind === 'cdata' && instruction.start > length;
763
+ truncate(length);
764
+ if (reopenSection)
765
+ cdataStart = length;
766
+ if (reopenInstruction)
767
+ instructions.push({ kind: 'cdata', start: length });
768
+ skipBlanks = true;
769
+ if (!openInstruction(' ') && length > 0 && !isBlank(out[length - 1])) {
770
+ push(' ');
771
+ softSpace = length;
772
+ }
773
+ };
774
+ /**
775
+ * Whether the `?>` ending `out` would close a literal `<?MML` on another pass: the nearest `<?`
776
+ * before it begins one, within `MAX_MML_SPAN` of its end.
777
+ */
778
+ const pairsWithLiteralMml = () => {
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;
785
+ }
786
+ const separator = out[at + INSTRUCTION_MML.length];
787
+ return (separator !== undefined && endsInstructionTarget(separator) && spanLength(at) <= MAX_MML_SPAN);
788
+ };
789
+ /** After removing a span: merge the blanks around it, and drop it cleanly at either end. */
790
+ const settleRemoval = () => {
791
+ if (out.length === 0) {
792
+ skipBlanks = true;
793
+ }
794
+ else if (isBlank(out.at(-1))) {
795
+ skipBlanks = true;
796
+ softSpace = out.length - 1;
797
+ }
798
+ };
799
+ const openSection = () => {
800
+ let start = out.length - CDATA_OPEN.length;
801
+ const gap = out[start - 1] === ' ' ? 1 : 0;
802
+ const lostClose = out[start - 1 - gap] === ']' && out[start - 2 - gap] === ']';
803
+ if (lostClose)
804
+ start -= CDATA_BARE_CLOSE.length + gap;
805
+ truncate(start);
806
+ if (lostClose && gap > 0 && out.length > 0 && !isBlank(out.at(-1)) && !openInstruction(' ')) {
807
+ push(' ');
808
+ }
809
+ // Sections do not nest; a second opener inside one only goes.
810
+ if (cdataStart < 0)
811
+ cdataStart = out.length;
812
+ };
813
+ let offset = 0;
202
814
  for (const ch of text) {
203
- out.push(ch);
815
+ const at = offset;
816
+ offset += ch.length;
817
+ if (checksumClose) {
818
+ checksumClose = false;
819
+ if (ch === '!')
820
+ continue;
821
+ }
822
+ if (newlinePending) {
823
+ newlinePending = false;
824
+ if (!isAsciiLetter(ch))
825
+ replaceNewline();
826
+ }
827
+ if (skipBlanks) {
828
+ if (isBlank(ch))
829
+ continue;
830
+ skipBlanks = false;
831
+ }
832
+ if (cdataStart >= 0 &&
833
+ ch !== '>' &&
834
+ ch !== ']' &&
835
+ out.length - CDATA_BARE_CLOSE.length >= cdataStart &&
836
+ endsWith(out, CDATA_BARE_CLOSE) &&
837
+ !sectionCloseAhead(at)) {
838
+ truncate(out.length - CDATA_BARE_CLOSE.length);
839
+ cdataStart = -1;
840
+ // `x ]] y` leaves one space, not two.
841
+ if (isBlank(ch) && isBlank(out.at(-1)))
842
+ continue;
843
+ }
844
+ if (endsInstructionTarget(ch) && openInstruction(ch))
845
+ continue;
846
+ const mathTypeStart = push(ch);
847
+ if (mathTypeStart >= 0) {
848
+ checksumClose = mathType?.completedBlob === true;
849
+ truncate(mathTypeStart);
850
+ settleRemoval();
851
+ continue;
852
+ }
853
+ if (ch === 'n' && out.at(-2) === '\\' && out.at(-3) !== '\\') {
854
+ newlinePending = true;
855
+ continue;
856
+ }
204
857
  if (ch === '<') {
205
858
  opens.push(out.length - 1);
206
859
  continue;
207
860
  }
861
+ if (ch === '[') {
862
+ if (endsWith(out, CDATA_OPEN))
863
+ openSection();
864
+ continue;
865
+ }
208
866
  if (ch === '-') {
209
867
  if (commentStart < 0 && endsWith(out, COMMENT_OPEN))
210
868
  commentStart = out.length - 4;
211
869
  continue;
212
870
  }
871
+ if (ch === '?') {
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
+ }
879
+ }
880
+ continue;
881
+ }
213
882
  if (ch !== '>')
214
883
  continue;
884
+ if (cdataStart >= 0 &&
885
+ out.length - CDATA_CLOSE.length >= cdataStart &&
886
+ endsWith(out, CDATA_CLOSE)) {
887
+ truncate(out.length - CDATA_CLOSE.length);
888
+ cdataStart = -1;
889
+ continue;
890
+ }
891
+ if (endsWith(out, INSTRUCTION_CLOSE)) {
892
+ let instruction = instructions.at(-1);
893
+ if (instruction?.kind === 'mml' && spanLength(instruction.start) > MAX_MML_SPAN) {
894
+ instructions.pop();
895
+ overCap.push({ end: out.length - 1, instruction });
896
+ instruction = instructions.at(-1);
897
+ }
898
+ if (instruction) {
899
+ instructions.pop();
900
+ if (instruction.kind === 'cdata') {
901
+ truncate(out.length - INSTRUCTION_CLOSE.length);
902
+ }
903
+ else {
904
+ truncate(instruction.start);
905
+ settleRemoval();
906
+ }
907
+ continue;
908
+ }
909
+ if (literalMml && pairsWithLiteralMml()) {
910
+ truncate(out.length - INSTRUCTION_CLOSE.length);
911
+ settleRemoval();
912
+ continue;
913
+ }
914
+ }
215
915
  if (commentStart >= 0 &&
216
916
  out.length - commentStart >= MIN_COMMENT_LENGTH &&
217
917
  endsWith(out, '-->')) {
218
- out.length = commentStart;
219
- commentStart = -1;
220
- while (opens.length > 0 && opens.at(-1) >= out.length)
221
- opens.pop();
918
+ truncate(commentStart);
222
919
  continue;
223
920
  }
224
- // Inside an unclosed comment, only a `<` opened after it can form a tag.
225
921
  const top = opens.at(-1);
226
- if (top === undefined || top <= commentStart)
922
+ if (top === undefined)
227
923
  continue;
228
- opens.pop();
229
- const replacement = replaceTag(out.slice(top).join(''));
230
- if (replacement === undefined) {
231
- while (opens.length > 0 && opens.at(-1) > commentStart)
232
- opens.pop();
924
+ // Like a tag, a formula opener goes even inside an unclosed comment.
925
+ if (isFormulaOpener(out, top)) {
926
+ truncate(top);
233
927
  continue;
234
928
  }
235
- out.length = top;
929
+ const replacement = replaceTag(out, top, tagShapes[out.length - 2]);
930
+ if (replacement === undefined)
931
+ continue;
932
+ truncate(top);
933
+ if (replacement === PARAGRAPH_BREAK && openInstruction(replacement))
934
+ continue;
236
935
  if (replacement)
237
- out.push(replacement);
936
+ push(replacement);
937
+ }
938
+ if (newlinePending)
939
+ replaceNewline();
940
+ if (cdataStart >= 0 &&
941
+ out.length - CDATA_BARE_CLOSE.length >= cdataStart &&
942
+ endsWith(out, CDATA_BARE_CLOSE)) {
943
+ truncate(out.length - CDATA_BARE_CLOSE.length);
238
944
  }
945
+ if (softSpace >= 0 && softSpace === out.length - 1)
946
+ out.length = softSpace;
239
947
  // A break element can only come from a block tag (input is copied one character at a time),
240
948
  // and one inside a comment was truncated with it.
241
949
  const stripped = out.join('');
@@ -259,18 +967,58 @@ export function normalizeProviderText(text) {
259
967
  function isRecord(value) {
260
968
  return value !== null && typeof value === 'object' && !Array.isArray(value);
261
969
  }
262
- /** Normalize every string leaf of a JSON value; keys, numbers, booleans, and nulls pass through. */
263
- export function normalizeProviderValue(value) {
970
+ /**
971
+ * Keys holding identifiers and URLs — what a caller passes back to look a record up, filter on,
972
+ * or open. Their strings stay byte-identical to upstream: OpenAlex stores some DOIs with a
973
+ * literal `&amp;`, and only that spelling resolves. Named by the key holding the string or the
974
+ * array of strings, at any depth: these, and any key ending in `_id`, `_ids`, `_url`, or
975
+ * `_lineage`. No display-text field (`title`, `display_name`, `abstract`, `raw_*`,
976
+ * `host_organization_name`, `description`, `hint`, …) matches.
977
+ */
978
+ const IDENTIFIER_KEYS = new Set([
979
+ 'doi',
980
+ 'host_organization',
981
+ 'id',
982
+ 'issn',
983
+ 'issn_l',
984
+ 'lineage',
985
+ 'orcid',
986
+ 'parent_publisher',
987
+ 'raw_orcid',
988
+ 'referenced_works',
989
+ 'related_works',
990
+ 'ror',
991
+ 'url',
992
+ 'wikidata',
993
+ ]);
994
+ const IDENTIFIER_SUFFIX = /_(?:id|ids|url|lineage)$/;
995
+ /** Objects whose every value is an identifier or URL (`ids.doi`, `content_urls.pdf`). */
996
+ const IDENTIFIER_MAPS = new Set(['content_urls', 'ids']);
997
+ function isIdentifierKey(key) {
998
+ return IDENTIFIER_KEYS.has(key) || IDENTIFIER_SUFFIX.test(key);
999
+ }
1000
+ function normalizeLeaves(value, identifier) {
264
1001
  if (typeof value === 'string')
265
- return normalizeProviderText(value);
1002
+ return identifier ? value : normalizeProviderText(value);
266
1003
  if (Array.isArray(value))
267
- return value.map(normalizeProviderValue);
268
- if (isRecord(value)) {
269
- const next = {};
270
- for (const [k, v] of Object.entries(value))
271
- next[k] = normalizeProviderValue(v);
272
- return next;
273
- }
274
- return value;
1004
+ return value.map((item) => normalizeLeaves(item, identifier));
1005
+ if (!isRecord(value))
1006
+ return value;
1007
+ // `fromEntries` defines each key as an own data property; assigning an own `__proto__` key
1008
+ // onto an object literal would run the inherited setter and drop it. An object under an
1009
+ // identifier key is still walked by its own keys, so a `display_name` inside it is normalized.
1010
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
1011
+ key,
1012
+ IDENTIFIER_MAPS.has(key) && isRecord(item)
1013
+ ? item
1014
+ : normalizeLeaves(item, isIdentifierKey(key)),
1015
+ ]));
1016
+ }
1017
+ /**
1018
+ * Normalize every string leaf of a JSON value except identifiers and URLs, which pass through
1019
+ * byte-identical; keys, numbers, booleans, and nulls pass through too.
1020
+ */
1021
+ export function normalizeProviderValue(value) {
1022
+ return normalizeLeaves(value, false);
275
1023
  }
276
1024
  //# sourceMappingURL=provider-text.js.map