@ai-react-markdown/engine 2.4.2 → 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);
@@ -1149,6 +1194,8 @@ function rebaseDualWalk(node, segments, maxEnd, offsetDelta, lineDelta) {
1149
1194
  for (const child of children) rebaseDualWalk(child, segments, maxEnd, offsetDelta, lineDelta);
1150
1195
  }
1151
1196
  }
1197
+ var TABLE_PART_TAG_RE = /<(?:td|th|tr|tbody|thead|tfoot|caption|col|colgroup)\b/i;
1198
+ var STRAY_SYNTHESIZED_END_TAG_RE = /<\/(?:br|p)\b/i;
1152
1199
  function spliceTrees(input) {
1153
1200
  const { prevMdast, prevHast, tailMdast, tailHast, content, boundary, injectionPrefix, injectedSegments } = input;
1154
1201
  const injectedLen = injectionPrefix.length;
@@ -1205,6 +1252,12 @@ function spliceTrees(input) {
1205
1252
  return !(start !== void 0 && start < injectedLen);
1206
1253
  });
1207
1254
  const tailWrapVisible = tailMdastChildren.some((child) => !isWrapInvisible(child));
1255
+ if (prefixMdast.some((c) => c.type === "html" && TABLE_PART_TAG_RE.test(c.value))) return null;
1256
+ for (const child of tailMdastChildren) {
1257
+ if (isWrapInvisible(child)) continue;
1258
+ if (child.type !== "html") break;
1259
+ if (STRAY_SYNTHESIZED_END_TAG_RE.test(child.value) || TABLE_PART_TAG_RE.test(child.value)) return null;
1260
+ }
1208
1261
  const aligned = alignPrefixCut(prefixMdast, cutRegion, tailWrapVisible);
1209
1262
  if (aligned === null) return null;
1210
1263
  const hastChildren = aligned.children;
@@ -1330,6 +1383,9 @@ function alignPrefixCut(prefixMdast, cutRegion, tailWrapVisible) {
1330
1383
  return null;
1331
1384
  }
1332
1385
  const lastIsLiteral = last !== void 0 && last.type === "text" && last.value.trim() !== "";
1386
+ if (lastIsLiteral && pairIdx >= 0 && visibles[pairIdx].type !== "html") {
1387
+ return null;
1388
+ }
1333
1389
  const litOwnerEnd = pairIdx >= 0 ? visibles[pairIdx].position?.end?.offset : void 0;
1334
1390
  const litEnd = lastIsLiteral ? last.position?.end?.offset : void 0;
