Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/add-inline-katex-completion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"remend": minor
---

Add opt-in inline KaTeX completion (`$formula` → `$formula$`) via a new `inlineKatex` option that defaults to `false` to avoid ambiguity with currency symbols. Also fixes block KaTeX completion when streaming produces a partial closing `$`.
7 changes: 5 additions & 2 deletions packages/remend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Remend intelligently completes the following incomplete Markdown patterns:
- **Links**: `[text](url` → `[text](streamdown:incomplete-link)`
- **Images**: `![alt](url` → removed (can't display partial images)
- **Block math**: `$$formula` → `$$formula$$`
- **Inline math**: `$formula` → `$formula$` (opt-in, see `inlineKatex`)

## Installation

Expand All @@ -56,7 +57,7 @@ const completed = remend(partialLink);

### Configuration

You can selectively disable specific completions by passing an options object. All options default to `true`:
You can selectively disable specific completions by passing an options object. Options default to `true` unless noted otherwise:

```typescript
import remend from "remend";
Expand All @@ -80,6 +81,7 @@ Available options:
| `inlineCode` | Complete inline code formatting (`` ` ``) |
| `strikethrough` | Complete strikethrough formatting (`~~`) |
| `katex` | Complete block KaTeX math (`$$`) |
| `inlineKatex` | Complete inline KaTeX math (`$`) — defaults to `false` to avoid ambiguity with currency symbols |
| `setextHeadings` | Handle incomplete setext headings |
| `handlers` | Custom handlers to extend remend |

Expand Down Expand Up @@ -118,7 +120,7 @@ interface RemendHandler {

#### Built-in Priorities

Built-in handlers use priorities 0-70. Custom handlers default to 100 (run after built-ins):
Built-in handlers use priorities 0-75. Custom handlers default to 100 (run after built-ins):

| Handler | Priority |
|---------|----------|
Expand All @@ -130,6 +132,7 @@ Built-in handlers use priorities 0-70. Custom handlers default to 100 (run after
| `inlineCode` | 50 |
| `strikethrough` | 60 |
| `katex` | 70 |
| `inlineKatex` | 75 |
| Custom (default) | 100 |

#### Exported Utilities
Expand Down
85 changes: 85 additions & 0 deletions packages/remend/__tests__/katex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ describe("KaTeX block formatting ($$)", () => {
expect(remend("$$x + y = z")).toBe("$$x + y = z$$");
});

it("should complete partial closing $ without duplicating it", () => {
// Streaming $$formula$$ cut off mid-close: block katex should produce $$formula$$
// not $$formula$$$ (which would then cause inline katex to append another $)
expect(remend("$$formula$")).toBe("$$formula$$");
expect(remend("$$x = y$")).toBe("$$x = y$$");
});

it("should handle multiline block KaTeX", () => {
expect(remend("$$\nx = 1\ny = 2")).toBe("$$\nx = 1\ny = 2\n$$");
});
Expand Down Expand Up @@ -91,6 +98,84 @@ describe("KaTeX inline formatting ($)", () => {
});
});

describe("KaTeX inline formatting ($) — opt-in via inlineKatex: true", () => {
const opts = { inlineKatex: true };

it("should complete incomplete inline math", () => {
expect(remend("Text with $formula", opts)).toBe("Text with $formula$");
expect(remend("$incomplete", opts)).toBe("$incomplete$");
});

it("should keep already-complete inline math unchanged", () => {
const text = "Text with $x^2 + y^2 = z^2$";
expect(remend(text, opts)).toBe(text);
});

it("should complete the third unpaired dollar sign", () => {
expect(remend("$first$ and $second", opts)).toBe("$first$ and $second$");
});

it("should complete inline $ but not affect complete block $$", () => {
expect(remend("$$block$$ and $inline", opts)).toBe(
"$$block$$ and $inline$"
);
});

it("should handle streaming chunks of inline math", () => {
const chunks = [
"The formula",
"The formula $E",
"The formula $E = mc",
"The formula $E = mc^2",
"The formula $E = mc^2$ shows",
];

expect(remend(chunks[0], opts)).toBe(chunks[0]);
expect(remend(chunks[1], opts)).toBe("The formula $E$");
expect(remend(chunks[2], opts)).toBe("The formula $E = mc$");
expect(remend(chunks[3], opts)).toBe("The formula $E = mc^2$");
expect(remend(chunks[4], opts)).toBe(chunks[4]);
});

it("should not complete escaped dollar signs", () => {
const text = "Price is \\$100";
expect(remend(text, opts)).toBe(text);
});

it("should not complete $ inside inline code", () => {
const text = "Use `$var` for variables and $formula";
expect(remend(text, opts)).toBe("Use `$var` for variables and $formula$");
});

it("should handle multiple complete inline math expressions", () => {
const text = "$a = 1$ and $b = 2$";
expect(remend(text, opts)).toBe(text);
});

it("should handle mixed inline and block math", () => {
const text = "Inline $x$ and block $$y$$";
expect(remend(text, opts)).toBe(text);
});

it("should not complete $ inside a complete block math expression", () => {
const text = "$$x_1 + y_2 = z_3$$";
expect(remend(text, opts)).toBe(text);
});

it("should handle $$ followed by an unmatched $", () => {
expect(remend("$$block$$ then $x + y", opts)).toBe(
"$$block$$ then $x + y$"
);
});

it("should not produce extra $ when block katex and inline katex both run", () => {
// $$formula$ is streaming $$formula$$ cut off mid-close
// block katex should fix it to $$formula$$, inline katex should leave it unchanged
expect(remend("$$formula$", opts)).toBe("$$formula$$");
expect(remend("$$x = y$", opts)).toBe("$$x = y$$");
});
});

