diff --git a/.changeset/spotty-eyes-brake.md b/.changeset/spotty-eyes-brake.md
new file mode 100644
index 00000000..1e6edb52
--- /dev/null
+++ b/.changeset/spotty-eyes-brake.md
@@ -0,0 +1,11 @@
+---
+"remend": minor
+---
+
+Rework code-region detection and double-underscore counting.
+
+A shared single-pass scanner now classifies fences and inline code spans, replacing the per-character rescans that made healing quadratic on delimiter-heavy input. Fence and span detection follows CommonMark, so `~~~` fences, list-indented fences, CRLF line endings, and multi-backtick spans are all recognized, and content inside code is never healed as prose.
+
+Double underscores are counted per maximal run with flanking rules, so identifiers containing `__` (like `snake__case`) no longer invent or swallow emphasis closers.
+
+Healing is now idempotent. Healed output re-heals to itself, including incomplete image removal and the trailing space it exposes.
diff --git a/packages/remend/__benchmarks__/remend.bench.ts b/packages/remend/__benchmarks__/remend.bench.ts
index d8ac7c3b..62ce9739 100644
--- a/packages/remend/__benchmarks__/remend.bench.ts
+++ b/packages/remend/__benchmarks__/remend.bench.ts
@@ -279,3 +279,25 @@ ${"Regular paragraph text with some [links](https://example.com) and more conten
{ iterations: 1000 }
);
});
+
+// Delimiter-heavy input at doubling sizes. Cost should grow in proportion to
+// input size: a doubling that more than doubles the time signals a
+// superlinear rescan in a handler.
+describe("Scaling", () => {
+ const unit =
+ "word snake__case text __bold__ and _it_ plus `code` *star* ~~del~~ ".repeat(
+ 30
+ );
+ const sizes = [1, 2, 4, 8] as const;
+
+ for (const mult of sizes) {
+ const doc = `${unit.repeat(mult)}__open`;
+ bench(
+ `delimiter-heavy ${doc.length} chars`,
+ () => {
+ remend(doc);
+ },
+ { iterations: 200 }
+ );
+ }
+});
diff --git a/packages/remend/__tests__/broken-markdown-variants.test.ts b/packages/remend/__tests__/broken-markdown-variants.test.ts
index 43d54a36..48a22ebf 100644
--- a/packages/remend/__tests__/broken-markdown-variants.test.ts
+++ b/packages/remend/__tests__/broken-markdown-variants.test.ts
@@ -123,8 +123,9 @@ describe("multiple incomplete links", () => {
});
it("should handle two incomplete links in text-only mode", () => {
+ // Fixed-point healing resolves both unmatched brackets in one call
const result = remend("[link1 and [link2", { linkMode: "text-only" });
- expect(result).toBe("link1 and [link2");
+ expect(result).toBe("link1 and link2");
});
});
@@ -586,11 +587,10 @@ describe("real-world AI streaming patterns", () => {
);
});
- it("should handle incomplete image with partial URL (preserves trailing space)", () => {
- // Image is removed, leaving "See " - the trailing space remains
- // because remend only trims single trailing space at the very start
- // before handlers run, and the handler produces new trailing space
- expect(remend("See ).toBe("See ");
+ it("should handle incomplete image with partial URL", () => {
+ // The space exposed by the removal is stripped like an input trailing
+ // space, so healed output re-heals to itself
+ expect(remend("See ).toBe("See");
});
it("should handle link with incomplete formatting after it", () => {
diff --git a/packages/remend/__tests__/coverage-gaps.test.ts b/packages/remend/__tests__/coverage-gaps.test.ts
index bdda90b1..8c91f147 100644
--- a/packages/remend/__tests__/coverage-gaps.test.ts
+++ b/packages/remend/__tests__/coverage-gaps.test.ts
@@ -93,8 +93,10 @@ describe("link handler edge cases", () => {
expect(remend("](partial")).toBe("](partial");
});
- it("should skip image brackets in text-only mode", () => {
- expect(remend(").toBe("");
});
@@ -21,9 +27,9 @@ describe("image handling", () => {
it("should handle nested brackets in incomplete images", () => {
// When findMatchingClosingBracket returns -1 for an image (lines 74-79)
// For this to happen, we need an opening bracket with a ] but no proper matching
- expect(remend("Text ![outer [inner]")).toBe("Text ");
+ expect(remend("Text ![outer [inner]")).toBe("Text");
expect(remend("![nested [brackets] text")).toBe("");
- expect(remend("Start ![foo [bar] baz")).toBe("Start ");
+ expect(remend("Start ![foo [bar] baz")).toBe("Start");
});
it("should not add trailing underscore for images with underscores in URL (#284)", () => {
diff --git a/packages/remend/__tests__/katex.test.ts b/packages/remend/__tests__/katex.test.ts
index af5c375e..1095b0e5 100644
--- a/packages/remend/__tests__/katex.test.ts
+++ b/packages/remend/__tests__/katex.test.ts
@@ -286,3 +286,15 @@ describe("math blocks with asterisks", () => {
expect(remend(text)).toBe("Start *italic with $$x^{*}$$*");
});
});
+
+describe("dollar signs inside code", () => {
+ it("should not let a $ in inline code suppress later healing", () => {
+ expect(remend("`$` _hello")).toBe("`$` _hello_");
+ });
+
+ it("should not let a $ in a fence suppress later healing", () => {
+ expect(remend("```\nprice = $5\n```\n_hello")).toBe(
+ "```\nprice = $5\n```\n_hello_"
+ );
+ });
+});
diff --git a/packages/remend/__tests__/links.test.ts b/packages/remend/__tests__/links.test.ts
index bc0bb04e..1c951580 100644
--- a/packages/remend/__tests__/links.test.ts
+++ b/packages/remend/__tests__/links.test.ts
@@ -115,10 +115,11 @@ describe("link handling with linkMode: text-only", () => {
});
it("should handle nested brackets without matching closing bracket", () => {
+ // Fixed-point healing resolves every unmatched bracket in one call
expect(remend("Text [outer [inner", textOnlyOptions)).toBe(
- "Text outer [inner"
+ "Text outer inner"
);
- expect(remend("[foo [bar [baz", textOnlyOptions)).toBe("foo [bar [baz");
+ expect(remend("[foo [bar [baz", textOnlyOptions)).toBe("foo bar baz");
expect(remend("Text [outer [inner]", textOnlyOptions)).toBe(
"Text outer [inner]"
);
@@ -128,9 +129,9 @@ describe("link handling with linkMode: text-only", () => {
});
it("should still remove incomplete images", () => {
- // Images should still be removed entirely, regardless of linkMode
- // Note: the space before the image is preserved
- expect(remend("Text ).toBe("Text ");
+ // Images are removed entirely regardless of linkMode, and the exposed
+ // trailing space is stripped
+ expect(remend("Text ).toBe("Text");
});
});
diff --git a/packages/remend/__tests__/streaming-properties.test.ts b/packages/remend/__tests__/streaming-properties.test.ts
new file mode 100644
index 00000000..435798c6
--- /dev/null
+++ b/packages/remend/__tests__/streaming-properties.test.ts
@@ -0,0 +1,151 @@
+import fc from "fast-check";
+import { describe, expect, it } from "vitest";
+import remend from "../src";
+
+// A streaming consumer feeds every prefix of a document through remend, so
+// these tests assemble documents from COMPLETE constructs and derive all
+// truncation from the streaming cut. The atoms deliberately include
+// identifiers with double-underscore runs (snake__case style), which look
+// like emphasis delimiters to a context-free counter.
+
+const INLINE_ATOMS = [
+ "plain words",
+ "**bold text**",
+ "*italic text*",
+ "_underscore italic_",
+ "__strong text__",
+ "___strong italic___",
+ "~~struck text~~",
+ "`inline code`",
+ "``double `tick` span``",
+ "snake__case",
+ "user__id",
+ "a_b_c",
+ "[label](https://example.com)",
+ "",
+ "tag",
+ "value is 20~25 degrees",
+];
+
+const BLOCK_ATOMS = [
+ "# heading",
+ "> a quote",
+ "- item one\n- item two",
+ "```js\nconst total__count = 1;\n```",
+ "~~~\ntilde fenced\n~~~",
+ "$$\nx^2\n$$",
+];
+
+// The longest fragment healing may legitimately drop: an incomplete
+// construct cut mid-stream is at most one atom long, plus the stripped
+// trailing space
+const MAX_LOSS =
+ Math.max(...[...INLINE_ATOMS, ...BLOCK_ATOMS].map((a) => a.length)) + 2;
+
+// Length of the longest prefix of `input` that appears as a subsequence of
+// `output`. Healing may insert characters (closers, escapes) and drop a
+// trailing fragment, so authored text is preserved exactly when everything
+// except a bounded tail survives as a subsequence.
+const preservedPrefixLength = (input: string, output: string): number => {
+ let matched = 0;
+ for (let i = 0; i < output.length && matched < input.length; i += 1) {
+ if (output[i] === input[matched]) {
+ matched += 1;
+ }
+ }
+ return matched;
+};
+
+const assertStreamingSafe = (prefix: string): void => {
+ const healed = remend(prefix);
+
+ const loss = prefix.length - preservedPrefixLength(prefix, healed);
+ if (loss > MAX_LOSS) {
+ throw new Error(
+ `healing dropped ${loss} chars of ${JSON.stringify(prefix)} -> ${JSON.stringify(healed)}`
+ );
+ }
+
+ const rehealed = remend(healed);
+ if (rehealed !== healed) {
+ throw new Error(
+ `healing is not idempotent: ${JSON.stringify(prefix)} -> ${JSON.stringify(healed)} -> ${JSON.stringify(rehealed)}`
+ );
+ }
+};
+
+// A document is a sequence of atoms. Inline atoms join with spaces into
+// paragraphs, block atoms stand alone, and everything joins with blank lines
+// so fences and headings begin at a line start.
+const documentArbitrary = fc
+ .array(
+ fc.oneof(
+ { weight: 3, arbitrary: fc.subarray(INLINE_ATOMS, { minLength: 1 }) },
+ { weight: 1, arbitrary: fc.constantFrom(...BLOCK_ATOMS).map((a) => [a]) }
+ ),
+ { minLength: 1, maxLength: 6 }
+ )
+ .map((groups) => groups.map((atoms) => atoms.join(" ")).join("\n\n"));
+
+describe("streaming properties", () => {
+ it("preserves authored text and re-heals to itself on every cut", () => {
+ fc.assert(
+ fc.property(documentArbitrary, fc.nat(), (doc, cutSeed) => {
+ const cut = cutSeed % (doc.length + 1);
+ assertStreamingSafe(doc.slice(0, cut));
+ }),
+ { numRuns: 2000 }
+ );
+ });
+
+ it("does not modify complete documents", () => {
+ // Escape-oriented handlers (comparison operators, single tilde between
+ // digits) intentionally rewrite complete text, so their trigger shapes
+ // are excluded here
+ const noOpAtoms = INLINE_ATOMS.filter((atom) => !atom.includes("20~25"));
+ const noOpDocArbitrary = fc
+ .array(
+ fc.oneof(
+ { weight: 3, arbitrary: fc.subarray(noOpAtoms, { minLength: 1 }) },
+ {
+ weight: 1,
+ arbitrary: fc.constantFrom(...BLOCK_ATOMS).map((a) => [a]),
+ }
+ ),
+ { minLength: 1, maxLength: 6 }
+ )
+ .map((groups) => groups.map((atoms) => atoms.join(" ")).join("\n\n"));
+
+ fc.assert(
+ fc.property(noOpDocArbitrary, (doc) => {
+ expect(remend(doc)).toBe(doc);
+ }),
+ { numRuns: 1000 }
+ );
+ });
+});
+
+describe("exhaustive prefix sweep", () => {
+ // Every prefix of a fixed corpus, deterministically. The corpus mixes
+ // constructs that interact: identifiers with double underscores next to
+ // real emphasis, fences of both characters, spans with multi-backtick runs,
+ // and half-typed closers.
+ const corpus = [
+ "Use snake__case for names and __bold text__ throughout.",
+ "The `obj__attr` field pairs with **bold** and _italic_ text.",
+ "```python\ndef f():\n return a__b\n```\n\nAfter the fence __open",
+ "~~~\ntilde __fence\n~~~\n\n~~struck~~ and more",
+ "A [link](https://example.com) and  done.",
+ "Math $$\nx^2 + y^2\n$$ and `code` mixed with ___strong italic___.",
+ "| a | b |\n| - | - |\n| 1 | 2 |\n\nTable then **bold**",
+ "Nested **bold with *italic* inside** plus ``double `tick` span``.",
+ ];
+
+ it("preserves authored text and re-heals to itself on every prefix", () => {
+ for (const doc of corpus) {
+ for (let cut = 0; cut <= doc.length; cut += 1) {
+ assertStreamingSafe(doc.slice(0, cut));
+ }
+ }
+ });
+});
diff --git a/packages/remend/__tests__/underscore-runs.test.ts b/packages/remend/__tests__/underscore-runs.test.ts
new file mode 100644
index 00000000..120f6a29
--- /dev/null
+++ b/packages/remend/__tests__/underscore-runs.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest";
+import remend from "../src";
+
+// Double underscores are counted per maximal run with flanking rules, so
+// identifiers containing __ (snake__case style) neither invent nor swallow
+// emphasis delimiters.
+describe("word-internal double underscores", () => {
+ it("should not treat an identifier's __ as a delimiter", () => {
+ expect(remend("fields user__id and org__id are join keys")).toBe(
+ "fields user__id and org__id are join keys"
+ );
+ });
+
+ it("should still close an opener when an identifier follows", () => {
+ // Counting raw __ occurrences would pair the identifier's run against
+ // the opener and swallow the closer that is still needed
+ expect(remend("Use snake__case and __bold")).toBe(
+ "Use snake__case and __bold__"
+ );
+ });
+
+ it("should not invent a closer for a lone identifier", () => {
+ expect(remend("the value of some__field is set")).toBe(
+ "the value of some__field is set"
+ );
+ });
+
+ it("should ignore identifiers inside complete inline code", () => {
+ expect(remend("`obj__attr` and __bold")).toBe("`obj__attr` and __bold__");
+ });
+
+ it("should ignore identifiers inside bold content", () => {
+ expect(remend("**bold snake__case text** and more")).toBe(
+ "**bold snake__case text** and more"
+ );
+ });
+});
+
+describe("underscore run lengths", () => {
+ it("should treat a run of four as balanced", () => {
+ expect(remend("a ____ b")).toBe("a ____ b");
+ });
+
+ it("should not complete a thematic break line", () => {
+ expect(remend("text\n\n___\n")).toBe("text\n\n___\n");
+ });
+
+ it("should keep ___text___ balanced", () => {
+ expect(remend("___both___ done")).toBe("___both___ done");
+ });
+});
+
+describe("escaped underscores", () => {
+ it("should treat the run after an escaped underscore as a delimiter", () => {
+ expect(remend("\\___bold")).toBe("\\___bold__");
+ });
+});
diff --git a/packages/remend/package.json b/packages/remend/package.json
index 44366e90..9d2c5329 100644
--- a/packages/remend/package.json
+++ b/packages/remend/package.json
@@ -34,6 +34,7 @@
"description": "Self-healing markdown. Intelligently parses and styles incomplete Markdown blocks.",
"devDependencies": {
"@vitest/coverage-v8": "^4.0.15",
+ "fast-check": "^4.9.0",
"mdast-util-from-markdown": "^2.0.2",
"tsup": "^8.5.1",
"vitest": "^4.0.15"
diff --git a/packages/remend/src/code-block-utils.ts b/packages/remend/src/code-block-utils.ts
index ca3de4bd..499aa465 100644
--- a/packages/remend/src/code-block-utils.ts
+++ b/packages/remend/src/code-block-utils.ts
@@ -1,96 +1,13 @@
-// Check if a position is inside a code block (between ``` or `)
-export const isInsideCodeBlock = (text: string, position: number): boolean => {
- // Check for inline code (backticks)
- let inInlineCode = false;
- let inMultilineCode = false;
+import { getScan, isCodeAt, isCompleteSpanAt } from "./scan";
- for (let i = 0; i < position; i += 1) {
- // Skip escaped backticks
- if (text[i] === "\\" && i + 1 < text.length && text[i + 1] === "`") {
- i += 1;
- continue;
- }
+// A code construct here means a fenced block or an inline code span.
+export const isInsideCodeBlock = (text: string, position: number): boolean =>
+ isCodeAt(getScan(text), position);
- // Check for triple backticks (multiline code blocks)
- if (text.substring(i, i + 3) === "```") {
- inMultilineCode = !inMultilineCode;
- i += 2; // Skip the next 2 backticks
- continue;
- }
-
- // Only check for inline code if not in multiline code
- if (!inMultilineCode && text[i] === "`") {
- inInlineCode = !inInlineCode;
- }
- }
-
- return inInlineCode || inMultilineCode;
-};
-
-// Checks if a backtick at position i is part of a triple backtick sequence
-export const isPartOfTripleBacktick = (text: string, i: number): boolean => {
- const isTripleStart = text.substring(i, i + 3) === "```";
- const isTripleMiddle = i > 0 && text.substring(i - 1, i + 2) === "```";
- const isTripleEnd = i > 1 && text.substring(i - 2, i + 1) === "```";
-
- return isTripleStart || isTripleMiddle || isTripleEnd;
-};
-
-// Counts single backticks that are not part of triple backticks or escaped
-export const countSingleBackticks = (text: string): number => {
- let count = 0;
- for (let i = 0; i < text.length; i += 1) {
- // Skip escaped backticks
- if (text[i] === "\\" && i + 1 < text.length && text[i + 1] === "`") {
- i += 1;
- continue;
- }
- if (text[i] === "`" && !isPartOfTripleBacktick(text, i)) {
- count += 1;
- }
- }
- return count;
-};
-
-// Check if a position is inside a COMPLETE inline code span (both opening and closing backtick present).
-// Returns false for incomplete inline code spans (streaming) so emphasis markers can still be completed.
+// Check if a position is within a COMPLETE inline code span (both opening and
+// closing backtick runs present). Returns false for incomplete spans
+// (streaming) so emphasis markers can still be completed.
export const isWithinCompleteInlineCode = (
text: string,
position: number
-): boolean => {
- let inInlineCode = false;
- let inMultilineCode = false;
- let inlineCodeStart = -1;
-
- for (let i = 0; i < text.length; i += 1) {
- // Skip escaped backticks
- if (text[i] === "\\" && i + 1 < text.length && text[i + 1] === "`") {
- i += 1;
- continue;
- }
-
- // Check for triple backticks (multiline code blocks)
- if (text.substring(i, i + 3) === "```") {
- inMultilineCode = !inMultilineCode;
- i += 2;
- continue;
- }
-
- // Only check for inline code if not in multiline code
- if (!inMultilineCode && text[i] === "`") {
- if (inInlineCode) {
- // Found closing backtick — check if position is inside this complete span
- if (inlineCodeStart < position && position < i) {
- return true;
- }
- inInlineCode = false;
- inlineCodeStart = -1;
- } else {
- inInlineCode = true;
- inlineCodeStart = i;
- }
- }
- }
-
- return false;
-};
+): boolean => isCompleteSpanAt(getScan(text), position);
diff --git a/packages/remend/src/emphasis-handlers.ts b/packages/remend/src/emphasis-handlers.ts
index 4c863a46..37da522a 100644
--- a/packages/remend/src/emphasis-handlers.ts
+++ b/packages/remend/src/emphasis-handlers.ts
@@ -14,16 +14,19 @@ import {
whitespaceOrMarkersPattern,
} from "./patterns";
import {
- isHorizontalRule,
- isWithinHtmlTag,
- isWithinLinkOrImageUrl,
- isWithinMathBlock,
- isWordChar,
-} from "./utils";
+ countDoublePairs,
+ getScan,
+ inHtmlTagAt,
+ inLinkUrlAt,
+ inMathAt,
+ REGION,
+ type TextScan,
+} from "./scan";
+import { isHorizontalRule, isWordChar } from "./utils";
// Helper function to check if an asterisk should be skipped
const shouldSkipAsterisk = (
- text: string,
+ scan: TextScan,
index: number,
prevChar: string,
nextChar: string
@@ -33,9 +36,8 @@ const shouldSkipAsterisk = (
return true;
}
- // Skip if within math block (only check if text has dollar signs)
- const hasMathBlocks = text.includes("$");
- if (hasMathBlocks && isWithinMathBlock(text, index)) {
+ // Skip if within math block
+ if (inMathAt(scan, index)) {
return true;
}
@@ -43,7 +45,8 @@ const shouldSkipAsterisk = (
// If this is the first * in ***, don't skip it - it can close a single * italic
// Example: **bold and *italic*** should count the first * of *** as closing the italic
if (prevChar !== "*" && nextChar === "*") {
- const nextNextChar = index < text.length - 2 ? text[index + 2] : "";
+ const nextNextChar =
+ index < scan.text.length - 2 ? scan.text[index + 2] : "";
if (nextNextChar === "*") {
// This is the first * in a *** sequence
// Count it as a single asterisk for matching purposes
@@ -76,40 +79,20 @@ const shouldSkipAsterisk = (
return false;
};
-// OPTIMIZATION: Counts single asterisks without split("").reduce()
-// Counts single asterisks that are not part of double asterisks, not escaped, not list markers, not word-internal,
-// and not inside fenced code blocks
export const countSingleAsterisks = (text: string): number => {
+ const scan = getScan(text);
let count = 0;
- let inCodeBlock = false;
const len = text.length;
for (let index = 0; index < len; index += 1) {
- // Track fenced code blocks (```)
- if (
- text[index] === "`" &&
- index + 2 < len &&
- text[index + 1] === "`" &&
- text[index + 2] === "`"
- ) {
- inCodeBlock = !inCodeBlock;
- index += 2;
- continue;
- }
-
- // Skip content inside fenced code blocks
- if (inCodeBlock) {
- continue;
- }
-
- if (text[index] !== "*") {
+ if (text[index] !== "*" || scan.regions[index] !== REGION.PROSE) {
continue;
}
const prevChar = index > 0 ? text[index - 1] : "";
const nextChar = index < len - 1 ? text[index + 1] : "";
- if (!shouldSkipAsterisk(text, index, prevChar, nextChar)) {
+ if (!shouldSkipAsterisk(scan, index, prevChar, nextChar)) {
count += 1;
}
}
@@ -119,7 +102,7 @@ export const countSingleAsterisks = (text: string): number => {
// Helper function to check if an underscore should be skipped
const shouldSkipUnderscore = (
- text: string,
+ scan: TextScan,
index: number,
prevChar: string,
nextChar: string
@@ -129,19 +112,18 @@ const shouldSkipUnderscore = (
return true;
}
- // Skip if within math block (only check if text has dollar signs)
- const hasMathBlocks = text.includes("$");
- if (hasMathBlocks && isWithinMathBlock(text, index)) {
+ // Skip if within math block
+ if (inMathAt(scan, index)) {
return true;
}
// Skip if within a link or image URL
- if (isWithinLinkOrImageUrl(text, index)) {
+ if (inLinkUrlAt(scan, index)) {
return true;
}
// Skip if within an HTML tag (e.g. )
- if (isWithinHtmlTag(text, index)) {
+ if (inHtmlTagAt(scan, index)) {
return true;
}
@@ -158,40 +140,20 @@ const shouldSkipUnderscore = (
return false;
};
-// OPTIMIZATION: Counts single underscores without split("").reduce()
-// Counts single underscores that are not part of double underscores, not escaped, not in math blocks,
-// and not inside fenced code blocks
export const countSingleUnderscores = (text: string): number => {
+ const scan = getScan(text);
let count = 0;
- let inCodeBlock = false;
const len = text.length;
for (let index = 0; index < len; index += 1) {
- // Track fenced code blocks (```)
- if (
- text[index] === "`" &&
- index + 2 < len &&
- text[index + 1] === "`" &&
- text[index + 2] === "`"
- ) {
- inCodeBlock = !inCodeBlock;
- index += 2;
- continue;
- }
-
- // Skip content inside fenced code blocks
- if (inCodeBlock) {
- continue;
- }
-
- if (text[index] !== "_") {
+ if (text[index] !== "_" || scan.regions[index] !== REGION.PROSE) {
continue;
}
const prevChar = index > 0 ? text[index - 1] : "";
const nextChar = index < len - 1 ? text[index + 1] : "";
- if (!shouldSkipUnderscore(text, index, prevChar, nextChar)) {
+ if (!shouldSkipUnderscore(scan, index, prevChar, nextChar)) {
count += 1;
}
}
@@ -200,37 +162,14 @@ export const countSingleUnderscores = (text: string): number => {
};
// Counts triple asterisks that are not part of quadruple or more asterisks
-// and not inside fenced code blocks
-// OPTIMIZATION: Count *** without regex to avoid allocation
+// and not inside code regions
export const countTripleAsterisks = (text: string): number => {
+ const scan = getScan(text);
let count = 0;
let consecutiveAsterisks = 0;
- let inCodeBlock = false;
for (let i = 0; i < text.length; i += 1) {
- // Track fenced code blocks (```)
- if (
- text[i] === "`" &&
- i + 2 < text.length &&
- text[i + 1] === "`" &&
- text[i + 2] === "`"
- ) {
- // Flush any pending asterisks before toggling
- if (consecutiveAsterisks >= 3) {
- count += Math.floor(consecutiveAsterisks / 3);
- }
- consecutiveAsterisks = 0;
- inCodeBlock = !inCodeBlock;
- i += 2;
- continue;
- }
-
- // Skip content inside fenced code blocks
- if (inCodeBlock) {
- continue;
- }
-
- if (text[i] === "*") {
+ if (text[i] === "*" && scan.regions[i] === REGION.PROSE) {
consecutiveAsterisks += 1;
} else {
// End of asterisk sequence
@@ -249,58 +188,127 @@ export const countTripleAsterisks = (text: string): number => {
return count;
};
-// Counts ** pairs outside fenced code blocks
-const countDoubleAsterisksOutsideCodeBlocks = (text: string): number => {
- let count = 0;
- let inCodeBlock = false;
+const countDoubleAsterisks = (text: string): number =>
+ countDoublePairs(text, "*");
+
+// Whether the text has an unmatched __ delimiter, counted per maximal
+// underscore run.
+//
+// Counting raw occurrences misreads identifiers: a name like snake__case
+// contains __ but cannot open or close emphasis, and counting it either
+// invents a closer (odd count) or pairs it against a real delimiter and
+// swallows a closer that was needed (even count). Per run:
+//
+// - A run contributes floor(length / 2) pairs, and flips delimiter parity
+// only when that is odd. __ and ___ flip; ____ does not.
+// - A word-internal run (word characters on both sides) is part of an
+// identifier, never a delimiter.
+// - Runs inside code regions, math, link URLs, and HTML tags are skipped,
+// matching the single-underscore handler's flanking rules.
+// - A run alone on its line is a thematic break, not emphasis.
+const isLineBoundaryChar = (char: string): boolean =>
+ char === "" || char === " " || char === "\t" || char === "\n";
+
+// isHorizontalRule scans the run's whole line, so its verdict is memoized
+// per line to keep run counting linear when many runs share a line
+interface ThematicBreakMemo {
+ lineEnd: number;
+ result: boolean;
+}
+
+const isThematicBreakRun = (
+ text: string,
+ runStart: number,
+ prevChar: string,
+ nextChar: string,
+ memo: ThematicBreakMemo
+): boolean => {
+ // A thematic break line holds only markers and whitespace, so only runs
+ // flanked by whitespace or line boundaries can be part of one
+ if (!(isLineBoundaryChar(prevChar) && isLineBoundaryChar(nextChar))) {
+ return false;
+ }
+ if (runStart > memo.lineEnd) {
+ const lineEnd = text.indexOf("\n", runStart);
+ memo.lineEnd = lineEnd === -1 ? text.length : lineEnd;
+ memo.result = isHorizontalRule(text, runStart, "_");
+ }
+ return memo.result;
+};
- for (let i = 0; i < text.length; i += 1) {
- if (
- text[i] === "`" &&
- i + 2 < text.length &&
- text[i + 1] === "`" &&
- text[i + 2] === "`"
- ) {
- inCodeBlock = !inCodeBlock;
- i += 2;
- continue;
- }
- if (inCodeBlock) {
- continue;
- }
- if (text[i] === "*" && i + 1 < text.length && text[i + 1] === "*") {
- count += 1;
- i += 1;
- }
+// Whether an underscore run at [runStart, runEnd) flips delimiter parity
+const doubleUnderscoreRunFlips = (
+ scan: TextScan,
+ initialRunStart: number,
+ runEnd: number,
+ memo: ThematicBreakMemo
+): boolean => {
+ const text = scan.text;
+
+ // A backslash escapes the first underscore of the run. The escaped
+ // underscore is literal punctuation, so the rest of the run still flanks
+ // as a delimiter.
+ let runStart = initialRunStart;
+ let escaped = false;
+ if (runStart > 0 && text[runStart - 1] === "\\") {
+ runStart += 1;
+ escaped = true;
}
- return count;
+ const runLength = runEnd - runStart;
+ if (runLength < 2) {
+ return false;
+ }
+
+ const beforeRun = runStart > 0 ? text[runStart - 1] : "";
+ const prevChar = escaped ? "\\" : beforeRun;
+ const nextChar = runEnd < text.length ? text[runEnd] : "";
+ if (isWordChar(prevChar) && isWordChar(nextChar)) {
+ return false;
+ }
+ if (isThematicBreakRun(text, runStart, prevChar, nextChar, memo)) {
+ return false;
+ }
+ if (
+ inMathAt(scan, runStart) ||
+ inLinkUrlAt(scan, runStart) ||
+ inHtmlTagAt(scan, runStart)
+ ) {
+ return false;
+ }
+
+ return Math.floor(runLength / 2) % 2 === 1;
};
-// Counts __ pairs outside fenced code blocks
-const countDoubleUnderscoresOutsideCodeBlocks = (text: string): number => {
- let count = 0;
- let inCodeBlock = false;
+const hasUnmatchedDoubleUnderscore = (text: string): boolean => {
+ const scan = getScan(text);
+ const n = text.length;
+ const memo: ThematicBreakMemo = { lineEnd: -1, result: false };
+ let unmatched = false;
+ let i = 0;
- for (let i = 0; i < text.length; i += 1) {
- if (
- text[i] === "`" &&
- i + 2 < text.length &&
- text[i + 1] === "`" &&
- text[i + 2] === "`"
- ) {
- inCodeBlock = !inCodeBlock;
- i += 2;
+ while (i < n) {
+ if (text[i] !== "_" || scan.regions[i] !== REGION.PROSE) {
+ i += 1;
continue;
}
- if (inCodeBlock) {
- continue;
+
+ const runStart = i;
+ let runEnd = i + 1;
+ while (
+ runEnd < n &&
+ text[runEnd] === "_" &&
+ scan.regions[runEnd] === REGION.PROSE
+ ) {
+ runEnd += 1;
}
- if (text[i] === "_" && i + 1 < text.length && text[i + 1] === "_") {
- count += 1;
- i += 1;
+ i = runEnd;
+
+ if (doubleUnderscoreRunFlips(scan, runStart, runEnd, memo)) {
+ unmatched = !unmatched;
}
}
- return count;
+
+ return unmatched;
};
// Helper to check if bold marker should not be completed
@@ -355,7 +363,7 @@ export const handleIncompleteBold = (text: string): string => {
return text;
}
- const asteriskPairs = countDoubleAsterisksOutsideCodeBlocks(text);
+ const asteriskPairs = countDoubleAsterisks(text);
if (asteriskPairs % 2 === 1) {
// Check for half-complete closing marker: **content* should become **content**
// The trailing * is the first char of the closing ** being streamed
@@ -414,12 +422,10 @@ export const handleIncompleteDoubleUnderscoreItalic = (
!(
isInsideCodeBlock(text, markerIndex) ||
isWithinCompleteInlineCode(text, markerIndex)
- )
+ ) &&
+ hasUnmatchedDoubleUnderscore(text)
) {
- const underscorePairs = countDoubleUnderscoresOutsideCodeBlocks(text);
- if (underscorePairs % 2 === 1) {
- return `${text}_`;
- }
+ return `${text}_`;
}
}
return text;
@@ -440,43 +446,25 @@ export const handleIncompleteDoubleUnderscoreItalic = (
return text;
}
- const underscorePairs = countDoubleUnderscoresOutsideCodeBlocks(text);
- if (underscorePairs % 2 === 1) {
+ if (hasUnmatchedDoubleUnderscore(text)) {
return `${text}__`;
}
return text;
};
-// Helper function to find the first single asterisk index (skips fenced code blocks)
-// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: asterisk detection requires many inline conditions
+// Skips code regions when locating the asterisk.
const findFirstSingleAsteriskIndex = (text: string): number => {
- let inCodeBlock = false;
+ const scan = getScan(text);
for (let i = 0; i < text.length; i += 1) {
- // Track fenced code blocks (```)
- if (
- text[i] === "`" &&
- i + 2 < text.length &&
- text[i + 1] === "`" &&
- text[i + 2] === "`"
- ) {
- inCodeBlock = !inCodeBlock;
- i += 2;
- continue;
- }
-
- // Skip content inside fenced code blocks
- if (inCodeBlock) {
- continue;
- }
-
if (
text[i] === "*" &&
+ scan.regions[i] === REGION.PROSE &&
text[i - 1] !== "*" &&
text[i + 1] !== "*" &&
text[i - 1] !== "\\" &&
- !isWithinMathBlock(text, i)
+ !inMathAt(scan, i)
) {
const prevChar = i > 0 ? text[i - 1] : "";
const nextChar = i < text.length - 1 ? text[i + 1] : "";
@@ -550,35 +538,18 @@ export const handleIncompleteSingleAsteriskItalic = (text: string): string => {
return text;
};
-// Helper function to find the first single underscore index (skips fenced code blocks)
const findFirstSingleUnderscoreIndex = (text: string): number => {
- let inCodeBlock = false;
+ const scan = getScan(text);
for (let i = 0; i < text.length; i += 1) {
- // Track fenced code blocks (```)
- if (
- text[i] === "`" &&
- i + 2 < text.length &&
- text[i + 1] === "`" &&
- text[i + 2] === "`"
- ) {
- inCodeBlock = !inCodeBlock;
- i += 2;
- continue;
- }
-
- // Skip content inside fenced code blocks
- if (inCodeBlock) {
- continue;
- }
-
if (
text[i] === "_" &&
+ scan.regions[i] === REGION.PROSE &&
text[i - 1] !== "_" &&
text[i + 1] !== "_" &&
text[i - 1] !== "\\" &&
- !isWithinMathBlock(text, i) &&
- !isWithinLinkOrImageUrl(text, i)
+ !inMathAt(scan, i) &&
+ !inLinkUrlAt(scan, i)
) {
// Check if underscore is word-internal (between word characters)
const prevChar = i > 0 ? text[i - 1] : "";
@@ -621,7 +592,7 @@ const handleTrailingAsterisksForUnderscore = (text: string): string | null => {
}
const textWithoutTrailingAsterisks = text.slice(0, -2);
- const asteriskPairsAfterRemoval = countDoubleAsterisksOutsideCodeBlocks(
+ const asteriskPairsAfterRemoval = countDoubleAsterisks(
textWithoutTrailingAsterisks
);
@@ -699,7 +670,7 @@ export const handleIncompleteSingleUnderscoreItalic = (
// Helper to check if bold-italic markers are already balanced
const areBoldItalicMarkersBalanced = (text: string): boolean => {
- const asteriskPairs = countDoubleAsterisksOutsideCodeBlocks(text);
+ const asteriskPairs = countDoubleAsterisks(text);
const singleAsterisks = countSingleAsterisks(text);
return asteriskPairs % 2 === 0 && singleAsterisks % 2 === 0;
};
diff --git a/packages/remend/src/index.ts b/packages/remend/src/index.ts
index 005c9fea..9d606d18 100644
--- a/packages/remend/src/index.ts
+++ b/packages/remend/src/index.ts
@@ -305,6 +305,13 @@ const remend = (text: string, options?: RemendOptions): string => {
}
}
+ // A handler that removes a trailing fragment can expose a trailing space
+ // (e.g. dropping an incomplete image). Strip it the same way the input
+ // was stripped, so healed output re-heals to itself.
+ if (result.endsWith(" ") && !result.endsWith(" ")) {
+ return result.slice(0, -1);
+ }
+
return result;
};
diff --git a/packages/remend/src/inline-code-handler.ts b/packages/remend/src/inline-code-handler.ts
index b77c2c32..ed079a79 100644
--- a/packages/remend/src/inline-code-handler.ts
+++ b/packages/remend/src/inline-code-handler.ts
@@ -1,61 +1,45 @@
-import { countSingleBackticks } from "./code-block-utils";
-import {
- inlineCodePattern,
- inlineTripleBacktickPattern,
- whitespaceOrMarkersPattern,
-} from "./patterns";
+import { whitespaceOrMarkersPattern } from "./patterns";
+import { getScan } from "./scan";
+
+// Completes an unclosed inline code span (`)
+//
+// A span opened by a run of N backticks closes only on a run of exactly N,
+// so the completion appends whatever remains of the closing run. If the text
+// already ends with a partial closing run of k < N backticks, only N - k are
+// appended.
+export const handleIncompleteInlineCode = (text: string): string => {
+ const scan = getScan(text);
-// Helper function to check for incomplete inline triple backticks
-const handleInlineTripleBackticks = (text: string): string | null => {
- const inlineTripleBacktickMatch = text.match(inlineTripleBacktickPattern);
- if (!inlineTripleBacktickMatch || text.includes("\n")) {
- return null;
+ // Inside an unterminated fenced code block, backticks are content and the
+ // block is left for the renderer to display as streaming code
+ if (scan.openFence) {
+ return text;
}
- // Check if it ends with exactly 2 backticks (incomplete)
- if (text.endsWith("``") && !text.endsWith("```")) {
- return `${text}\``;
+ const span = scan.openSpan;
+ if (!span) {
+ return text;
}
- // Already complete inline triple backticks
- return text;
-};
-
-// Helper function to check if we're inside an incomplete code block
-const isInsideIncompleteCodeBlock = (text: string): boolean => {
- const allTripleBackticks = (text.match(/```/g) || []).length;
- return allTripleBackticks % 2 === 1;
-};
-// Completes incomplete inline code formatting (`)
-// Avoids completing if inside an incomplete code block
-export const handleIncompleteInlineCode = (text: string): string => {
- // Check if we have inline triple backticks (starts with ``` and should end with ```)
- // This pattern should ONLY match truly inline code (no newlines)
- // Examples: ```code``` or ```python code```
- const inlineResult = handleInlineTripleBackticks(text);
- if (inlineResult !== null) {
- return inlineResult;
+ // Don't close if there's no meaningful content after the opening run
+ const content = text.slice(span.start + span.runLength);
+ if (!content || whitespaceOrMarkersPattern.test(content)) {
+ return text;
}
- const inlineCodeMatch = text.match(inlineCodePattern);
-
- if (inlineCodeMatch && !isInsideIncompleteCodeBlock(text)) {
- // Don't close if there's no meaningful content after the opening marker
- // inlineCodeMatch[2] contains the content after `
- // Check if content is only whitespace or other emphasis markers
- const contentAfterMarker = inlineCodeMatch[2];
- if (
- !contentAfterMarker ||
- whitespaceOrMarkersPattern.test(contentAfterMarker)
- ) {
- return text;
- }
+ // A trailing backtick run is the closing run being streamed
+ let trailingRun = 0;
+ let i = text.length - 1;
+ while (i >= 0 && text[i] === "`") {
+ trailingRun += 1;
+ i -= 1;
+ }
- const singleBacktickCount = countSingleBackticks(text);
- if (singleBacktickCount % 2 === 1) {
- return `${text}\``;
- }
+ // A trailing run at least as long as the opener is a literal run inside
+ // the span. Appending backticks would only extend it, never close the span.
+ if (trailingRun >= span.runLength) {
+ return text;
}
- return text;
+ return text + "`".repeat(span.runLength - trailingRun);
};
diff --git a/packages/remend/src/katex-handler.ts b/packages/remend/src/katex-handler.ts
index 765542f1..c274fd70 100644
--- a/packages/remend/src/katex-handler.ts
+++ b/packages/remend/src/katex-handler.ts
@@ -1,32 +1,11 @@
-// Helper function to check if a backtick is part of a triple backtick
-const isTripleBacktick = (text: string, index: number): boolean =>
- (index >= 2 && text.substring(index - 2, index + 1) === "```") ||
- (index >= 1 && text.substring(index - 1, index + 2) === "```") ||
- (index <= text.length - 3 && text.substring(index, index + 3) === "```");
-
-// Helper function to count $$ pairs outside of inline code blocks
-const countDollarPairs = (text: string): number => {
- let dollarPairs = 0;
- let inInlineCode = false;
-
- for (let i = 0; i < text.length - 1; i += 1) {
- if (text[i] === "`" && !isTripleBacktick(text, i)) {
- inInlineCode = !inInlineCode;
- }
-
- if (!inInlineCode && text[i] === "$" && text[i + 1] === "$") {
- dollarPairs += 1;
- i += 1;
- }
- }
+import { countDoublePairs, getScan, REGION } from "./scan";
- return dollarPairs;
-};
+const countDollarPairs = (text: string): number => countDoublePairs(text, "$");
-// Helper function to count single $ signs (excluding $$) outside of code blocks
+// Excludes $$ pairs and any $ inside code regions.
const countSingleDollars = (text: string): number => {
+ const scan = getScan(text);
let count = 0;
- let inInlineCode = false;
for (let i = 0; i < text.length; i += 1) {
if (text[i] === "\\") {
@@ -34,12 +13,11 @@ const countSingleDollars = (text: string): number => {
continue;
}
- if (text[i] === "`" && !isTripleBacktick(text, i)) {
- inInlineCode = !inInlineCode;
+ if (scan.regions[i] !== REGION.PROSE) {
continue;
}
- if (!inInlineCode && text[i] === "$") {
+ if (text[i] === "$") {
if (i + 1 < text.length && text[i + 1] === "$") {
i += 1;
} else {
diff --git a/packages/remend/src/link-image-handler.ts b/packages/remend/src/link-image-handler.ts
index 067fd45d..4fb75688 100644
--- a/packages/remend/src/link-image-handler.ts
+++ b/packages/remend/src/link-image-handler.ts
@@ -131,11 +131,8 @@ const handleIncompleteText = (
return null;
};
-// Handles incomplete links and images by preserving them with a special marker
-export const handleIncompleteLinksAndImages = (
- text: string,
- linkMode: LinkMode = "protocol"
-): string => {
+// One healing step over the trailing incomplete link or image, if any
+const healTrailingLinkOrImage = (text: string, linkMode: LinkMode): string => {
// Look for patterns like [text]( or : string => {
+ let current = text;
+
+ // Removing a trailing incomplete image can expose another incomplete
+ // construct that ended immediately before it (an input ending in "![!["
+ // heals to "![", which is itself incomplete). Iterate until healed text
+ // re-heals to itself, so healing is idempotent. A step that grows the text
+ // has completed the construct, and truncating steps strictly shorten,
+ // so the loop terminates.
+ for (let pass = 0; pass < MAX_HEAL_PASSES; pass += 1) {
+ const next = healTrailingLinkOrImage(current, linkMode);
+ if (next.length >= current.length) {
+ return next;
+ }
+ current = next;
+ }
+ return current;
+};
diff --git a/packages/remend/src/patterns.ts b/packages/remend/src/patterns.ts
index 50700792..9e57b07e 100644
--- a/packages/remend/src/patterns.ts
+++ b/packages/remend/src/patterns.ts
@@ -3,16 +3,10 @@ export const italicPattern = /(__)([^_]*?)$/;
export const boldItalicPattern = /(\*\*\*)([^*]*?)$/;
export const singleAsteriskPattern = /(\*)([^*]*?)$/;
export const singleUnderscorePattern = /(_)([^_]*?)$/;
-export const inlineCodePattern = /(`)([^`]*?)$/;
export const strikethroughPattern = /(~~)([^~]*?)$/;
export const whitespaceOrMarkersPattern = /^[\s_~*`]*$/;
export const listItemPattern = /^[\s]*[-*+][\s]+$/;
export const letterNumberUnderscorePattern = /[\p{L}\p{N}_]/u;
-export const inlineTripleBacktickPattern = /^```[^`\n]*```?$/;
export const fourOrMoreAsterisksPattern = /^\*{4,}$/;
-export const linkImagePattern = /(!?\[)([^\]]*?)$/;
-export const incompleteLinkUrlPattern = /(!?)\[([^\]]+)\](\([^)]+)$/;
export const halfCompleteUnderscorePattern = /(__)([^_]+)_$/;
export const halfCompleteTildePattern = /(~~)([^~]+)~$/;
-export const doubleUnderscoreGlobalPattern = /__/g;
-export const doubleTildeGlobalPattern = /~~/g;
diff --git a/packages/remend/src/scan.ts b/packages/remend/src/scan.ts
new file mode 100644
index 00000000..3de71783
--- /dev/null
+++ b/packages/remend/src/scan.ts
@@ -0,0 +1,482 @@
+// Single-pass classification of a text into code and prose regions.
+//
+// Healing runs on every streaming token, and every handler needs to know
+// whether a candidate delimiter sits in prose or in code. A single scan
+// paints a region code for every position, memoized per input string, so
+// each query is O(1) and healing stays linear in the input no matter how
+// many delimiters it holds. The lazy masks below answer the same question
+// for math, link URLs, and HTML tags.
+//
+// Fence and span semantics follow CommonMark:
+//
+// - A fence opens only at the start of a line, with any indentation. CommonMark
+// caps a top-level fence at 3 spaces, but fences nested in list items carry
+// deeper absolute indents and a line-based scan has no list context. Reading
+// an indented line as code is the safe direction: healing then leaves it
+// alone instead of corrupting it.
+// - Both ``` and ~~~ fences are recognized, with runs of 3 or more.
+// - The info string of a backtick fence cannot contain a backtick
+// (a line like ```code``` is inline code, not a fence).
+// - A fence closes on a run of the same character at least as long as the
+// opener, alone on its line. Lines may end in \n or \r\n.
+// - An inline code span opened by a run of N backticks closes only on a run
+// of exactly N backticks. Other runs are literal inside the span.
+// - A span cannot cross a blank line: inline parsing is paragraph-scoped, so
+// an unmatched run in a finished paragraph stays literal prose.
+
+export const REGION = {
+ PROSE: 0,
+ /** The ``` or ~~~ run that opens or closes a fence */
+ FENCE_MARKER: 1,
+ /** The info string on a fence opener line. Neither prose nor code body. */
+ FENCE_INFO: 2,
+ FENCE_BODY: 3,
+ /** A complete inline code span, including its backtick markers */
+ CODE_SPAN: 4,
+ /** An inline code span whose closing run has not arrived yet */
+ CODE_SPAN_OPEN: 5,
+} as const;
+
+export type Region = (typeof REGION)[keyof typeof REGION];
+
+export interface OpenFence {
+ char: "`" | "~";
+ /** Length of the opening run; a closer must be at least this long */
+ length: number;
+}
+
+export interface OpenSpan {
+ /** Length of the opening run; the closer must match it exactly */
+ runLength: number;
+ /** Index of the first backtick of the opening run */
+ start: number;
+}
+
+export interface TextScan {
+ htmlTagMask: Uint8Array | null;
+ linkUrlMask: Uint8Array | null;
+ /** Lazily computed masks backing the inMathAt/inLinkUrlAt/inHtmlTagAt helpers */
+ mathMask: Uint8Array | null;
+ /** Fence still open at end of text, if any */
+ openFence: OpenFence | null;
+ /** Inline code span still open at end of text, if any */
+ openSpan: OpenSpan | null;
+ regions: Uint8Array;
+ text: string;
+}
+
+const FENCE_OPENER_PATTERN = /^( *)(`{3,}|~{3,})(.*)$/;
+
+const paintFenceOpener = (
+ regions: Uint8Array,
+ lineStart: number,
+ lineEnd: number,
+ indentLength: number,
+ markerLength: number
+): void => {
+ const markerStart = lineStart + indentLength;
+ regions.fill(REGION.FENCE_MARKER, markerStart, markerStart + markerLength);
+ // Info string plus the line terminator belong to the fence.
+ regions.fill(
+ REGION.FENCE_INFO,
+ markerStart + markerLength,
+ Math.min(lineEnd + 1, regions.length)
+ );
+};
+
+// Whether a line inside an open fence closes it: optional indent, then a run
+// of the fence character at least as long as the opener, then only whitespace
+const isFenceCloser = (
+ text: string,
+ lineStart: number,
+ lineEnd: number,
+ fence: OpenFence
+): boolean => {
+ let i = lineStart;
+ while (i < lineEnd && text[i] === " ") {
+ i += 1;
+ }
+ let runLength = 0;
+ while (i < lineEnd && text[i] === fence.char) {
+ i += 1;
+ runLength += 1;
+ }
+ if (runLength < fence.length) {
+ return false;
+ }
+ while (i < lineEnd) {
+ if (text[i] !== " " && text[i] !== "\t" && text[i] !== "\r") {
+ return false;
+ }
+ i += 1;
+ }
+ return true;
+};
+
+const paintFences = (text: string, regions: Uint8Array): OpenFence | null => {
+ const n = text.length;
+ let openFence: OpenFence | null = null;
+ let lineStart = 0;
+
+ while (lineStart < n) {
+ let lineEnd = text.indexOf("\n", lineStart);
+ if (lineEnd === -1) {
+ lineEnd = n;
+ }
+
+ if (openFence) {
+ if (isFenceCloser(text, lineStart, lineEnd, openFence)) {
+ regions.fill(REGION.FENCE_MARKER, lineStart, lineEnd);
+ openFence = null;
+ } else {
+ regions.fill(REGION.FENCE_BODY, lineStart, Math.min(lineEnd + 1, n));
+ }
+ } else {
+ const contentEnd =
+ lineEnd > lineStart && text[lineEnd - 1] === "\r"
+ ? lineEnd - 1
+ : lineEnd;
+ const line = text.slice(lineStart, contentEnd);
+ const opener = line.match(FENCE_OPENER_PATTERN);
+ if (opener) {
+ const markerChar = opener[2][0] as "`" | "~";
+ // A backtick fence's info string cannot contain a backtick; such a
+ // line is inline code instead
+ if (markerChar === "~" || !opener[3].includes("`")) {
+ paintFenceOpener(
+ regions,
+ lineStart,
+ lineEnd,
+ opener[1].length,
+ opener[2].length
+ );
+ openFence = { char: markerChar, length: opener[2].length };
+ }
+ }
+ }
+
+ lineStart = lineEnd + 1;
+ }
+
+ return openFence;
+};
+
+const measureBacktickRun = (text: string, start: number): number => {
+ let end = start + 1;
+ while (end < text.length && text[end] === "`") {
+ end += 1;
+ }
+ return end;
+};
+
+// A blank line ends the paragraph, and with it any chance of closing a span
+const isParagraphBreakAt = (text: string, newlineIndex: number): boolean => {
+ let j = newlineIndex + 1;
+ while (
+ j < text.length &&
+ (text[j] === " " || text[j] === "\t" || text[j] === "\r")
+ ) {
+ j += 1;
+ }
+ return j < text.length && text[j] === "\n";
+};
+
+// Paint inline code spans in the regions the fence pass left as prose
+const paintSpans = (text: string, regions: Uint8Array): OpenSpan | null => {
+ const n = text.length;
+ let spanStart = -1;
+ let spanRunLength = 0;
+ let i = 0;
+
+ while (i < n) {
+ if (regions[i] !== REGION.PROSE) {
+ // A span cannot cross into a fence, so leave it marked open up to here
+ if (spanStart >= 0) {
+ regions.fill(REGION.CODE_SPAN_OPEN, spanStart, i);
+ spanStart = -1;
+ }
+ i += 1;
+ continue;
+ }
+ if (text[i] === "\n" && spanStart >= 0 && isParagraphBreakAt(text, i)) {
+ // The unmatched opener stays literal prose in its finished paragraph
+ spanStart = -1;
+ i += 1;
+ continue;
+ }
+ if (text[i] === "\\" && text[i + 1] === "`" && spanStart < 0) {
+ i += 2;
+ continue;
+ }
+ if (text[i] !== "`") {
+ i += 1;
+ continue;
+ }
+
+ const runEnd = measureBacktickRun(text, i);
+ const runLength = runEnd - i;
+ if (spanStart < 0) {
+ spanStart = i;
+ spanRunLength = runLength;
+ } else if (runLength === spanRunLength) {
+ regions.fill(REGION.CODE_SPAN, spanStart, runEnd);
+ spanStart = -1;
+ }
+ // A run of a different length is literal inside the open span
+ i = runEnd;
+ }
+
+ if (spanStart >= 0) {
+ regions.fill(REGION.CODE_SPAN_OPEN, spanStart, n);
+ return { start: spanStart, runLength: spanRunLength };
+ }
+ return null;
+};
+
+const scanText = (text: string): TextScan => {
+ const regions = new Uint8Array(text.length);
+ const openFence = paintFences(text, regions);
+ const openSpan = paintSpans(text, regions);
+ return {
+ text,
+ regions,
+ openFence,
+ openSpan,
+ mathMask: null,
+ linkUrlMask: null,
+ htmlTagMask: null,
+ };
+};
+
+// Memoize the most recent scan. Handlers query many positions of the same
+// string, and remend's handler chain passes each handler's output to the
+// next, so a single-entry cache gives O(1) queries within a handler while
+// staying O(n) per handler overall.
+let cachedText: string | null = null;
+let cachedScan: TextScan | null = null;
+
+export const getScan = (text: string): TextScan => {
+ if (cachedScan !== null && text === cachedText) {
+ return cachedScan;
+ }
+ const scan = scanText(text);
+ cachedText = text;
+ cachedScan = scan;
+ return scan;
+};
+
+/** A code construct here means a fence or inline span. */
+export const isCodeAt = (scan: TextScan, position: number): boolean => {
+ if (position >= scan.regions.length) {
+ return scan.openFence !== null || scan.openSpan !== null;
+ }
+ if (position < 0) {
+ return false;
+ }
+ return scan.regions[position] !== REGION.PROSE;
+};
+
+/** Whether the position is inside a fenced code block (marker, info, or body) */
+export const isFenceAt = (scan: TextScan, position: number): boolean => {
+ if (position >= scan.regions.length) {
+ return scan.openFence !== null;
+ }
+ if (position < 0) {
+ return false;
+ }
+ const region = scan.regions[position];
+ return (
+ region === REGION.FENCE_MARKER ||
+ region === REGION.FENCE_INFO ||
+ region === REGION.FENCE_BODY
+ );
+};
+
+export const isCompleteSpanAt = (scan: TextScan, position: number): boolean =>
+ scan.regions[position] === REGION.CODE_SPAN;
+
+/** Counts non-overlapping double-character pairs (**, ~~, $$) in prose */
+export const countDoublePairs = (text: string, char: string): number => {
+ const scan = getScan(text);
+ let count = 0;
+
+ for (let i = 0; i < text.length; i += 1) {
+ if (scan.regions[i] !== REGION.PROSE) {
+ continue;
+ }
+ if (text[i] === char && i + 1 < text.length && text[i + 1] === char) {
+ count += 1;
+ i += 1;
+ }
+ }
+ return count;
+};
+
+// The masks share the empty array when their trigger character is absent, so
+// plain prose skips three allocations and passes per scan
+const EMPTY_MASK = new Uint8Array(0);
+
+// Math mask: for each position, whether it is inside $...$ or $$...$$.
+// Delimiters inside code regions are literal and do not toggle math state.
+const buildMathMask = (scan: TextScan): Uint8Array => {
+ const { text, regions } = scan;
+ const n = text.length;
+ const mask = new Uint8Array(n);
+ let inInlineMath = false;
+ let inBlockMath = false;
+
+ let i = 0;
+ while (i < n) {
+ mask[i] = inInlineMath || inBlockMath ? 1 : 0;
+ if (regions[i] !== REGION.PROSE) {
+ i += 1;
+ continue;
+ }
+ if (text[i] === "\\" && text[i + 1] === "$") {
+ mask[i + 1] = mask[i];
+ i += 2;
+ continue;
+ }
+ if (text[i] !== "$") {
+ i += 1;
+ continue;
+ }
+ if (text[i + 1] === "$") {
+ inBlockMath = !inBlockMath;
+ inInlineMath = false;
+ mask[i + 1] = 1;
+ i += 2;
+ continue;
+ }
+ if (!inBlockMath) {
+ inInlineMath = !inInlineMath;
+ }
+ i += 1;
+ }
+
+ return mask;
+};
+
+export const inMathAt = (scan: TextScan, position: number): boolean => {
+ if (position < 0 || position >= scan.text.length) {
+ return false;
+ }
+ if (scan.mathMask === null) {
+ scan.mathMask = scan.text.includes("$") ? buildMathMask(scan) : EMPTY_MASK;
+ }
+ return scan.mathMask[position] === 1;
+};
+
+// Marks the URL positions of one line: those between a "](" opener and the
+// next ")" on the line. Two sub-passes: backward to know whether a ")" still
+// follows a position, forward to know whether the nearest paren boundary
+// before a position is a "](" opener.
+const paintLinkUrlLine = (
+ scan: TextScan,
+ lineStart: number,
+ lineEnd: number,
+ mask: Uint8Array
+): void => {
+ const { text, regions } = scan;
+ // closerFollows[i - lineStart]: a ")" exists at or after i on this line
+ const closerFollows = new Uint8Array(lineEnd - lineStart);
+ let seenCloser = 0;
+ for (let i = lineEnd - 1; i >= lineStart; i -= 1) {
+ if (text[i] === ")" && regions[i] === REGION.PROSE) {
+ seenCloser = 1;
+ }
+ closerFollows[i - lineStart] = seenCloser;
+ }
+
+ let inUrl = false;
+ for (let i = lineStart; i < lineEnd; i += 1) {
+ if (inUrl && closerFollows[i - lineStart] === 1) {
+ mask[i] = 1;
+ }
+ if (regions[i] !== REGION.PROSE) {
+ continue;
+ }
+ if (text[i] === ")") {
+ inUrl = false;
+ } else if (text[i] === "(") {
+ inUrl = i > 0 && text[i - 1] === "]";
+ }
+ }
+};
+
+// Link/image URL mask: positions inside the (url) part of [text](url).
+// Delimiters inside code regions are literal and never open or close a URL.
+const buildLinkUrlMask = (scan: TextScan): Uint8Array => {
+ const { text } = scan;
+ const n = text.length;
+ const mask = new Uint8Array(n);
+ let lineStart = 0;
+
+ while (lineStart < n) {
+ let lineEnd = text.indexOf("\n", lineStart);
+ if (lineEnd === -1) {
+ lineEnd = n;
+ }
+ paintLinkUrlLine(scan, lineStart, lineEnd, mask);
+ lineStart = lineEnd + 1;
+ }
+
+ return mask;
+};
+
+export const inLinkUrlAt = (scan: TextScan, position: number): boolean => {
+ if (position < 0 || position >= scan.text.length) {
+ return false;
+ }
+ if (scan.linkUrlMask === null) {
+ scan.linkUrlMask = scan.text.includes("](")
+ ? buildLinkUrlMask(scan)
+ : EMPTY_MASK;
+ }
+ return scan.linkUrlMask[position] === 1;
+};
+
+// HTML tag mask: positions after a "<" that begins a plausible tag (letter
+// or /), through the closing ">" inclusive, within a single line. Angle
+// brackets inside code regions are literal and never open or close a tag.
+const buildHtmlTagMask = (scan: TextScan): Uint8Array => {
+ const { text, regions } = scan;
+ const n = text.length;
+ const mask = new Uint8Array(n);
+ let inTag = false;
+
+ for (let i = 0; i < n; i += 1) {
+ if (text[i] === "\n") {
+ inTag = false;
+ continue;
+ }
+ mask[i] = inTag ? 1 : 0;
+ if (regions[i] !== REGION.PROSE) {
+ continue;
+ }
+ if (text[i] === ">") {
+ inTag = false;
+ } else if (text[i] === "<") {
+ const next = text[i + 1];
+ inTag =
+ next !== undefined &&
+ ((next >= "a" && next <= "z") ||
+ (next >= "A" && next <= "Z") ||
+ next === "/");
+ }
+ }
+
+ return mask;
+};
+
+export const inHtmlTagAt = (scan: TextScan, position: number): boolean => {
+ if (position < 0 || position >= scan.text.length) {
+ return false;
+ }
+ if (scan.htmlTagMask === null) {
+ scan.htmlTagMask = scan.text.includes("<")
+ ? buildHtmlTagMask(scan)
+ : EMPTY_MASK;
+ }
+ return scan.htmlTagMask[position] === 1;
+};
diff --git a/packages/remend/src/strikethrough-handler.ts b/packages/remend/src/strikethrough-handler.ts
index bbb22dd4..61e9e2a8 100644
--- a/packages/remend/src/strikethrough-handler.ts
+++ b/packages/remend/src/strikethrough-handler.ts
@@ -3,11 +3,16 @@ import {
isWithinCompleteInlineCode,
} from "./code-block-utils";
import {
- doubleTildeGlobalPattern,
halfCompleteTildePattern,
strikethroughPattern,
whitespaceOrMarkersPattern,
} from "./patterns";
+import { countDoublePairs } from "./scan";
+
+// Tilde runs that open or close a fence are painted as fence regions by the
+// scanner, so a line-start ~~~ fence never counts as strikethrough while a
+// mid-line tilde run still does.
+const countDoubleTildes = (text: string): number => countDoublePairs(text, "~");
// Completes incomplete strikethrough formatting (~~)
export const handleIncompleteStrikethrough = (text: string): string => {
@@ -34,9 +39,7 @@ export const handleIncompleteStrikethrough = (text: string): string => {
return text;
}
- // doubleTildeGlobalPattern always matches when strikethroughPattern matched
- const tildePairs = text.match(doubleTildeGlobalPattern)?.length;
- if (tildePairs % 2 === 1) {
+ if (countDoubleTildes(text) % 2 === 1) {
return `${text}~~`;
}
} else {
@@ -52,9 +55,7 @@ export const handleIncompleteStrikethrough = (text: string): string => {
) {
return text;
}
- // doubleTildeGlobalPattern always matches when halfCompleteTildePattern matched
- const tildePairs = text.match(doubleTildeGlobalPattern)?.length;
- if (tildePairs % 2 === 1) {
+ if (countDoubleTildes(text) % 2 === 1) {
return `${text}~`;
}
}
diff --git a/packages/remend/src/utils.ts b/packages/remend/src/utils.ts
index ac0a7d14..06291fd0 100644
--- a/packages/remend/src/utils.ts
+++ b/packages/remend/src/utils.ts
@@ -1,4 +1,5 @@
import { letterNumberUnderscorePattern } from "./patterns";
+import { getScan, inHtmlTagAt, inLinkUrlAt, inMathAt, isFenceAt } from "./scan";
// OPTIMIZATION: Precompute which characters are word characters
// Using ASCII fast path before falling back to Unicode regex
@@ -20,20 +21,8 @@ export const isWordChar = (char: string): boolean => {
return letterNumberUnderscorePattern.test(char);
};
-// Check if a position is within a code block (between ``` markers)
-export const isWithinCodeBlock = (text: string, position: number): boolean => {
- let inCodeBlock = false;
-
- for (let i = 0; i < position; i += 1) {
- // Check for triple backticks
- if (text[i] === "`" && text[i + 1] === "`" && text[i + 2] === "`") {
- inCodeBlock = !inCodeBlock;
- i += 2; // Skip the next two backticks
- }
- }
-
- return inCodeBlock;
-};
+export const isWithinCodeBlock = (text: string, position: number): boolean =>
+ isFenceAt(getScan(text), position);
// Helper function to find the matching opening bracket for a closing bracket
// Handles nested brackets correctly by searching backwards
@@ -76,101 +65,20 @@ export const findMatchingClosingBracket = (
};
// Check if a position is within a math block (between $ or $$)
-export const isWithinMathBlock = (text: string, position: number): boolean => {
- // Count dollar signs before this position
- let inInlineMath = false;
- let inBlockMath = false;
-
- for (let i = 0; i < text.length && i < position; i += 1) {
- // Skip escaped dollar signs
- if (text[i] === "\\" && text[i + 1] === "$") {
- i += 1; // Skip the next character
- continue;
- }
-
- if (text[i] === "$") {
- // Check for block math ($$)
- if (text[i + 1] === "$") {
- inBlockMath = !inBlockMath;
- i += 1; // Skip the second $
- inInlineMath = false; // Block math takes precedence
- } else if (!inBlockMath) {
- // Only toggle inline math if not in block math
- inInlineMath = !inInlineMath;
- }
- }
- }
-
- return inInlineMath || inBlockMath;
-};
-
-// Helper to check if position is before closing paren on same line
-const isBeforeClosingParen = (text: string, position: number): boolean => {
- for (let j = position; j < text.length; j += 1) {
- if (text[j] === ")") {
- return true;
- }
- if (text[j] === "\n") {
- return false;
- }
- }
- return false;
-};
+export const isWithinMathBlock = (text: string, position: number): boolean =>
+ inMathAt(getScan(text), position);
// Check if a position is within a link or image URL
// Links and images have the format [text](url) or 
export const isWithinLinkOrImageUrl = (
text: string,
position: number
-): boolean => {
- // Search backwards from position to find if we're inside a (url) part
- for (let i = position - 1; i >= 0; i -= 1) {
- if (text[i] === ")") {
- return false;
- }
- if (text[i] === "(") {
- // Check if there's a ] immediately before the (
- if (i > 0 && text[i - 1] === "]") {
- // We're potentially inside a link/image URL
- // Check if we're before the closing )
- return isBeforeClosingParen(text, position);
- }
- return false;
- }
- if (text[i] === "\n") {
- return false;
- }
- }
-
- return false;
-};
+): boolean => inLinkUrlAt(getScan(text), position);
// Check if a position is within an HTML tag (between < and >)
// e.g. — the underscore in _blank is inside the tag
-export const isWithinHtmlTag = (text: string, position: number): boolean => {
- // Search backwards from position to find < or >
- for (let i = position - 1; i >= 0; i -= 1) {
- if (text[i] === ">") {
- return false; // Found closing > first — we're outside a tag
- }
- if (text[i] === "<") {
- // Found opening < — check it starts a valid tag (followed by letter or /)
- const nextChar = i + 1 < text.length ? text[i + 1] : "";
- if (
- (nextChar >= "a" && nextChar <= "z") ||
- (nextChar >= "A" && nextChar <= "Z") ||
- nextChar === "/"
- ) {
- return true;
- }
- return false;
- }
- if (text[i] === "\n") {
- return false; // Tags don't span lines in this context
- }
- }
- return false;
-};
+export const isWithinHtmlTag = (text: string, position: number): boolean =>
+ inHtmlTagAt(getScan(text), position);
// Check if a marker sequence appears to be a horizontal rule
// Horizontal rules must be on their own line with optional leading/trailing whitespace
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a055b22c..733ade41 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -158,7 +158,7 @@ importers:
version: 1.6.1(next@16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
'@vercel/geistdocs':
specifier: 1.11.0
- version: 1.11.0(@svta/cml-cta@1.0.1(@svta/cml-structured-field-values@1.0.1(@svta/cml-utils@1.0.1))(@svta/cml-utils@1.0.1))(@svta/cml-structured-field-values@1.0.1(@svta/cml-utils@1.0.1))(@svta/cml-utils@1.0.1)(@tanstack/react-router@1.151.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/mdast@4.0.4)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(micromark-util-types@2.0.2)(micromark@4.0.2)(next@16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(unified@11.0.5)(vite@7.3.1(@types/node@24.10.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
+ version: 1.11.0(@svta/cml-cta@1.0.1(@svta/cml-structured-field-values@1.0.1(@svta/cml-utils@1.0.1))(@svta/cml-utils@1.0.1))(@svta/cml-structured-field-values@1.0.1(@svta/cml-utils@1.0.1))(@svta/cml-utils@1.0.1)(@tanstack/react-router@1.151.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/mdast@4.0.4)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(next@16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(unified@11.0.5)(vite@7.3.1(@types/node@24.10.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
'@vercel/speed-insights':
specifier: ^1.3.1
version: 1.3.1(next@16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
@@ -268,6 +268,9 @@ importers:
'@vitest/coverage-v8':
specifier: ^4.0.15
version: 4.0.17(vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@25.0.9)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(tsx@4.21.0))
+ fast-check:
+ specifier: ^4.9.0
+ version: 4.9.0
mdast-util-from-markdown:
specifier: ^2.0.2
version: 2.0.2
@@ -4241,6 +4244,10 @@ packages:
extendable-error@0.1.7:
resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==}
+ fast-check@4.9.0:
+ resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==}
+ engines: {node: '>=12.17.0'}
+
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -5348,6 +5355,9 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
+ pure-rand@8.4.2:
+ resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==}
+
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@@ -8933,7 +8943,7 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
- '@streamdown/cjk@1.0.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.2.3)(unified@11.0.5)':
+ '@streamdown/cjk@1.0.3(@types/mdast@4.0.4)(react@19.2.3)(unified@11.0.5)':
dependencies:
react: 19.2.3
remark-cjk-friendly: 2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5)
@@ -9347,13 +9357,13 @@ snapshots:
next: 16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
- '@vercel/geistdocs@1.11.0(@svta/cml-cta@1.0.1(@svta/cml-structured-field-values@1.0.1(@svta/cml-utils@1.0.1))(@svta/cml-utils@1.0.1))(@svta/cml-structured-field-values@1.0.1(@svta/cml-utils@1.0.1))(@svta/cml-utils@1.0.1)(@tanstack/react-router@1.151.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/mdast@4.0.4)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(micromark-util-types@2.0.2)(micromark@4.0.2)(next@16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(unified@11.0.5)(vite@7.3.1(@types/node@24.10.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))':
+ '@vercel/geistdocs@1.11.0(@svta/cml-cta@1.0.1(@svta/cml-structured-field-values@1.0.1(@svta/cml-utils@1.0.1))(@svta/cml-utils@1.0.1))(@svta/cml-structured-field-values@1.0.1(@svta/cml-utils@1.0.1))(@svta/cml-utils@1.0.1)(@tanstack/react-router@1.151.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/mdast@4.0.4)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(next@16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(unified@11.0.5)(vite@7.3.1(@types/node@24.10.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))':
dependencies:
'@ai-sdk/react': 3.0.201(react@19.2.3)(zod@4.3.5)
'@clack/prompts': 0.11.0
'@icons-pack/react-simple-icons': 13.8.0(react@19.2.3)
'@orama/tokenizers': 3.1.18
- '@streamdown/cjk': 1.0.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.2.3)(unified@11.0.5)
+ '@streamdown/cjk': 1.0.3(@types/mdast@4.0.4)(react@19.2.3)(unified@11.0.5)
'@streamdown/code': 1.1.1(react@19.2.3)
'@vercel/agent-readability': 0.2.1(next@16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
ai: 6.0.199(zod@4.3.5)
@@ -10129,6 +10139,10 @@ snapshots:
extendable-error@0.1.7: {}
+ fast-check@4.9.0:
+ dependencies:
+ pure-rand: 8.4.2
+
fast-deep-equal@3.1.3: {}
fast-glob@3.3.3:
@@ -11601,6 +11615,8 @@ snapshots:
punycode@2.3.1: {}
+ pure-rand@8.4.2: {}
+
quansync@0.2.11: {}
queue-microtask@1.2.3: {}