Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/remend-escaped-markers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"remend": minor
---

Heal emphasis the way CommonMark and GFM parse it

- Closes only openers that exist, innermost first, within the paragraph, list item or link text that holds them. An open code span closes before the emphasis around it.
- Leaves literal markers alone: `**Note:**This`, `*.ts`, `x**2`, `(+, -, *, /)`, fill-in blanks like `______`, and runs after an escaped marker like `\**`. A trailing backslash is escaped so it can't escape the closers.
- Stops closing an opener once its line has ended and the next line has started, since models close emphasis on the line that opens it. A stray `*` in code written outside a fence, like `char *name`, no longer italicizes what follows.
- Closes an inline code span only within its own block, and never with a closer that would start a fence. A `[` before a complete link stays literal instead of healing a second link.
- The `bold`, `italic`, `boldItalic` and `strikethrough` options still apply, but the built-in handlers they controlled are now one `emphasis` handler at priority 30. Custom handlers ordered against the old priorities may need new ones.
5 changes: 5 additions & 0 deletions .changeset/remend-link-block-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"remend": patch
---

Complete unfinished links and images only in the current block: the paragraph, list item, heading or table row at the end of the text. An unclosed `[` or `](` in an earlier block no longer adds `](streamdown:incomplete-link)` to later text, or drops the text after it.
5 changes: 5 additions & 0 deletions .changeset/streamdown-tail-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"streamdown": patch
---

While streaming, repair only the end of the message instead of the whole message on every update. The footnote and link-definition checks now scan only the new text. Rendered output is unchanged.
13 changes: 5 additions & 8 deletions apps/website/content/docs/termination.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -278,27 +278,24 @@ Built-in handlers use priorities 0-75:
| `htmlTags` | 10 |
| `setextHeadings` | 15 |
| `links` | 20 |
| `boldItalic` | 30 |
| `bold` | 35 |
| `italic` | 40-42 |
| `emphasis` (bold, italic, bold italic, strikethrough) | 30 |
| `inlineCode` | 50 |
| `strikethrough` | 60 |
| `katex` | 70 |

Custom handlers default to priority 100, running after all built-ins. Set a lower priority to run before specific built-ins.

**Example: Running Before Bold Handling**
**Example: Running Before Emphasis Handling**

To process content before bold formatting is completed (priority 30), set a lower priority:
To process content before emphasis is completed (priority 30), set a lower priority:

```typescript
const preprocessHandler: RemendHandler = {
name: 'preprocess',
handle: (text) => {
// Transform content before bold is completed
// Transform content before emphasis is completed
return text.replace(/\{\{highlight\}\}/g, '**');
},
priority: 25, // Runs before bold (35) and after links (20)
priority: 25, // Runs before emphasis (30) and after links (20)
};
```