describe("math blocks with underscores", () => {
it("should not complete underscores within inline math blocks", () => {
const text = "The variable $x_1$ represents the first element";
Expand Down
28 changes: 26 additions & 2 deletions packages/remend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import {
} from "./emphasis-handlers";
import { handleIncompleteHtmlTag } from "./html-tag-handler";
import { handleIncompleteInlineCode } from "./inline-code-handler";
import { handleIncompleteBlockKatex } from "./katex-handler";
import {
handleIncompleteBlockKatex,
handleIncompleteInlineKatex,
} from "./katex-handler";
import {
handleIncompleteLinksAndImages,
type LinkMode,
Expand Down Expand Up @@ -39,7 +42,7 @@ export interface RemendHandler {

/**
* Configuration options for the remend function.
* All options default to `true` when not specified.
* Options default to `true` unless noted otherwise.
* Set an option to `false` to disable that specific completion.
*/
export interface RemendOptions {
Expand All @@ -57,6 +60,11 @@ export interface RemendOptions {
images?: boolean;
/** Complete inline code formatting (e.g., `` `code `` → `` `code` ``) */
inlineCode?: boolean;
/**
* Complete inline KaTeX math (e.g., `$equation` → `$equation$`).
* Defaults to `false` — single `$` is ambiguous with currency symbols.
*/
inlineKatex?: boolean;
/** Complete italic formatting (e.g., `*text` → `*text*` or `_text` → `_text_`) */
italic?: boolean;
/** Complete block KaTeX math (e.g., `$$equation` → `$$equation$$`) */
Expand All @@ -78,6 +86,9 @@ export interface RemendOptions {
// Helper to check if an option is enabled (defaults to true)
const isEnabled = (option: boolean | undefined): boolean => option !== false;

// Helper to check if an opt-in option is enabled (defaults to false)
const isOptedIn = (option: boolean | undefined): boolean => option === true;

// Built-in handler priorities (0-100)
const PRIORITY = {
COMPARISON_OPERATORS: -10,
Expand All @@ -92,6 +103,7 @@ const PRIORITY = {
INLINE_CODE: 50,
STRIKETHROUGH: 60,
KATEX: 70,
INLINE_KATEX: 75,
DEFAULT: 100,
} as const;

Expand Down Expand Up @@ -198,6 +210,14 @@ const builtInHandlers: Array<{
},
optionKey: "katex",
},
{
handler: {
name: "inlineKatex",
handle: handleIncompleteInlineKatex,
priority: PRIORITY.INLINE_KATEX,
},
optionKey: "inlineKatex",
},
];

// Also enable links handler when images option is enabled
Expand All @@ -215,6 +235,10 @@ const getEnabledBuiltInHandlers = (
if (handler.name === "links") {
return isEnabled(options?.links) || isEnabled(options?.images);
}
// Special case: inlineKatex is opt-in (defaults to false, unlike other options)
if (handler.name === "inlineKatex") {
return isOptedIn(options?.inlineKatex);
}
return isEnabled(options?.[optionKey]);
})
.map(({ handler, earlyReturn }) => {
Expand Down
45 changes: 45 additions & 0 deletions packages/remend/src/katex-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,42 @@ const countDollarPairs = (text: string): number => {
return dollarPairs;
};

// Helper function to count single $ signs (excluding $$) outside of code blocks
const countSingleDollars = (text: string): number => {
let count = 0;
let inInlineCode = false;

for (let i = 0; i < text.length; i += 1) {
if (text[i] === "\\") {
i += 1;
continue;
}

if (text[i] === "`" && !isTripleBacktick(text, i)) {
inInlineCode = !inInlineCode;
continue;
}

if (!inInlineCode && text[i] === "$") {
if (i + 1 < text.length && text[i + 1] === "$") {
i += 1;
} else {
count += 1;
}
}
}

return count;
};

// Helper function to add closing $$ with appropriate formatting
const addClosingKatex = (text: string): string => {
// If the text already ends with a partial closing $ (but not $$),
// just append one more $ to complete the $$ marker.
if (text.endsWith("$") && !text.endsWith("$$")) {
return `${text}$`;
}

const firstDollarIndex = text.indexOf("$$");
const hasNewlineAfterStart =
firstDollarIndex !== -1 && text.indexOf("\n", firstDollarIndex) !== -1;
Expand All @@ -46,3 +80,14 @@ export const handleIncompleteBlockKatex = (text: string): string => {

return addClosingKatex(text);
};

// Completes incomplete inline KaTeX formatting ($...$)
export const handleIncompleteInlineKatex = (text: string): string => {
const count = countSingleDollars(text);
Comment thread
vercel[bot] marked this conversation as resolved.

if (count % 2 === 1) {
return `${text}$`;
}

return text;
};