@cyanheads/congressgov-mcp-server 0.5.0 → 0.5.1

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.
@@ -0,0 +1,689 @@
1
+ /**
2
+ * @fileoverview Incremental form of {@link extractDocumentText} — consumes a
3
+ * document body chunk by chunk and retains only the requested character window.
4
+ *
5
+ * `extract-text.ts` is the normative definition of the extraction: every
6
+ * character offset in the `content` contract indexes into the string it
7
+ * produces. This module reproduces that string exactly while holding constant
8
+ * memory, so a document is bounded by how long it takes to stream rather than by
9
+ * how much of it fits in a buffer. The two are pinned against each other by a
10
+ * differential test — if this file drifts, that test fails, not production.
11
+ *
12
+ * The whole-string pipeline is `normalize newlines → strip comments → unwrap
13
+ * <pre> → strip tags → decode entities → trim`, and three of those stages are
14
+ * decided by content that arrives arbitrarily late:
15
+ *
16
+ * - **Is there a `<pre>` at all?** Only a body with none is read whole. Both
17
+ * readings run at once, in two sinks; the losing one is dropped the moment a
18
+ * `<pre>` open tag settles the question.
19
+ * - **Where is the last `</pre>`?** Every close is a candidate, so each one
20
+ * records a mark on the `<pre>` sink and the last mark standing is restored at
21
+ * end of stream.
22
+ * - **Does this `<!--` (or this `<`) ever close?** An unterminated one is not a
23
+ * comment or a tag at all — the regexes leave it in the text. Its content is
24
+ * therefore written through as literal text and rolled back if the closing
25
+ * delimiter turns up.
26
+ *
27
+ * Every one of those rewinds is a mark: a handful of counters plus a length to
28
+ * truncate the captured window to. Nothing rewindable grows with the document.
29
+ *
30
+ * @module services/congress-documents/extract-text-stream
31
+ */
32
+ import { decodeCharacterReference } from './extract-text.js';
33
+ /**
34
+ * Whitespace as `String.prototype.trim` defines it — WhiteSpace plus
35
+ * LineTerminator, which is also exactly the `\s` character class.
36
+ */
37
+ function isWhitespace(ch) {
38
+ const code = ch.charCodeAt(0);
39
+ if (code === 0x20 || (code >= 0x09 && code <= 0x0d))
40
+ return true;
41
+ if (code < 0x80)
42
+ return false;
43
+ return (code === 0xa0 ||
44
+ code === 0x1680 ||
45
+ (code >= 0x2000 && code <= 0x200a) ||
46
+ code === 0x2028 ||
47
+ code === 0x2029 ||
48
+ code === 0x202f ||
49
+ code === 0x205f ||
50
+ code === 0x3000 ||
51
+ code === 0xfeff);
52
+ }
53
+ function isAsciiLetter(ch) {
54
+ return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z');
55
+ }
56
+ function isDigit(ch) {
57
+ return ch >= '0' && ch <= '9';
58
+ }
59
+ function isHexDigit(ch) {
60
+ return isDigit(ch) || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F');
61
+ }
62
+ /** Stages of a `&…;` candidate, mirroring the alternatives in `ENTITY_RE`. */
63
+ const ENTITY_NONE = 0;
64
+ const ENTITY_AMP = 1;
65
+ const ENTITY_NAMED = 2;
66
+ const ENTITY_HASH = 3;
67
+ const ENTITY_HEX_MARKER = 4;
68
+ const ENTITY_DEC = 5;
69
+ const ENTITY_HEX = 6;
70
+ /**
71
+ * The longest name any recognized entity has is four letters, so a longer run
72
+ * resolves to nothing and renders verbatim whether or not it is ever closed.
73
+ */
74
+ const MAX_ENTITY_NAME = 8;
75
+ /** The largest code point a numeric reference can name. */
76
+ const MAX_CODE_POINT = 0x10ffff;
77
+ /**
78
+ * One reading of the body: strips tags, decodes entities, trims, and keeps only
79
+ * the characters inside the requested window.
80
+ *
81
+ * Two of these run in parallel — one fed from the start of the body, one fed
82
+ * from just past a `<pre>` open tag — because tag and entity state differ
83
+ * between the two readings (an entity can span a tag the `<pre>` slice cuts
84
+ * away). Whichever reading the document turns out to be is the one that answers.
85
+ */
86
+ class WindowSink {
87
+ windowStart;
88
+ emitted = 0;
89
+ entityDigits = '';
90
+ entityHexMarker = '';
91
+ entityKind = ENTITY_NONE;
92
+ entityName = '';
93
+ entityZeros = 0;
94
+ inTag = false;
95
+ started = false;
96
+ tagMark = null;
97
+ trailingWs = 0;
98
+ windowChars = [];
99
+ windowEnd;
100
+ constructor(windowStart, windowLimit) {
101
+ this.windowStart = windowStart;
102
+ this.windowEnd = windowStart + windowLimit;
103
+ }
104
+ write(text) {
105
+ let i = 0;
106
+ while (i < text.length) {
107
+ if (this.started && !this.inTag && this.entityKind === ENTITY_NONE) {
108
+ let stop = i;
109
+ while (stop < text.length) {
110
+ const ch = text[stop];
111
+ if (ch === '<' || ch === '&' || isWhitespace(ch))
112
+ break;
113
+ stop++;
114
+ }
115
+ if (stop > i) {
116
+ this.emitRun(text, i, stop);
117
+ i = stop;
118
+ continue;
119
+ }
120
+ }
121
+ this.writeChar(text[i]);
122
+ i++;
123
+ }
124
+ }
125
+ mark() {
126
+ return {
127
+ emitted: this.emitted,
128
+ entityDigits: this.entityDigits,
129
+ entityHexMarker: this.entityHexMarker,
130
+ entityKind: this.entityKind,
131
+ entityName: this.entityName,
132
+ entityZeros: this.entityZeros,
133
+ inTag: this.inTag,
134
+ started: this.started,
135
+ tagMark: this.tagMark,
136
+ trailingWs: this.trailingWs,
137
+ windowLength: this.windowChars.length,
138
+ };
139
+ }
140
+ /**
141
+ * Keep a mark restorable across a rewind that would land beneath it.
142
+ *
143
+ * Captured window characters are dropped by truncation, so a mark can only be
144
+ * restored while nothing has rewound past it. A mark taken outside a tag is
145
+ * safe by construction — every later rewind targets a `<` or a `<!--` at or
146
+ * after it. One taken *inside* an open tag is not: that tag started earlier,
147
+ * and the `>` closing it rewinds all the way back there. Only such a mark pays
148
+ * for a copy of its window, taken while its characters are still intact.
149
+ */
150
+ protect(mark) {
151
+ if (!mark.inTag)
152
+ return mark;
153
+ return { ...mark, windowCopy: this.windowChars.slice(0, mark.windowLength) };
154
+ }
155
+ /** A mark that survives whatever the characters after it do. */
156
+ protectedMark() {
157
+ return this.protect(this.mark());
158
+ }
159
+ restore(mark) {
160
+ this.emitted = mark.emitted;
161
+ this.entityDigits = mark.entityDigits;
162
+ this.entityHexMarker = mark.entityHexMarker;
163
+ this.entityKind = mark.entityKind;
164
+ this.entityName = mark.entityName;
165
+ this.entityZeros = mark.entityZeros;
166
+ this.inTag = mark.inTag;
167
+ this.started = mark.started;
168
+ this.tagMark = mark.tagMark;
169
+ this.trailingWs = mark.trailingWs;
170
+ this.windowChars.length = mark.windowLength;
171
+ if (mark.windowCopy !== undefined) {
172
+ for (let i = 0; i < mark.windowCopy.length; i++) {
173
+ this.windowChars[i] = mark.windowCopy[i];
174
+ }
175
+ }
176
+ }
177
+ /**
178
+ * An open tag or entity candidate still pending at end of stream never closed,
179
+ * so it is text, not markup — the same reading the regexes give it.
180
+ */
181
+ finish() {
182
+ this.flushEntityVerbatim();
183
+ const totalCharacters = this.emitted - this.trailingWs;
184
+ const overshoot = this.windowStart + this.windowChars.length - totalCharacters;
185
+ const take = overshoot > 0 ? this.windowChars.length - overshoot : this.windowChars.length;
186
+ return {
187
+ text: take > 0 ? this.windowChars.slice(0, take).join('') : '',
188
+ totalCharacters,
189
+ };
190
+ }
191
+ writeChar(ch) {
192
+ if (this.inTag) {
193
+ /** `<[^>]*>` ends at the first `>`; everything it spanned was markup. */
194
+ if (ch === '>') {
195
+ this.restore(this.tagMark);
196
+ return;
197
+ }
198
+ this.literal(ch);
199
+ return;
200
+ }
201
+ if (ch === '<') {
202
+ this.tagMark = this.mark();
203
+ this.inTag = true;
204
+ this.literal(ch);
205
+ return;
206
+ }
207
+ this.literal(ch);
208
+ }
209
+ /** Feed one character to the entity decoder. */
210
+ literal(ch) {
211
+ if (this.entityKind !== ENTITY_NONE && this.stepEntity(ch))
212
+ return;
213
+ if (ch === '&') {
214
+ this.entityKind = ENTITY_AMP;
215
+ return;
216
+ }
217
+ this.emitChar(ch);
218
+ }
219
+ /** Advance the entity candidate. Returns false when `ch` still needs handling. */
220
+ stepEntity(ch) {
221
+ switch (this.entityKind) {
222
+ case ENTITY_AMP:
223
+ if (ch === '#') {
224
+ this.entityKind = ENTITY_HASH;
225
+ return true;
226
+ }
227
+ if (isAsciiLetter(ch)) {
228
+ this.entityKind = ENTITY_NAMED;
229
+ this.entityName = ch;
230
+ return true;
231
+ }
232
+ break;
233
+ case ENTITY_NAMED:
234
+ if (isAsciiLetter(ch)) {
235
+ this.entityName += ch;
236
+ if (this.entityName.length > MAX_ENTITY_NAME)
237
+ this.flushEntityVerbatim();
238
+ return true;
239
+ }
240
+ if (ch === ';') {
241
+ this.completeEntity(this.entityName);
242
+ return true;
243
+ }
244
+ break;
245
+ case ENTITY_HASH:
246
+ if (ch === 'x' || ch === 'X') {
247
+ this.entityKind = ENTITY_HEX_MARKER;
248
+ this.entityHexMarker = ch;
249
+ return true;
250
+ }
251
+ if (isDigit(ch)) {
252
+ this.entityKind = ENTITY_DEC;
253
+ this.addEntityDigit(ch, false);
254
+ return true;
255
+ }
256
+ break;
257
+ case ENTITY_HEX_MARKER:
258
+ if (isHexDigit(ch)) {
259
+ this.entityKind = ENTITY_HEX;
260
+ this.addEntityDigit(ch, true);
261
+ return true;
262
+ }
263
+ break;
264
+ case ENTITY_DEC:
265
+ if (isDigit(ch)) {
266
+ this.addEntityDigit(ch, false);
267
+ return true;
268
+ }
269
+ if (ch === ';') {
270
+ this.completeEntity(`#${this.significantDigits()}`);
271
+ return true;
272
+ }
273
+ break;
274
+ case ENTITY_HEX:
275
+ if (isHexDigit(ch)) {
276
+ this.addEntityDigit(ch, true);
277
+ return true;
278
+ }
279
+ if (ch === ';') {
280
+ this.completeEntity(`#${this.entityHexMarker}${this.significantDigits()}`);
281
+ return true;
282
+ }
283
+ break;
284
+ }
285
+ this.flushEntityVerbatim();
286
+ return false;
287
+ }
288
+ /**
289
+ * Leading zeros are counted rather than stored, so a reference padded to any
290
+ * width still resolves without buffering it. Past the largest code point the
291
+ * value can only climb, so the reference is already text.
292
+ */
293
+ addEntityDigit(ch, hex) {
294
+ if (this.entityDigits === '' && ch === '0') {
295
+ this.entityZeros++;
296
+ return;
297
+ }
298
+ this.entityDigits += ch;
299
+ if (Number.parseInt(this.entityDigits, hex ? 16 : 10) > MAX_CODE_POINT) {
300
+ this.flushEntityVerbatim();
301
+ }
302
+ }
303
+ significantDigits() {
304
+ return this.entityDigits === '' ? '0' : this.entityDigits;
305
+ }
306
+ completeEntity(ref) {
307
+ const decoded = decodeCharacterReference(ref);
308
+ if (decoded === undefined) {
309
+ this.flushEntityVerbatim();
310
+ this.emitChar(';');
311
+ return;
312
+ }
313
+ this.entityKind = ENTITY_NONE;
314
+ this.entityName = '';
315
+ this.entityDigits = '';
316
+ this.entityHexMarker = '';
317
+ this.entityZeros = 0;
318
+ this.emit(decoded);
319
+ }
320
+ /** Render the pending candidate as the literal characters it was built from. */
321
+ flushEntityVerbatim() {
322
+ const kind = this.entityKind;
323
+ if (kind === ENTITY_NONE)
324
+ return;
325
+ const name = this.entityName;
326
+ const digits = this.entityDigits;
327
+ const hexMarker = this.entityHexMarker;
328
+ const zeros = this.entityZeros;
329
+ this.entityKind = ENTITY_NONE;
330
+ this.entityName = '';
331
+ this.entityDigits = '';
332
+ this.entityHexMarker = '';
333
+ this.entityZeros = 0;
334
+ this.emitChar('&');
335
+ if (kind === ENTITY_NAMED) {
336
+ this.emit(name);
337
+ return;
338
+ }
339
+ if (kind === ENTITY_AMP)
340
+ return;
341
+ this.emitChar('#');
342
+ if (kind === ENTITY_HEX_MARKER || kind === ENTITY_HEX)
343
+ this.emitChar(hexMarker);
344
+ for (let i = 0; i < zeros; i++)
345
+ this.emitChar('0');
346
+ if (digits !== '')
347
+ this.emit(digits);
348
+ }
349
+ emit(text) {
350
+ for (let i = 0; i < text.length; i++)
351
+ this.emitChar(text[i]);
352
+ }
353
+ emitChar(ch) {
354
+ if (!this.started) {
355
+ if (isWhitespace(ch))
356
+ return;
357
+ this.started = true;
358
+ }
359
+ const index = this.emitted;
360
+ this.emitted = index + 1;
361
+ if (isWhitespace(ch))
362
+ this.trailingWs++;
363
+ else
364
+ this.trailingWs = 0;
365
+ if (index >= this.windowStart && index < this.windowEnd)
366
+ this.windowChars.push(ch);
367
+ }
368
+ /** Bulk path for a run with no markup, no entity, and no whitespace in it. */
369
+ emitRun(text, from, to) {
370
+ const start = this.emitted;
371
+ this.emitted = start + (to - from);
372
+ this.trailingWs = 0;
373
+ if (start >= this.windowEnd || this.emitted <= this.windowStart)
374
+ return;
375
+ const sliceFrom = Math.max(from, from + (this.windowStart - start));
376
+ const sliceTo = Math.min(to, from + (this.windowEnd - start));
377
+ for (let i = sliceFrom; i < sliceTo; i++)
378
+ this.windowChars.push(text[i]);
379
+ }
380
+ }
381
+ /** `<\s*pre(?:\s[^>]*)?\/?>` as a state machine. `-1` is "already matched". */
382
+ const OPEN_DONE = -1;
383
+ const OPEN_IDLE = 0;
384
+ const OPEN_AFTER_LT = 1;
385
+ const OPEN_P = 2;
386
+ const OPEN_PR = 3;
387
+ const OPEN_PRE = 4;
388
+ const OPEN_SLASH = 5;
389
+ const OPEN_ATTRS = 6;
390
+ /** `<\s*\/\s*pre\s*>` as a state machine. */
391
+ const CLOSE_IDLE = 0;
392
+ const CLOSE_AFTER_LT = 1;
393
+ const CLOSE_SLASH = 2;
394
+ const CLOSE_P = 3;
395
+ const CLOSE_PR = 4;
396
+ const CLOSE_PRE = 5;
397
+ class DocumentTextStream {
398
+ carry = '';
399
+ checkpoint = null;
400
+ closeMark = null;
401
+ closeStart = 0;
402
+ closeState = CLOSE_IDLE;
403
+ inComment = false;
404
+ lastCloseMark = null;
405
+ noPre;
406
+ openState = OPEN_IDLE;
407
+ pendingCr = false;
408
+ pendingMark = null;
409
+ pendingMarkPosition = 0;
410
+ position = 0;
411
+ pre = null;
412
+ preDecided = false;
413
+ preStart = 0;
414
+ windowLimit;
415
+ windowStart;
416
+ constructor(request) {
417
+ this.windowStart = request.characterOffset;
418
+ this.windowLimit = request.characterLimit;
419
+ this.noPre = new WindowSink(this.windowStart, this.windowLimit);
420
+ }
421
+ push(chunk) {
422
+ if (chunk !== '')
423
+ this.consume(this.normalizeNewlines(chunk));
424
+ }
425
+ finish() {
426
+ if (this.pendingCr) {
427
+ this.pendingCr = false;
428
+ this.consume('\n');
429
+ }
430
+ if (this.carry !== '') {
431
+ const rest = this.carry;
432
+ this.carry = '';
433
+ this.feed(rest);
434
+ }
435
+ if (this.pre !== null && this.lastCloseMark !== null)
436
+ this.pre.restore(this.lastCloseMark);
437
+ return (this.pre ?? this.noPre).finish();
438
+ }
439
+ /** `\r\n?` → `\n`, holding a trailing `\r` until the next chunk resolves it. */
440
+ normalizeNewlines(chunk) {
441
+ let text = chunk;
442
+ if (this.pendingCr) {
443
+ this.pendingCr = false;
444
+ text = `\n${text.startsWith('\n') ? text.slice(1) : text}`;
445
+ }
446
+ if (text.endsWith('\r')) {
447
+ this.pendingCr = true;
448
+ text = text.slice(0, -1);
449
+ }
450
+ return text.replace(/\r\n?/g, '\n');
451
+ }
452
+ /**
453
+ * `<!--[\s\S]*?-->` removal. A comment's content is written through as literal
454
+ * text and rolled back once `-->` arrives, because an unterminated `<!--` is
455
+ * not a comment — the regex matches nothing and leaves it in the document.
456
+ */
457
+ consume(chunk) {
458
+ const text = this.carry + chunk;
459
+ this.carry = '';
460
+ let i = 0;
461
+ while (i < text.length) {
462
+ if (this.inComment) {
463
+ const end = text.indexOf('-->', i);
464
+ if (end === -1) {
465
+ const safe = Math.max(i, text.length - 2);
466
+ this.feed(text.slice(i, safe));
467
+ this.carry = text.slice(safe);
468
+ return;
469
+ }
470
+ this.feed(text.slice(i, end + 3));
471
+ this.restoreCheckpoint();
472
+ this.inComment = false;
473
+ i = end + 3;
474
+ continue;
475
+ }
476
+ const at = text.indexOf('<!--', i);
477
+ if (at === -1) {
478
+ const safe = Math.max(i, text.length - 3);
479
+ this.feed(text.slice(i, safe));
480
+ this.carry = text.slice(safe);
481
+ return;
482
+ }
483
+ this.feed(text.slice(i, at));
484
+ this.checkpoint = this.captureCheckpoint();
485
+ this.inComment = true;
486
+ /** `[\s\S]*?` may match nothing, but never the opener's own dashes. */
487
+ this.feed(text.slice(at, at + 4));
488
+ i = at + 4;
489
+ }
490
+ }
491
+ /** Drive the sinks and the `<pre>` scanners over comment-stripped text. */
492
+ feed(text) {
493
+ let i = 0;
494
+ while (i < text.length) {
495
+ if (this.openState <= OPEN_IDLE && this.closeState === CLOSE_IDLE) {
496
+ const next = text.indexOf('<', i);
497
+ const stop = next === -1 ? text.length : next;
498
+ if (stop > i) {
499
+ this.writeSinks(text.slice(i, stop));
500
+ this.position += stop - i;
501
+ i = stop;
502
+ continue;
503
+ }
504
+ }
505
+ const ch = text[i];
506
+ /**
507
+ * The `<pre>` sink is read at three different moments in one character:
508
+ * a close candidate marks it before the `<` lands, a confirmed close
509
+ * protects it before the `>` lands (closing that `>` can rewind the sink),
510
+ * and an opening tag creates it only after the `>` has gone by.
511
+ */
512
+ if (this.pre !== null && ch === '<') {
513
+ this.pendingMark = this.pre.mark();
514
+ this.pendingMarkPosition = this.position;
515
+ }
516
+ this.stepPreClose(ch);
517
+ this.writeSinks(ch);
518
+ this.stepPreOpen(ch);
519
+ this.position++;
520
+ i++;
521
+ }
522
+ }
523
+ writeSinks(text) {
524
+ if (!this.preDecided)
525
+ this.noPre.write(text);
526
+ this.pre?.write(text);
527
+ }
528
+ /**
529
+ * The leftmost `<pre>` opens the slice the rest of the pipeline reads. Until
530
+ * one turns up the document may have none at all, so the whole-body reading
531
+ * runs alongside; a match outside a comment settles it and retires that sink.
532
+ */
533
+ stepPreOpen(ch) {
534
+ switch (this.openState) {
535
+ case OPEN_DONE:
536
+ return;
537
+ case OPEN_IDLE:
538
+ this.openState = ch === '<' ? OPEN_AFTER_LT : OPEN_IDLE;
539
+ return;
540
+ case OPEN_AFTER_LT:
541
+ if (isWhitespace(ch))
542
+ return;
543
+ if (ch === 'p' || ch === 'P')
544
+ this.openState = OPEN_P;
545
+ else
546
+ this.openState = ch === '<' ? OPEN_AFTER_LT : OPEN_IDLE;
547
+ return;
548
+ case OPEN_P:
549
+ this.openState = ch === 'r' || ch === 'R' ? OPEN_PR : this.restartOpen(ch);
550
+ return;
551
+ case OPEN_PR:
552
+ this.openState = ch === 'e' || ch === 'E' ? OPEN_PRE : this.restartOpen(ch);
553
+ return;
554
+ case OPEN_PRE:
555
+ if (ch === '>')
556
+ this.openPre();
557
+ else if (ch === '/')
558
+ this.openState = OPEN_SLASH;
559
+ else if (isWhitespace(ch))
560
+ this.openState = OPEN_ATTRS;
561
+ else
562
+ this.openState = this.restartOpen(ch);
563
+ return;
564
+ case OPEN_SLASH:
565
+ if (ch === '>')
566
+ this.openPre();
567
+ else
568
+ this.openState = this.restartOpen(ch);
569
+ return;
570
+ default:
571
+ /** `[^>]*` swallows everything, `<` included, up to the closing `>`. */
572
+ if (ch === '>')
573
+ this.openPre();
574
+ return;
575
+ }
576
+ }
577
+ restartOpen(ch) {
578
+ return ch === '<' ? OPEN_AFTER_LT : OPEN_IDLE;
579
+ }
580
+ openPre() {
581
+ this.openState = OPEN_DONE;
582
+ this.preStart = this.position + 1;
583
+ this.pre = new WindowSink(this.windowStart, this.windowLimit);
584
+ if (!this.inComment)
585
+ this.preDecided = true;
586
+ }
587
+ /**
588
+ * Every `</pre>` past the opening tag is a candidate for the last one, so each
589
+ * records where the `<pre>` sink stood before it. End of stream restores the
590
+ * survivor, which is exactly the slice ending at the last `</pre>`.
591
+ */
592
+ stepPreClose(ch) {
593
+ if (this.pre === null)
594
+ return;
595
+ switch (this.closeState) {
596
+ case CLOSE_IDLE:
597
+ if (ch === '<')
598
+ this.startCloseCandidate();
599
+ return;
600
+ case CLOSE_AFTER_LT:
601
+ if (isWhitespace(ch))
602
+ return;
603
+ if (ch === '/')
604
+ this.closeState = CLOSE_SLASH;
605
+ else
606
+ this.restartClose(ch);
607
+ return;
608
+ case CLOSE_SLASH:
609
+ if (isWhitespace(ch))
610
+ return;
611
+ if (ch === 'p' || ch === 'P')
612
+ this.closeState = CLOSE_P;
613
+ else
614
+ this.restartClose(ch);
615
+ return;
616
+ case CLOSE_P:
617
+ if (ch === 'r' || ch === 'R')
618
+ this.closeState = CLOSE_PR;
619
+ else
620
+ this.restartClose(ch);
621
+ return;
622
+ case CLOSE_PR:
623
+ if (ch === 'e' || ch === 'E')
624
+ this.closeState = CLOSE_PRE;
625
+ else
626
+ this.restartClose(ch);
627
+ return;
628
+ default:
629
+ if (isWhitespace(ch))
630
+ return;
631
+ if (ch === '>') {
632
+ if (this.closeStart > this.preStart && this.closeMark !== null) {
633
+ this.lastCloseMark = this.pre.protect(this.closeMark);
634
+ }
635
+ this.closeState = CLOSE_IDLE;
636
+ return;
637
+ }
638
+ this.restartClose(ch);
639
+ }
640
+ }
641
+ startCloseCandidate() {
642
+ this.closeState = CLOSE_AFTER_LT;
643
+ this.closeMark = this.pendingMark;
644
+ this.closeStart = this.pendingMarkPosition;
645
+ }
646
+ restartClose(ch) {
647
+ if (ch === '<')
648
+ this.startCloseCandidate();
649
+ else
650
+ this.closeState = CLOSE_IDLE;
651
+ }
652
+ captureCheckpoint() {
653
+ return {
654
+ closeMark: this.closeMark,
655
+ closeStart: this.closeStart,
656
+ closeState: this.closeState,
657
+ lastCloseMark: this.lastCloseMark,
658
+ noPreMark: this.noPre.protectedMark(),
659
+ openState: this.openState,
660
+ position: this.position,
661
+ preMark: this.pre?.protectedMark() ?? null,
662
+ preStart: this.preStart,
663
+ };
664
+ }
665
+ restoreCheckpoint() {
666
+ const saved = this.checkpoint;
667
+ this.checkpoint = null;
668
+ this.noPre.restore(saved.noPreMark);
669
+ if (saved.preMark === null)
670
+ this.pre = null;
671
+ else
672
+ this.pre?.restore(saved.preMark);
673
+ this.closeMark = saved.closeMark;
674
+ this.closeStart = saved.closeStart;
675
+ this.closeState = saved.closeState;
676
+ this.lastCloseMark = saved.lastCloseMark;
677
+ this.openState = saved.openState;
678
+ this.position = saved.position;
679
+ this.preStart = saved.preStart;
680
+ }
681
+ }
682
+ /**
683
+ * Create an extractor that reproduces {@link extractDocumentText} over a chunked
684
+ * body while retaining only `characterLimit` characters of it.
685
+ */
686
+ export function createStreamingExtractor(request) {
687
+ return new DocumentTextStream(request);
688
+ }
689
+ //# sourceMappingURL=extract-text-stream.js.map