1335
1391
  if (lastIsLiteral && last.position !== void 0 && (litEnd === void 0 || litOwnerEnd === void 0)) {
@@ -1418,8 +1474,9 @@ function stripInjectedHast(tailMdast, tailHast, injectedLen, tailWrapVisible) {
1418
1474
  }
1419
1475
  function tailLeadingTextIsHoist(tailMdastChildren, tailHastChildren) {
1420
1476
  const firstText = tailHastChildren[0];
1421
- if (!firstText) return false;
1422
1477
  const firstVisible = tailMdastChildren.find((c) => !isWrapInvisible(c));
1478
+ if (firstVisible?.type === "html" && STRAY_SYNTHESIZED_END_TAG_RE.test(firstVisible.value)) return null;
1479
+ if (!firstText) return false;
1423
1480
  if (!isSeparatorText(firstText)) {
1424
1481
  if (firstText.type === "text" && firstText.position === void 0 && firstVisible?.type === "html") {
1425
1482
  if (/^\s*<\/[A-Za-z][A-Za-z0-9-]*\s*>/.test(firstVisible.value)) return true;
@@ -1468,6 +1525,9 @@ function countTrailingNewlines(value) {
1468
1525
  function countNewlines(text, end = text.length) {
1469
1526
  let count = 0;
1470
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
+ }
1471
1531
  return count;
1472
1532
  }
1473
1533
 
@@ -1587,7 +1647,7 @@ function normalizeId(s) {
1587
1647
  return (0, import_micromark_util_normalize_identifier3.normalizeIdentifier)(s);
1588
1648
  }
1589
1649
  function normalizeForMatch(s) {
1590
- 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"));
1591
1651
  }
1592
1652
 
1593
1653
  // src/components/collectDefLabels.ts
@@ -1860,17 +1920,8 @@ function phantomSuffixCloser(content) {
1860
1920
  }
1861
1921
 
1862
1922
  // src/components/extractContributions.ts
1863
- function fakeAnchorElement(url) {
1864
- return { type: "element", tagName: "a", properties: { href: url }, children: [] };
1865
- }
1866
- function sanitizeDefUrl(url, urlTransform) {
1867
- if (!urlTransform) return url;
1868
- const result = urlTransform(url, "href", fakeAnchorElement(url));
1869
- return result == null ? "" : String(result);
1870
- }
1871
1923
  function* extractContributions(mdast, options = {}) {
1872
1924
  const phantomFn = options.phantomFootnoteLabels;
1873
- const urlTransform = options.urlTransform;
1874
1925
  const out = [];
1875
1926
  (0, import_unist_util_visit3.visit)(mdast, (n) => {
1876
1927
  if (n.type === "footnoteReference") {
@@ -1898,7 +1949,7 @@ function* extractContributions(mdast, options = {}) {
1898
1949
  out.push({
1899
1950
  kind: "linkDef",
1900
1951
  label: normalizeId(d.identifier),
1901
- url: sanitizeDefUrl(d.url, urlTransform),
1952
+ url: d.url,
1902
1953
  title: d.title
1903
1954
  });
1904
1955
  }
@@ -3612,8 +3663,7 @@ function isProtocolAllowed(url, allowed) {
3612
3663
  }
3613
3664
  function sanitizeCrossChunkUrl(rawUrl, key, tagName, urlTransform, schema) {
3614
3665
  rawUrl = (0, import_micromark_util_sanitize_uri2.normalizeUri)(rawUrl);
3615
- const callerProtocols = schema.protocols;
3616
- const allowed = callerProtocols === void 0 || callerProtocols === null ? sanitizeSchema.protocols?.[key] : callerProtocols[key];
3666
+ const allowed = Object.hasOwn(schema, "protocols") ? schema.protocols?.[key] : sanitizeSchema.protocols?.[key];
3617
3667
  if (allowed && allowed.length > 0 && !isProtocolAllowed(rawUrl, allowed)) return null;
3618
3668
  const transformed = urlTransform(rawUrl, key, fakeElement(tagName, key, rawUrl));
3619
3669
  if (transformed == null) return null;
@@ -4011,23 +4061,28 @@ function lineHasBacktick(content, pos) {
4011
4061
  }
4012
4062
  return false;
4013
4063
  }
4014
- function isAtLineStart(content, pos) {
4064
+ function lineIndentBefore(content, pos) {
4015
4065
  let i = pos - 1;
4016
- let spaces = 0;
4017
- while (i >= 0 && content[i] === " ") {
4018
- spaces++;
4019
- if (spaces > 3) return false;
4066
+ let indent = 0;
4067
+ while (i >= 0 && (content[i] === " " || content[i] === " ")) {
4068
+ indent += content[i] === " " ? 4 : 1;
4020
4069
  i--;
4021
4070
  }
4022
- return i < 0 || content[i] === "\n" || content[i] === "\r";
4071
+ return i < 0 || content[i] === "\n" || content[i] === "\r" ? indent : -1;
4023
4072
  }
4024
4073
  function findClosingBacktickRun(content, start, n) {
4025
4074
  let i = start;
4026
4075
  while (i < content.length) {
4027
- if (content[i] === "`") {
4076
+ const ch = content[i];
4077
+ if (ch === "`") {
4028
4078
  const runLen = getRepeatedMarkerLength(content, i, "`");
4029
4079
  if (runLen === n) return i;
4030
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;
4031
4086
  } else {
4032
4087
  i += 1;
4033
4088
  }
@@ -4040,6 +4095,7 @@ function splitByProtectedRegions(content) {
4040
4095
  let multilineStart = -1;
4041
4096
  let multilineFenceMarker = null;
4042
4097
  let multilineFenceLength = 0;
4098
+ let multilineFenceIndent = 0;
4043
4099
  function pushProtected(start, end) {
4044
4100
  if (start > lastIndex) {
4045
4101
  segments.push({ text: content.substring(lastIndex, start), isCode: false });
@@ -4053,7 +4109,8 @@ function splitByProtectedRegions(content) {
4053
4109
  if (multilineStart !== -1) {
4054
4110
  if (char === multilineFenceMarker) {
4055
4111
  const runLen = getRepeatedMarkerLength(content, i, multilineFenceMarker);
4056
- if (runLen >= multilineFenceLength && isAtLineStart(content, i) && restOfLineIsBlank(content, i + runLen)) {
4112
+ const closerIndent = lineIndentBefore(content, i);
4113
+ if (runLen >= multilineFenceLength && closerIndent !== -1 && closerIndent <= multilineFenceIndent + 3 && restOfLineIsBlank(content, i + runLen)) {
4057
4114
  pushProtected(multilineStart, i + runLen);
4058
4115
  multilineStart = -1;
4059
4116
  multilineFenceMarker = null;
@@ -4069,10 +4126,12 @@ function splitByProtectedRegions(content) {
4069
4126
  }
4070
4127
  if (char === "`" || char === "~") {
4071
4128
  const runLen = getRepeatedMarkerLength(content, i, char);
4072
- if (runLen >= 3 && isAtLineStart(content, i) && !(char === "`" && lineHasBacktick(content, i + runLen))) {
4129
+ const openerIndent = lineIndentBefore(content, i);
4130
+ if (runLen >= 3 && openerIndent !== -1 && !(char === "`" && lineHasBacktick(content, i + runLen))) {
4073
4131
  multilineStart = i;
4074
4132
  multilineFenceMarker = char;
4075
4133
  multilineFenceLength = runLen;
4134
+ multilineFenceIndent = openerIndent;
4076
4135
  i += runLen;
4077
4136
  continue;
4078
4137
  }
@@ -4145,16 +4204,20 @@ function escapeCurrencyDollarSigns(text) {
4145
4204
  currentLineProcessed += segment;
4146
4205
  }
4147
4206
  let needEscape = true;
4148
- let restBeforeNextMatchOrEnd = "";
4149
- if (i < currencyMatches.length - 1) {
4150
- const nextMatch = currencyMatches[i + 1];
4151
- if (nextMatch.index - match.index > 1) {
4152
- 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
+ }
4153
4218
  }
4154
- } else {
4155
- restBeforeNextMatchOrEnd = text.substring(match.index + 1);
4219
+ firstLineBeforeNextMatch = text.substring(restStart, eol);
4156
4220
  }
4157
- const firstLineBeforeNextMatch = restBeforeNextMatchOrEnd.split(/\r\n|\r|\n/g)[0];
4158
4221
  if (Array.from(firstLineBeforeNextMatch.matchAll(NO_ESCAPED_DOLLAR_REGEX)).length % 2 !== 0) {
4159
4222
  const wholeLineBeforeNextMatchWithoutCurrentDollar = currentLineProcessed + firstLineBeforeNextMatch;
4160
4223
  if (Array.from(wholeLineBeforeNextMatchWithoutCurrentDollar.matchAll(NO_ESCAPED_DOLLAR_REGEX)).length % 2 !== 0) {
@@ -4320,39 +4383,49 @@ function hasUnclosedTextCommand(text) {
4320
4383
  return false;
4321
4384
  }
4322
4385
  var RESIDUAL_OPEN_BRACKET_RE = /(?<!!)\\\[/;
4323
- function processSliceInstrumented(slice) {
4386
+ var LEADING_DOUBLE_DOLLAR_RE = /^\s*\$\$/;
4387
+ function processSliceInstrumented(slice, probe = true) {
4324
4388
  const segments = splitByProtectedRegions(slice);
4325
- let out = "";
4389
+ const parts = [];
4326
4390
  let quiescent = true;
4327
4391
  let truncatedAtSeamStart = false;
4328
4392
  for (let index = 0; index < segments.length; index++) {
4329
4393
  const segment = segments[index];
4330
4394
  if (segment.isCode) {
4331
- out += segment.text;
4395
+ parts.push(segment.text);
4332
4396
  continue;
4333
4397
  }
4334
4398
  let text = segment.text;
4335
4399
  text = escapeMhchemCommands(text);
4336
4400
  text = escapeCurrencyDollarSigns(text);
4337
4401
  text = convertLatexDelimiters(text);
4338
- if (RESIDUAL_OPEN_BRACKET_RE.test(text)) quiescent = false;
4402
+ if (probe && RESIDUAL_OPEN_BRACKET_RE.test(text)) quiescent = false;
4339
4403
  text = escapeLatexPipes(text);
4340
- if (findUnclosedDelimiterStart(text, "both") !== -1) quiescent = false;
4404
+ if (probe && findUnclosedDelimiterStart(text, "both") !== -1) quiescent = false;
4341
4405
  text = escapeLatexPipesInUnclosed(text);
4342
- if (hasUnclosedTextCommand(text)) quiescent = false;
4406
+ if (probe && hasUnclosedTextCommand(text)) quiescent = false;
4343
4407
  text = escapeTextUnderscores(text);
4344
4408
  text = convertSingleToDoubleDollar(text);
4345
- const unclosedDouble = findUnclosedDelimiterStart(text, "double-only");
4346
- if (unclosedDouble !== -1) {
4347
- quiescent = false;
4348
- if (index === 0 && text.slice(0, unclosedDouble).trim() === "") {
4349
- 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
+ }
4350
4416
  }
4351
4417
  }
4352
4418
  text = truncateUnclosedLatexBlock(text);
4353
- out += text;
4419
+ parts.push(text);
4354
4420
  }
4355
- 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;
4356
4429
  }
4357
4430
  function findRawSafeCut(active) {
4358
4431
  const segments = splitByProtectedRegions(active);
@@ -4367,10 +4440,12 @@ function findRawSafeCut(active) {
4367
4440
  continue;
4368
4441
  }
4369
4442
  const text = segment.text;
4443
+ let atLineStart = offset === 0 || active.charCodeAt(offset - 1) === 10;
4370
4444
  let lineStart = 0;
4371
4445
  while (lineStart <= text.length) {
4372
4446
  const nl = text.indexOf("\n", lineStart);
4373
4447
  const lineEnd = nl === -1 ? text.length : nl;
4448
+ if (atLineStart && nl !== -1 && isBlankRawLine(text, lineStart, lineEnd)) backtickHazard = false;
4374
4449
  for (let i = lineStart; i < lineEnd; i++) {
4375
4450
  const ch = text[i];
4376
4451
  if (ch === "`") backtickHazard = true;
@@ -4383,6 +4458,7 @@ function findRawSafeCut(active) {
4383
4458
  if (nl === -1) break;
4384
4459
  if (!backtickHazard && !latentLt) lastCut = offset + nl + 1;
4385
4460
  lineStart = nl + 1;
4461
+ atLineStart = true;
4386
4462
  }
4387
4463
  offset += text.length;
4388
4464
  }
@@ -4391,11 +4467,14 @@ function findRawSafeCut(active) {
4391
4467
  var DEFAULT_FREEZE_ATTEMPT_THRESHOLD = 512;
4392
4468
  function createIncrementalLatexPreprocessor(options) {
4393
4469
  const freezeThreshold = options?.freezeThreshold ?? DEFAULT_FREEZE_ATTEMPT_THRESHOLD;
4470
+ const onAttempt = options?.onAttempt;
4471
+ const backoff = options?.backoff ?? true;
4394
4472
  let prevSource = "";
4395
4473
  let prevOutput = "";
4396
4474
  let frozenSrcEnd = 0;
4397
4475
  let frozenOut = "";
4398
4476
  let triggered = false;
4477
+ let nextAttemptLen = 0;
4399
4478
  return function incrementalPreprocessLaTeX(source) {
4400
4479
  if (source === prevSource) return prevOutput;
4401
4480
  const isAppend = source.length > prevSource.length && source.startsWith(prevSource);
@@ -4403,6 +4482,7 @@ function createIncrementalLatexPreprocessor(options) {
4403
4482
  frozenSrcEnd = 0;
4404
4483
  frozenOut = "";
4405
4484
  triggered = false;
4485
+ nextAttemptLen = 0;
4406
4486
  }
4407
4487
  if (!triggered) {
4408
4488
  const checkFrom = isAppend ? Math.max(0, prevSource.length - 1) : 0;
@@ -4414,18 +4494,26 @@ function createIncrementalLatexPreprocessor(options) {
4414
4494
  triggered = true;
4415
4495
  }
4416
4496
  let active = source.slice(frozenSrcEnd);
4417
- 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
+ };
4418
4508
  const cut = findRawSafeCut(active);
4419
4509
  if (cut > 0) {
4420
4510
  const candidate = processSliceInstrumented(active.slice(0, cut));
4421
- if (candidate.quiescent) {
4422
- frozenOut += candidate.out;
4423
- frozenSrcEnd += cut;
4424
- active = source.slice(frozenSrcEnd);
4425
- }
4511
+ if (candidate.quiescent) freeze(cut, candidate);
4426
4512
  }
4513
+ nextAttemptLen = advanced || !backoff ? 0 : active.length * 2;
4514
+ onAttempt?.({ activeLength, frozenBytes });
4427
4515
  }
4428
- const tail = processSliceInstrumented(active);
4516
+ const tail = processSliceInstrumented(active, false);
4429
4517
  const head = tail.truncatedAtSeamStart ? frozenOut.replace(/\s+$/, "") : frozenOut;
4430
4518
  const out = head + tail.out;
4431
4519
  prevSource = source;