Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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
71 changes: 71 additions & 0 deletions packages/remend/__tests__/katex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,77 @@ 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$"
);
});
});

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
39 changes: 39 additions & 0 deletions packages/remend/src/katex-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,34 @@ 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 => {
const firstDollarIndex = text.indexOf("$$");
Expand All @@ -46,3 +74,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;
};