@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.
- package/AGENTS.md +48 -33
- package/CLAUDE.md +48 -33
- package/Dockerfile +98 -33
- package/README.md +23 -16
- package/changelog/0.8.x/0.8.0.md +38 -0
- package/changelog/0.9.x/0.9.0.md +48 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- 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 +28 -3
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js +57 -10
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +62 -5
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.js +100 -52
- 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 +38 -2
- package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js +176 -41
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts +75 -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 +169 -73
- 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/render-entity-record.d.ts +12 -1
- package/dist/mcp-server/tools/render-entity-record.d.ts.map +1 -1
- package/dist/mcp-server/tools/render-entity-record.js +23 -8
- package/dist/mcp-server/tools/render-entity-record.js.map +1 -1
- package/dist/mcp-server/tools/response-budget.d.ts +160 -0
- package/dist/mcp-server/tools/response-budget.d.ts.map +1 -0
- package/dist/mcp-server/tools/response-budget.js +489 -0
- package/dist/mcp-server/tools/response-budget.js.map +1 -0
- 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 +181 -16
- package/dist/services/openalex/openalex-service.d.ts.map +1 -1
- package/dist/services/openalex/openalex-service.js +593 -141
- package/dist/services/openalex/openalex-service.js.map +1 -1
- package/dist/services/openalex/provider-text.d.ts +13 -5
- package/dist/services/openalex/provider-text.d.ts.map +1 -1
- package/dist/services/openalex/provider-text.js +799 -51
- package/dist/services/openalex/provider-text.js.map +1 -1
- package/dist/services/openalex/types.d.ts +1 -1
- package/dist/services/openalex/types.js +1 -1
- package/package.json +16 -13
- 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
|
|
5
|
-
* a small allowlist of HTML/JATS/MathML elements
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
148
|
-
|
|
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
|
|
151
|
-
*
|
|
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(
|
|
154
|
-
const
|
|
155
|
-
if (
|
|
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 =
|
|
158
|
-
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();
|
|
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
|
-
|
|
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
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
922
|
+
if (top === undefined)
|
|
227
923
|
continue;
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
263
|
-
|
|
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 `&`, 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(
|
|
268
|
-
if (isRecord(value))
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|