Expand Down
5 changes: 1 addition & 4 deletions packages/remend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,8 @@ Built-in handlers use priorities 0-75. Custom handlers default to 100 (run after
| `htmlTags` | 10 |
| `setextHeadings` | 15 |
| `links` | 20 |
| `boldItalic` | 30 |
| `bold` | 35 |
| `italic` | 40-42 |
| `emphasis` (bold, italic, bold italic, strikethrough) | 30 |
| `inlineCode` | 50 |
| `strikethrough` | 60 |
| `katex` | 70 |
| `inlineKatex` | 75 |
| Custom (default) | 100 |
Expand Down
219 changes: 219 additions & 0 deletions packages/remend/__benchmarks__/stream-glitches.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { fromMarkdown } from "mdast-util-from-markdown";
import { gfmStrikethroughFromMarkdown } from "mdast-util-gfm-strikethrough";
import { gfmStrikethrough } from "micromark-extension-gfm-strikethrough";
import { it } from "vitest";
import remend from "../src";

// Streams each response in a JSONL corpus through remend and counts frames
// whose render disagrees with the finished response's render. Run with
// CORPUS=<file.jsonl> [BASELINE=<path to another remend src>] [OUT=<file.json>]

interface Node {
children?: Node[];
position?: { end: { offset?: number }; start: { offset?: number } };
type: string;
}

interface Sample {
model: string;
source: string;
text: string;
}

type Heal = (text: string) => string;

const toSample = (line: string): Sample => {
const value: unknown = JSON.parse(line);
if (
typeof value !== "object" ||
value === null ||
!("text" in value) ||
typeof value.text !== "string"
) {
throw new Error(`Corpus line has no text: ${line.slice(0, 80)}`);
}
const field = (key: string): string =>
key in value ? String(value[key as keyof typeof value]) : "unknown";
return { model: field("model"), source: field("source"), text: value.text };
};

const STEP = 4;
const STYLED = new Set(["emphasis", "strong", "delete", "inlineCode"]);
const ASCII_PUNCTUATION = /[!-/:-@[-`{-~]/;
const LEADING_TICKS = /^`+/;
const LETTER = /\p{L}/gu;
const DIGIT = /\d/g;
const LETTERS = /a+/g;
const ZERO_WIDTH_SPACE = "\u200B";

const parse = (markdown: string): Node =>
fromMarkdown(markdown, {
extensions: [gfmStrikethrough()],
mdastExtensions: [gfmStrikethroughFromMarkdown()],
});

// Each source offset's rendering: "" when it is syntax, otherwise the inline
// styles around the character it shows
const renderStyles = (markdown: string, root: Node, shift = 0): string[] => {
const styles: string[] = new Array(markdown.length + shift).fill("");
const visit = (node: Node, stack: string) => {
const start = node.position?.start.offset ?? 0;
const end = node.position?.end.offset ?? 0;
const style = STYLED.has(node.type) ? `${stack}/${node.type}` : stack;
if (node.type === "text") {
for (let i = start; i < end; i++) {
if (
markdown[i] === "\\" &&
i + 1 < end &&
ASCII_PUNCTUATION.test(markdown[i + 1])
) {
i++;
}
styles[shift + i] = `v${style}`;
}
} else if (node.type === "inlineCode") {
const raw = markdown.slice(start, end);
const ticks = raw.length - raw.replace(LEADING_TICKS, "").length;
for (let i = start + ticks; i < end - ticks; i++) {
styles[shift + i] = `v${style}`;
}
}
for (const child of node.children ?? []) {
visit(child, style);
}
};
visit(root, "");
return styles;
};

// Top-level block starts, since emphasis never crosses them
const blockStarts = (root: Node): number[] =>
(root.children ?? []).map((child) => child.position?.start.offset ?? 0);

// Characters of the prefix whose rendering differs from the final render,
// plus characters the heal added that render as visible text
const mismatches = (
prefix: string,
healed: string,
from: number,
final: string[]
): number => {
let cut = 0;
while (cut < prefix.length && prefix[cut] === healed[cut]) {
cut++;
}
const added = healed.length - prefix.length;
const frame = renderStyles(
healed.slice(from),
parse(healed.slice(from)),
from
);
let count = 0;
for (let at = cut; at < cut + added; at++) {
if ((frame[at] ?? "") !== "" && healed[at] !== ZERO_WIDTH_SPACE) {
count++;
}
}
for (let i = from; i < prefix.length; i++) {
const at = i < cut ? i : i + added;
if ((frame[at] ?? "") !== final[i]) {
count++;
}
}
return count;
};

// A coarse shape of the text before a cut, for grouping examples
const shapeOf = (tail: string): string =>
tail.replace(LETTER, "a").replace(DIGIT, "0").replace(LETTERS, "a");

interface Arm {
frames: number;
glitches: number;
}

const measure = (samples: Sample[], arms: Record<string, Heal>) => {
const byGroup = new Map<string, Record<string, Arm>>();
const divergent = { baselineCloser: 0, branchCloser: 0, tie: 0 };
const worse = new Map<string, { count: number; example: string }>();
for (const sample of samples) {
const group = `${sample.source} ${sample.model}`;
const totals =
byGroup.get(group) ??
Object.fromEntries(
Object.keys(arms).map((name) => [name, { frames: 0, glitches: 0 }])
);
byGroup.set(group, totals);
const finalTree = parse(sample.text);
const final = renderStyles(sample.text, finalTree);
const starts = blockStarts(finalTree);
for (let end = STEP; end < sample.text.length; end += STEP) {
const prefix = sample.text.slice(0, end);
const from = starts.findLast((start) => start < end) ?? 0;
const healed = Object.fromEntries(
Object.entries(arms).map(([name, heal]) => [name, heal(prefix)])
);
const counts: Record<string, number> = {};
for (const name of Object.keys(arms)) {
counts[name] = mismatches(prefix, healed[name], from, final);
totals[name].frames++;
if (counts[name] > 0) {
totals[name].glitches++;
}
}
if (arms.baseline && healed.baseline !== healed.branch) {
if (counts.branch < counts.baseline) {
divergent.branchCloser++;
} else if (counts.branch > counts.baseline) {
divergent.baselineCloser++;
const tail = prefix.slice(Math.max(from, end - 24));
const shape = shapeOf(tail);
const entry = worse.get(shape) ?? {
count: 0,
example: JSON.stringify({
tail,
baseline: healed.baseline.slice(Math.max(from, end - 24)),
branch: healed.branch.slice(Math.max(from, end - 24)),
final: sample.text.slice(Math.max(from, end - 24), end + 24),
}),
};
entry.count++;
worse.set(shape, entry);
} else {
divergent.tie++;
}
}
}
}
return {
divergent,
groups: Object.fromEntries(byGroup),
worse: [...worse.entries()]
.sort((a, b) => b[1].count - a[1].count)
.slice(0, 40)
.map(([shape, entry]) => ({ shape, ...entry })),
};
};

it.skipIf(!process.env.CORPUS)(
"measures streaming glitches against the final render",
async () => {
const samples: Sample[] = readFileSync(process.env.CORPUS ?? "", "utf8")
.split("\n")
.filter(Boolean)
.map(toSample);
const arms: Record<string, Heal> = { branch: remend };
if (process.env.BASELINE) {
const baseline = await import(resolve(process.env.BASELINE));
arms.baseline = baseline.default;
}
const report = measure(samples, arms);
writeFileSync(
process.env.OUT ?? "stream-glitches.json",
JSON.stringify(report, null, 2)
);
},
24 * 60 * 60 * 1000
);
2 changes: 1 addition & 1 deletion packages/remend/__tests__/bold-italic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe("bold-italic formatting (***)", () => {

it("should handle nested formatting with triple asterisks", () => {
expect(remend("***bold-italic with `code")).toBe(
"***bold-italic with `code***`"
"***bold-italic with `code`***"
);
});

Expand Down
Loading
Loading