@ai-react-markdown/engine 2.4.3 → 2.4.5

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/README.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @ai-react-markdown/engine
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@ai-react-markdown/engine?logo=npm&color=cb3837)](https://www.npmjs.com/package/@ai-react-markdown/engine)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@ai-react-markdown/engine?color=blue)](https://www.npmjs.com/package/@ai-react-markdown/engine)
5
+ [![minzipped size](https://img.shields.io/bundlephobia/minzip/@ai-react-markdown/engine?label=minzip)](https://bundlephobia.com/package/@ai-react-markdown/engine)
6
+ [![types](https://img.shields.io/npm/types/@ai-react-markdown/engine?logo=typescript&logoColor=white&color=3178c6)](https://www.typescriptlang.org/)
7
+
8
+ [![Node ≥20](https://img.shields.io/badge/Node-%E2%89%A520-339933?logo=nodedotjs&logoColor=white)](https://nodejs.org/)
9
+ [![ESM + CJS](https://img.shields.io/badge/module-ESM%20%2B%20CJS-f7df1e?logo=javascript&logoColor=black)](#installation)
10
+ [![license](https://img.shields.io/npm/l/@ai-react-markdown/engine?color=green)](https://github.com/AIEPhoenix/ai-react-markdown/blob/main/LICENSE)
11
+
12
+ [![CI](https://img.shields.io/github/actions/workflow/status/AIEPhoenix/ai-react-markdown/ci.yml?branch=main&label=CI&logo=githubactions&logoColor=white)](https://github.com/AIEPhoenix/ai-react-markdown/actions/workflows/ci.yml)
13
+ [![Release](https://img.shields.io/github/actions/workflow/status/AIEPhoenix/ai-react-markdown/release.yml?label=release&logo=githubactions&logoColor=white)](https://github.com/AIEPhoenix/ai-react-markdown/actions/workflows/release.yml)
14
+ [![part of ai-react-markdown](https://img.shields.io/badge/monorepo-ai--react--markdown-8a2be2?logo=github)](https://github.com/AIEPhoenix/ai-react-markdown)
15
+
3
16
  Framework-agnostic Markdown engine for [ai-react-markdown](https://github.com/AIEPhoenix/ai-react-markdown) — incremental parsing, LaTeX preprocessing, definition/footnote machinery, and the unified plugin pipeline. Takes Markdown text in, produces a [hast](https://github.com/syntax-tree/hast) tree plus incremental-parse state out; rendering that tree is the job of a framework adapter such as [`@ai-react-markdown/core`](https://www.npmjs.com/package/@ai-react-markdown/core) (React).
4
17
 
5
18
  > **Status: internal supplier.** This package exists to serve
@@ -10,12 +23,94 @@ Framework-agnostic Markdown engine for [ai-react-markdown](https://github.com/AI
10
23
  > instead; this package is interesting to you only if you are building a
11
24
  > framework adapter of your own.
12
25
 
26
+ ## What's inside
27
+
28
+ Everything is exported from the package root (`import { … } from '@ai-react-markdown/engine'`); the barrel is grouped by layer:
29
+
30
+ | Layer | Modules | Highlights |
31
+ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
32
+ | Preprocessors | `preprocessors/latex`, `preprocessors/remend`, `preprocessAIMDContent` | `preprocessLaTeX(text)` (currency `$`, `\[…\]` / `\(…\)` normalization, code-fence and inline-code protection), `createIncrementalLatexPreprocessor()` for append-only streams, `remend` for unterminated-markup mending |
33
+ | Incremental parsing | `incrementalParse/*` | `advanceIncrementalParse(state, content, options)` — the prefix-freeze engine: a line scanner decides a verified-safe freeze boundary, only the tail re-parses, and the two trees are spliced; every frame is deep-equal to a full parse (enforced by the arbiter suites) or falls back to one |
34
+ | Pipeline assembly | `markdown/*`, `pluginChain`, `plugins/catalog`, `customMdastHandlers`, `remarkInjectPhantomDefs`, `rehypeRebaseHashLinks`, `rehypeFooterAdorn` | `buildCoreRemarkPlugins` / `buildCoreRehypePlugins` / `buildCoreRemarkRehypeOptions` — the exact chains the React renderer uses; the sealed engine-plugin catalog (`highlight`, `definitionList`, `removeComments`, `smartypants`, `pangu`, `defaultEnginePlugins`) |
35
+ | Cross-chunk coordination | `documentRegistry`, `collectDefLabels`, `extractContributions`, `extractDefBodiesFromHast`, `crossChunkUrlSanitize` | `createRegistry()` — the per-document store that numbers footnotes and resolves link definitions across chunks; `sanitizeCrossChunkUrl()` mirrors the standalone two-gate URL policy |
36
+ | Sanitization | `sanitizeSchema`, `extendSanitizeSchema`, `markdown/urlTransform` | The library default `rehype-sanitize` schema (read-only singleton — clone with `extendSanitizeSchema`), `defaultUrlTransform` |
37
+ | Streaming | `smoothStream/controller` | `createSmoothStreamController()` — the framework-agnostic typewriter pacing state machine behind `<AIMarkdownSmoothStream>`, with `SMOOTH_STREAM_PACING_PRESETS` |
38
+ | Leaves | `hastPredicates`, `normalizeId`, `shortenDocumentId`, `devStageTimings`, `fixtures/scenarios` | Small pure helpers and the shared test corpus |
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ npm install @ai-react-markdown/engine
44
+ ```
45
+
46
+ Dual ESM/CJS build with types for both. No React dependency. The only peer is `katex` (`^0.16 || ^0.17`, **optional** — needed only if you render math). It ships transitively via `rehype-katex`, so hoisted installers resolve it automatically; strict-isolation installers (yarn PnP, `pnpm --node-linker=isolated`) must install it explicitly in your app.
47
+
48
+ ## Example: the LaTeX preprocessor on its own
49
+
50
+ ```ts
51
+ import { preprocessLaTeX } from '@ai-react-markdown/engine';
52
+
53
+ preprocessLaTeX('Price is $100, and \\(x^2\\) is inline math.');
54
+ // → 'Price is \\$100, and $$x^2$$ is inline math.'
55
+ // (currency `$` escaped; `\\(…\\)` normalized to the `$$…$$` form remark-math's inline rule accepts)
56
+ ```
57
+
58
+ The same function runs inside `@ai-react-markdown/core` before every parse; the incremental variant (`createIncrementalLatexPreprocessor`) reuses work across append-only frames.
59
+
60
+ ## Example: driving the incremental parser
61
+
62
+ ```ts
63
+ import {
64
+ advanceIncrementalParse,
65
+ buildCoreRemarkPlugins,
66
+ buildCoreRehypePlugins,
67
+ buildCoreRemarkRehypeOptions,
68
+ defaultEnginePlugins,
69
+ sanitizeSchema,
70
+ } from '@ai-react-markdown/engine';
71
+
72
+ const options = {
73
+ remarkPlugins: buildCoreRemarkPlugins(defaultEnginePlugins),
74
+ rehypePlugins: buildCoreRehypePlugins(sanitizeSchema, ''),
75
+ remarkRehypeOptions: buildCoreRemarkRehypeOptions(false),
76
+ depsKey: [],
77
+ defListEnabled: false,
78
+ };
79
+
80
+ let state = null;
81
+ for (const frame of ['# Hello', '# Hello\n\nworld', '# Hello\n\nworld and more']) {
82
+ const result = advanceIncrementalParse(state, frame, options);
83
+ state = result.nextState;
84
+ // result.hast — the full-document hast for this frame
85
+ // result.usedIncremental / result.boundary — whether the frame spliced, and where
86
+ }
87
+ ```
88
+
89
+ `AdvanceOptions` is documented in `incrementalParse/advanceIncrementalParse.ts`; the React renderer's `MarkdownContent` is the reference consumer.
90
+
91
+ ## Verification
92
+
93
+ The incremental engine ships with a five-layer equivalence stack (fixture pins, fuzz arbiter, direction battery, exhaustive census, arbiter-sensitivity meta-suite) plus a release-gate soak (`scripts/run-soak.sh`); the full record lives in `src/experiments/prefixFreeze/README.md`. Every reachable divergence found so far is pinned as a deterministic test.
94
+
13
95
  ## Runtime support
14
96
 
15
97
  Pure computation over strings and syntax trees: no DOM access, no
16
98
  Node-only APIs, and no unguarded environment reads. Runs in browsers,
17
99
  Node, workers, and embedded JS runtimes (e.g. Hermes/JavaScriptCore).
18
100
 
101
+ ## Versioning
102
+
103
+ Lockstep with `@ai-react-markdown/core`, which pins this package **exactly** — the export surface follows what core consumes and may change in any release before 3.0.0 (see the status note above). Release notes: [release highlights](https://github.com/AIEPhoenix/ai-react-markdown/blob/main/docs/release-highlights.md).
104
+
105
+ ## Package family
106
+
107
+ | Package | Role | Version policy |
108
+ | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
109
+ | [`@ai-react-markdown/core`](https://www.npmjs.com/package/@ai-react-markdown/core) | The React renderer — `<AIMarkdown>`, `<AIMarkdownSmoothStream>`, `<AIMarkdownDocuments>`, hooks, providers | Release train |
110
+ | [`@ai-react-markdown/mantine`](https://www.npmjs.com/package/@ai-react-markdown/mantine) | Mantine UI bindings — themed typography, code-highlight tabs, Mermaid, color-scheme wiring | Release train (lockstep with core) |
111
+ | [`@ai-react-markdown/engine`](https://www.npmjs.com/package/@ai-react-markdown/engine) | Framework-agnostic engine — incremental parsing, LaTeX preprocessing, plugin pipeline, cross-chunk registry | Release train (lockstep, pinned exactly by core; internal supplier) |
112
+ | [`@ai-react-markdown/remark-mark-highlight`](https://www.npmjs.com/package/@ai-react-markdown/remark-mark-highlight) | remark plugin for `==mark==` highlight syntax | Independent semver |
113
+
19
114
  ## License
20
115
 
21
116
  MIT
package/dist/index.cjs CHANGED
@@ -269,6 +269,11 @@ var defaultUrlTransform = (value) => {
269
269
  var import_micromark_util_html_tag_name = require("micromark-util-html-tag-name");
270
270
  var import_micromark_util_normalize_identifier = require("micromark-util-normalize-identifier");
271
271
  var TYPE6_NAMES = new Set(import_micromark_util_html_tag_name.htmlBlockNames);
272
+ var TABLE_PART_NAMES = /* @__PURE__ */ new Set(["td", "th", "tr", "tbody", "thead", "tfoot", "caption", "col", "colgroup"]);
273
+ var TYPE1_NAMES = /* @__PURE__ */ new Set(["script", "pre", "style", "textarea"]);
274
+ var TYPE6_START_RE = /^<\/?([A-Za-z][A-Za-z0-9-]*)(?:[ \t\r]|\/?>|$)/;
275
+ var TYPE1_START_RE = /^<(script|pre|style|textarea)(?:[ \t\r]|>|$)/i;
276
+ var TYPE7_LINE_RE = /^<(\/?)([A-Za-z][A-Za-z0-9-]*)(?:[ \t\r][^>]*|\/)?>[ \t\r]*$/;
272
277
  var VOID_TAGS = /* @__PURE__ */ new Set([
273
278
  "area",
274
279
  "base",
@@ -407,7 +412,11 @@ function freshCheckpoint(defListEnabled, mathFlow, referenceTaint) {
407
412
  htmlFlowSinceBlank: false,
408
413
  htmlSeamPending: false,
409
414
  phasePoisonedAt: Infinity,
410
- pendingTruncatedTags: []
415
+ pendingTruncatedTags: [],
416
+ pendingTruncatedCloses: [],
417
+ tagAcrossLines: false,
418
+ tagAcrossLinesIndent: 0,
419
+ htmlFlowReal: false
411
420
  };
412
421
  }
413
422
  function isPlausibleLinkDefRest(rest) {
@@ -704,6 +713,8 @@ function processConfirmedLine(cp, ln, text) {
704
713
  for (const tag of cp.pendingTruncatedTags) applyTag(tag, true);
705
714
  cp.pendingTruncatedTags = [];
706
715
  }
716
+ cp.pendingTruncatedCloses = [];
717
+ cp.tagAcrossLines = false;
707
718
  cp.blankRun += 1;
708
719
  cp.lastBlankStart = ln.start;
709
720
  cp.candidates.push({
@@ -717,6 +728,7 @@ function processConfirmedLine(cp, ln, text) {
717
728
  cp.paragraphHasUnpairedRun = false;
718
729
  cp.openBracket = null;
719
730
  cp.htmlFlowSinceBlank = false;
731
+ cp.htmlFlowReal = false;
720
732
  cp.prevLineBlank = true;
721
733
  cp.prevLineWasText = false;
722
734
  cp.prevLineWasValidDef = false;
@@ -732,6 +744,16 @@ function processConfirmedLine(cp, ln, text) {
732
744
  if (tagStart) {
733
745
  cp.htmlFlowSinceBlank = true;
734
746
  if (!TYPE6_NAMES.has(tagStart[1].toLowerCase())) cp.hazardVerdict = true;
747
+ if (!cp.htmlFlowReal) {
748
+ const t = mdTrimStart(ln.text);
749
+ const t6 = TYPE6_START_RE.exec(t);
750
+ const t7 = TYPE7_LINE_RE.exec(t);
751
+ if (t6 !== null && TYPE6_NAMES.has(t6[1].toLowerCase()) || TYPE1_START_RE.test(t) || // Type 7 cannot interrupt a paragraph, and excludes the raw-text
752
+ // names (those are type 1 as start tags, paragraph as end tags).
753
+ t7 !== null && !cp.prevLineWasText && !TYPE1_NAMES.has(t7[2].toLowerCase())) {
754
+ cp.htmlFlowReal = true;
755
+ }
756
+ }
735
757
  }
736
758
  const inRawText = cp.htmlFlowSinceBlank || rawOpenAtLineStart;
737
759
  const rawFlowStart = ln.indent <= 3 && /^<(?:!--|\?|![A-Za-z]|!\[CDATA\[)/.test(mdTrimStart(ln.text));
@@ -872,7 +894,21 @@ ${cont(scanText)}` };
872
894
  for (const [from, to] of rawSpans) {
873
895
  tagText = tagText.slice(0, from) + " ".repeat(to - from) + tagText.slice(to);
874
896
  }
875
- {
897
+ let skipTagScan = false;
898
+ if (cp.tagAcrossLines) {
899
+ if (ln.indent < cp.tagAcrossLinesIndent) poisonRawDivergence();
900
+ const gt = ln.text.indexOf(">");
901
+ if (gt === -1) {
902
+ skipTagScan = true;
903
+ } else {
904
+ if (/["']/.test(ln.text.slice(0, gt))) poisonRawDivergence();
905
+ for (const tag of cp.pendingTruncatedCloses) applyTag(tag, true);
906
+ cp.pendingTruncatedCloses = [];
907
+ cp.tagAcrossLines = false;
908
+ tagText = " ".repeat(gt + 1) + tagText.slice(gt + 1);
909
+ }
910
+ }
911
+ if (!skipTagScan) {
876
912
  TAG_OR_COMMENT_RE.lastIndex = 0;
877
913
  let m;
878
914
  let lastCommentOpenerIdx = -1;
@@ -902,6 +938,7 @@ ${cont(scanText)}` };
902
938
  if (cp.commentOpen) continue;
903
939
  const closing = m[1] === "/";
904
940
  const tag = m[2].toLowerCase();
941
+ if (TABLE_PART_NAMES.has(tag)) cp.phasePoisonedAt = Math.min(cp.phasePoisonedAt, ln.start + m.index);
905
942
  const selfClosing = m[3] !== void 0 && /\/\s*$/.test(m[3]);
906
943
  if (VOID_TAGS.has(tag) || selfClosing) continue;
907
944
  applyTag(tag, closing);
@@ -922,6 +959,7 @@ ${cont(scanText)}` };
922
959
  if (startMasked || wholeVisible || inRaw(mr.index) || cp.commentOpen) continue;
923
960
  const closing = mr[1] === "/";
924
961
  const tag = mr[2].toLowerCase();
962
+ if (TABLE_PART_NAMES.has(tag)) cp.phasePoisonedAt = Math.min(cp.phasePoisonedAt, ln.start + mr.index);
925
963
  const selfClosing = mr[3] !== void 0 && /\/\s*$/.test(mr[3]);
926
964
  if (VOID_TAGS.has(tag) || selfClosing) continue;
927
965
  applyTag(tag, closing);
@@ -937,7 +975,14 @@ ${cont(scanText)}` };
937
975
  if (m2) {
938
976
  const closing = m2[1] === "/";
939
977
  const tag = m2[2].toLowerCase();
940
- if (!VOID_TAGS.has(tag)) {
978
+ if (TABLE_PART_NAMES.has(tag)) cp.phasePoisonedAt = Math.min(cp.phasePoisonedAt, ln.start + lastLt);
979
+ if (cp.htmlFlowReal) {
980
+ cp.tagAcrossLines = true;
981
+ cp.tagAcrossLinesIndent = ln.indent;
982
+ }
983
+ if (closing) {
984
+ if (!VOID_TAGS.has(tag) && cp.htmlFlowReal) cp.pendingTruncatedCloses.push(tag);
985
+ } else if (!VOID_TAGS.has(tag)) {
941
986
  applyTag(tag, closing);
942
987
  const rawLastLt = ln.text.lastIndexOf("<");
943
988
  const rawTruncated = rawLastLt !== -1 && !ln.text.includes(">", rawLastLt);
@@ -1480,6 +1525,9 @@ function countTrailingNewlines(value) {
1480
1525
  function countNewlines(text, end = text.length) {
1481
1526
  let count = 0;
1482
1527
  for (let i = text.indexOf("\n"); i !== -1 && i < end; i = text.indexOf("\n", i + 1)) count += 1;
1528
+ for (let i = text.indexOf("\r"); i !== -1 && i < end; i = text.indexOf("\r", i + 1)) {
1529
+ if (text.charCodeAt(i + 1) !== 10) count += 1;
1530
+ }
1483
1531
  return count;
1484
1532
  }
1485
1533
 
@@ -1599,7 +1647,7 @@ function normalizeId(s) {
1599
1647
  return (0, import_micromark_util_normalize_identifier3.normalizeIdentifier)(s);
1600
1648
  }
1601
1649
  function normalizeForMatch(s) {
1602
- return (0, import_micromark_util_normalize_identifier3.normalizeIdentifier)(s.replace(/\\(.)/g, "$1"));
1650
+ return (0, import_micromark_util_normalize_identifier3.normalizeIdentifier)(s.replace(/\\([!-/:-@[-`{-~])/g, "$1"));
1603
1651
  }
1604
1652
 
1605
1653
  // src/components/collectDefLabels.ts
@@ -4025,10 +4073,16 @@ function lineIndentBefore(content, pos) {
4025
4073
  function findClosingBacktickRun(content, start, n) {
4026
4074
  let i = start;
4027
4075
  while (i < content.length) {
4028
- if (content[i] === "`") {
4076
+ const ch = content[i];
4077
+ if (ch === "`") {
4029
4078
  const runLen = getRepeatedMarkerLength(content, i, "`");
4030
4079
  if (runLen === n) return i;
4031
4080
  i += runLen;
4081
+ } else if (ch === "\n") {
4082
+ let j = i + 1;
4083
+ while (j < content.length && (content[j] === " " || content[j] === " " || content[j] === "\r")) j += 1;
4084
+ if (j >= content.length || content[j] === "\n") return -1;
4085
+ i += 1;
4032
4086
  } else {
4033
4087
  i += 1;
4034
4088
  }
@@ -4150,16 +4204,20 @@ function escapeCurrencyDollarSigns(text) {
4150
4204
  currentLineProcessed += segment;
4151
4205
  }
4152
4206
  let needEscape = true;
4153
- let restBeforeNextMatchOrEnd = "";
4154
- if (i < currencyMatches.length - 1) {
4155
- const nextMatch = currencyMatches[i + 1];
4156
- if (nextMatch.index - match.index > 1) {
4157
- restBeforeNextMatchOrEnd = text.substring(match.index + 1, nextMatch.index);
4207
+ const restStart = match.index + 1;
4208
+ const restEnd = i < currencyMatches.length - 1 ? currencyMatches[i + 1].index : text.length;
4209
+ let firstLineBeforeNextMatch = "";
4210
+ if (restEnd - restStart > 0) {
4211
+ let eol = restEnd;
4212
+ for (let k = restStart; k < restEnd; k++) {
4213
+ const c = text.charCodeAt(k);
4214
+ if (c === 10 || c === 13) {
4215
+ eol = k;
4216
+ break;
4217
+ }
4158
4218
  }
4159
- } else {
4160
- restBeforeNextMatchOrEnd = text.substring(match.index + 1);
4219
+ firstLineBeforeNextMatch = text.substring(restStart, eol);
4161
4220
  }
4162
- const firstLineBeforeNextMatch = restBeforeNextMatchOrEnd.split(/\r\n|\r|\n/g)[0];
4163
4221
  if (Array.from(firstLineBeforeNextMatch.matchAll(NO_ESCAPED_DOLLAR_REGEX)).length % 2 !== 0) {
4164
4222
  const wholeLineBeforeNextMatchWithoutCurrentDollar = currentLineProcessed + firstLineBeforeNextMatch;
4165
4223
  if (Array.from(wholeLineBeforeNextMatchWithoutCurrentDollar.matchAll(NO_ESCAPED_DOLLAR_REGEX)).length % 2 !== 0) {
@@ -4325,39 +4383,49 @@ function hasUnclosedTextCommand(text) {
4325
4383
  return false;
4326
4384
  }
4327
4385
  var RESIDUAL_OPEN_BRACKET_RE = /(?<!!)\\\[/;
4328
- function processSliceInstrumented(slice) {
4386
+ var LEADING_DOUBLE_DOLLAR_RE = /^\s*\$\$/;
4387
+ function processSliceInstrumented(slice, probe = true) {
4329
4388
  const segments = splitByProtectedRegions(slice);
4330
- let out = "";
4389
+ const parts = [];
4331
4390
  let quiescent = true;
4332
4391
  let truncatedAtSeamStart = false;
4333
4392
  for (let index = 0; index < segments.length; index++) {
4334
4393
  const segment = segments[index];
4335
4394
  if (segment.isCode) {
4336
- out += segment.text;
4395
+ parts.push(segment.text);
4337
4396
  continue;
4338
4397
  }
4339
4398
  let text = segment.text;
4340
4399
  text = escapeMhchemCommands(text);
4341
4400
  text = escapeCurrencyDollarSigns(text);
4342
4401
  text = convertLatexDelimiters(text);
4343
- if (RESIDUAL_OPEN_BRACKET_RE.test(text)) quiescent = false;
4402
+ if (probe && RESIDUAL_OPEN_BRACKET_RE.test(text)) quiescent = false;
4344
4403
  text = escapeLatexPipes(text);
4345
- if (findUnclosedDelimiterStart(text, "both") !== -1) quiescent = false;
4404
+ if (probe && findUnclosedDelimiterStart(text, "both") !== -1) quiescent = false;
4346
4405
  text = escapeLatexPipesInUnclosed(text);
4347
- if (hasUnclosedTextCommand(text)) quiescent = false;
4406
+ if (probe && hasUnclosedTextCommand(text)) quiescent = false;
4348
4407
  text = escapeTextUnderscores(text);
4349
4408
  text = convertSingleToDoubleDollar(text);
4350
- const unclosedDouble = findUnclosedDelimiterStart(text, "double-only");
4351
- if (unclosedDouble !== -1) {
4352
- quiescent = false;
4353
- if (index === 0 && text.slice(0, unclosedDouble).trim() === "") {
4354
- truncatedAtSeamStart = true;
4409
+ if (probe || index === 0 && LEADING_DOUBLE_DOLLAR_RE.test(text)) {
4410
+ const unclosedDouble = findUnclosedDelimiterStart(text, "double-only");
4411
+ if (unclosedDouble !== -1) {
4412
+ quiescent = false;
4413
+ if (index === 0 && text.slice(0, unclosedDouble).trim() === "") {
4414
+ truncatedAtSeamStart = true;
4415
+ }
4355
4416
  }
4356
4417
  }
4357
4418
  text = truncateUnclosedLatexBlock(text);
4358
- out += text;
4419
+ parts.push(text);
4359
4420
  }
4360
- return { out, quiescent, truncatedAtSeamStart };
4421
+ return { out: parts.join(""), quiescent, truncatedAtSeamStart };
4422
+ }
4423
+ function isBlankRawLine(text, from, to) {
4424
+ for (let i = from; i < to; i++) {
4425
+ const c = text.charCodeAt(i);
4426
+ if (c !== 32 && c !== 9 && c !== 13) return false;
4427
+ }
4428
+ return true;
4361
4429
  }
4362
4430
  function findRawSafeCut(active) {
4363
4431
  const segments = splitByProtectedRegions(active);
@@ -4372,10 +4440,12 @@ function findRawSafeCut(active) {
4372
4440
  continue;
4373
4441
  }
4374
4442
  const text = segment.text;
4443
+ let atLineStart = offset === 0 || active.charCodeAt(offset - 1) === 10;
4375
4444
  let lineStart = 0;
4376
4445
  while (lineStart <= text.length) {
4377
4446
  const nl = text.indexOf("\n", lineStart);
4378
4447
  const lineEnd = nl === -1 ? text.length : nl;
4448
+ if (atLineStart && nl !== -1 && isBlankRawLine(text, lineStart, lineEnd)) backtickHazard = false;
4379
4449
  for (let i = lineStart; i < lineEnd; i++) {
4380
4450
  const ch = text[i];
4381
4451
  if (ch === "`") backtickHazard = true;
@@ -4388,6 +4458,7 @@ function findRawSafeCut(active) {
4388
4458
  if (nl === -1) break;
4389
4459
  if (!backtickHazard && !latentLt) lastCut = offset + nl + 1;
4390
4460
  lineStart = nl + 1;
4461
+ atLineStart = true;
4391
4462
  }
4392
4463
  offset += text.length;
4393
4464
  }
@@ -4396,11 +4467,14 @@ function findRawSafeCut(active) {
4396
4467
  var DEFAULT_FREEZE_ATTEMPT_THRESHOLD = 512;
4397
4468
  function createIncrementalLatexPreprocessor(options) {
4398
4469
  const freezeThreshold = options?.freezeThreshold ?? DEFAULT_FREEZE_ATTEMPT_THRESHOLD;
4470
+ const onAttempt = options?.onAttempt;
4471
+ const backoff = options?.backoff ?? true;
4399
4472
  let prevSource = "";
4400
4473
  let prevOutput = "";
4401
4474
  let frozenSrcEnd = 0;
4402
4475
  let frozenOut = "";
4403
4476
  let triggered = false;
4477
+ let nextAttemptLen = 0;
4404
4478
  return function incrementalPreprocessLaTeX(source) {
4405
4479
  if (source === prevSource) return prevOutput;
4406
4480
  const isAppend = source.length > prevSource.length && source.startsWith(prevSource);
@@ -4408,6 +4482,7 @@ function createIncrementalLatexPreprocessor(options) {
4408
4482
  frozenSrcEnd = 0;
4409
4483
  frozenOut = "";
4410
4484
  triggered = false;
4485
+ nextAttemptLen = 0;
4411
4486
  }
4412
4487
  if (!triggered) {
4413
4488
  const checkFrom = isAppend ? Math.max(0, prevSource.length - 1) : 0;
@@ -4419,18 +4494,26 @@ function createIncrementalLatexPreprocessor(options) {
4419
4494
  triggered = true;
4420
4495
  }
4421
4496
  let active = source.slice(frozenSrcEnd);
4422
- if (active.length > freezeThreshold) {
4497
+ if (active.length > freezeThreshold && active.length >= nextAttemptLen) {
4498
+ const activeLength = active.length;
4499
+ let advanced = false;
4500
+ let frozenBytes = 0;
4501
+ const freeze = (cut2, slice) => {
4502
+ frozenOut += slice.out;
4503
+ frozenSrcEnd += cut2;
4504
+ active = source.slice(frozenSrcEnd);
4505
+ advanced = true;
4506
+ frozenBytes = cut2;
4507
+ };
4423
4508
  const cut = findRawSafeCut(active);
4424
4509
  if (cut > 0) {
4425
4510
  const candidate = processSliceInstrumented(active.slice(0, cut));
4426
- if (candidate.quiescent) {
4427
- frozenOut += candidate.out;
4428
- frozenSrcEnd += cut;
4429
- active = source.slice(frozenSrcEnd);
4430
- }
4511
+ if (candidate.quiescent) freeze(cut, candidate);
4431
4512
  }
4513
+ nextAttemptLen = advanced || !backoff ? 0 : active.length * 2;
4514
+ onAttempt?.({ activeLength, frozenBytes });
4432
4515
  }
4433
- const tail = processSliceInstrumented(active);
4516
+ const tail = processSliceInstrumented(active, false);
4434
4517
  const head = tail.truncatedAtSeamStart ? frozenOut.replace(/\s+$/, "") : frozenOut;
4435
4518
  const out = head + tail.out;
4436
4519
  prevSource = source;