Skip to content

Commit e50b0c4

Browse files
hryhoriiK97haydenbleaselclaude
authored
feat(remend): add opt-in inline KaTeX completion (#446)
* feat(remend): add opt-in inline KaTeX completion * docs(remend): update README to include inline KaTeX completion and clarify option defaults * fix(remend): complete partial closing $ in block math without duplicating * Add changeset for inline KaTeX completion feature Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Hayden Bleasel <hello@haydenbleasel.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent add5374 commit e50b0c4

File tree

5 files changed

+166
-4
lines changed

5 files changed

+166
-4
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"remend": minor
3+
---
4+
5+
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 `$`.

packages/remend/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Remend intelligently completes the following incomplete Markdown patterns:
3131
- **Links**: `[text](url``[text](streamdown:incomplete-link)`
3232
- **Images**: `![alt](url` → removed (can't display partial images)
3333
- **Block math**: `$$formula``$$formula$$`
34+
- **Inline math**: `$formula``$formula$` (opt-in, see `inlineKatex`)
3435

3536
## Installation
3637

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

5758
### Configuration
5859

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

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

@@ -118,7 +120,7 @@ interface RemendHandler {
118120

119121
#### Built-in Priorities
120122

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

123125
| Handler | Priority |
124126
|---------|----------|
@@ -130,6 +132,7 @@ Built-in handlers use priorities 0-70. Custom handlers default to 100 (run after
130132
| `inlineCode` | 50 |
131133
| `strikethrough` | 60 |
132134
| `katex` | 70 |
135+
| `inlineKatex` | 75 |
133136
| Custom (default) | 100 |
134137

135138
#### Exported Utilities

packages/remend/__tests__/katex.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ describe("KaTeX block formatting ($$)", () => {
2525
expect(remend("$$x + y = z")).toBe("$$x + y = z$$");
2626
});
2727

28+
it("should complete partial closing $ without duplicating it", () => {
29+
// Streaming $$formula$$ cut off mid-close: block katex should produce $$formula$$
30+
// not $$formula$$$ (which would then cause inline katex to append another $)
31+
expect(remend("$$formula$")).toBe("$$formula$$");
32+
expect(remend("$$x = y$")).toBe("$$x = y$$");
33+
});
34+
2835
it("should handle multiline block KaTeX", () => {
2936
expect(remend("$$\nx = 1\ny = 2")).toBe("$$\nx = 1\ny = 2\n$$");
3037
});
@@ -91,6 +98,84 @@ describe("KaTeX inline formatting ($)", () => {
9198
});
9299
});
93100

101+
describe("KaTeX inline formatting ($) — opt-in via inlineKatex: true", () => {
102+
const opts = { inlineKatex: true };
103+
104+
it("should complete incomplete inline math", () => {
105+
expect(remend("Text with $formula", opts)).toBe("Text with $formula$");
106+
expect(remend("$incomplete", opts)).toBe("$incomplete$");
107+
});
108+
109+
it("should keep already-complete inline math unchanged", () => {
110+
const text = "Text with $x^2 + y^2 = z^2$";
111+
expect(remend(text, opts)).toBe(text);
112+
});
113+
114+
it("should complete the third unpaired dollar sign", () => {
115+
expect(remend("$first$ and $second", opts)).toBe("$first$ and $second$");
116+
});
117+
118+
it("should complete inline $ but not affect complete block $$", () => {
119+
expect(remend("$$block$$ and $inline", opts)).toBe(
120+
"$$block$$ and $inline$"
121+
);
122+
});
123+
124+
it("should handle streaming chunks of inline math", () => {
125+
const chunks = [
126+
"The formula",
127+
"The formula $E",
128+
"The formula $E = mc",
129+
"The formula $E = mc^2",
130+
"The formula $E = mc^2$ shows",
131+
];
132+
133+
expect(remend(chunks[0], opts)).toBe(chunks[0]);
134+
expect(remend(chunks[1], opts)).toBe("The formula $E$");
135+
expect(remend(chunks[2], opts)).toBe("The formula $E = mc$");
136+
expect(remend(chunks[3], opts)).toBe("The formula $E = mc^2$");
137+
expect(remend(chunks[4], opts)).toBe(chunks[4]);
138+
});
139+
140+
it("should not complete escaped dollar signs", () => {
141+
const text = "Price is \\$100";
142+
expect(remend(text, opts)).toBe(text);
143+
});
144+
145+
it("should not complete $ inside inline code", () => {
146+
const text = "Use `$var` for variables and $formula";
147+
expect(remend(text, opts)).toBe("Use `$var` for variables and $formula$");
148+
});
149+
150+
it("should handle multiple complete inline math expressions", () => {
151+
const text = "$a = 1$ and $b = 2$";
152+
expect(remend(text, opts)).toBe(text);
153+
});
154+
155+
it("should handle mixed inline and block math", () => {
156+
const text = "Inline $x$ and block $$y$$";
157+
expect(remend(text, opts)).toBe(text);
158+
});
159+
160+
it("should not complete $ inside a complete block math expression", () => {
161+
const text = "$$x_1 + y_2 = z_3$$";
162+
expect(remend(text, opts)).toBe(text);
163+
});
164+
165+
it("should handle $$ followed by an unmatched $", () => {
166+
expect(remend("$$block$$ then $x + y", opts)).toBe(
167+
"$$block$$ then $x + y$"
168+
);
169+
});
170+
171+
it("should not produce extra $ when block katex and inline katex both run", () => {
172+
// $$formula$ is streaming $$formula$$ cut off mid-close
173+
// block katex should fix it to $$formula$$, inline katex should leave it unchanged
174+
expect(remend("$$formula$", opts)).toBe("$$formula$$");
175+
expect(remend("$$x = y$", opts)).toBe("$$x = y$$");
176+
});
177+
});
178+
94179
describe("math blocks with underscores", () => {
95180
it("should not complete underscores within inline math blocks", () => {
96181
const text = "The variable $x_1$ represents the first element";

packages/remend/src/index.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import {
88
} from "./emphasis-handlers";
99
import { handleIncompleteHtmlTag } from "./html-tag-handler";
1010
import { handleIncompleteInlineCode } from "./inline-code-handler";
11-
import { handleIncompleteBlockKatex } from "./katex-handler";
11+
import {
12+
handleIncompleteBlockKatex,
13+
handleIncompleteInlineKatex,
14+
} from "./katex-handler";
1215
import {
1316
handleIncompleteLinksAndImages,
1417
type LinkMode,
@@ -39,7 +42,7 @@ export interface RemendHandler {
3942

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

89+
// Helper to check if an opt-in option is enabled (defaults to false)
90+
const isOptedIn = (option: boolean | undefined): boolean => option === true;
91+
8192
// Built-in handler priorities (0-100)
8293
const PRIORITY = {
8394
COMPARISON_OPERATORS: -10,
@@ -92,6 +103,7 @@ const PRIORITY = {
92103
INLINE_CODE: 50,
93104
STRIKETHROUGH: 60,
94105
KATEX: 70,
106+
INLINE_KATEX: 75,
95107
DEFAULT: 100,
96108
} as const;
97109

@@ -198,6 +210,14 @@ const builtInHandlers: Array<{
198210
},
199211
optionKey: "katex",
200212
},
213+
{
214+
handler: {
215+
name: "inlineKatex",
216+
handle: handleIncompleteInlineKatex,
217+
priority: PRIORITY.INLINE_KATEX,
218+
},
219+
optionKey: "inlineKatex",
220+
},
201221
];
202222

203223
// Also enable links handler when images option is enabled
@@ -215,6 +235,10 @@ const getEnabledBuiltInHandlers = (
215235
if (handler.name === "links") {
216236
return isEnabled(options?.links) || isEnabled(options?.images);
217237
}
238+
// Special case: inlineKatex is opt-in (defaults to false, unlike other options)
239+
if (handler.name === "inlineKatex") {
240+
return isOptedIn(options?.inlineKatex);
241+
}
218242
return isEnabled(options?.[optionKey]);
219243
})
220244
.map(({ handler, earlyReturn }) => {

packages/remend/src/katex-handler.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,42 @@ const countDollarPairs = (text: string): number => {
2323
return dollarPairs;
2424
};
2525

26+
// Helper function to count single $ signs (excluding $$) outside of code blocks
27+
const countSingleDollars = (text: string): number => {
28+
let count = 0;
29+
let inInlineCode = false;
30+
31+
for (let i = 0; i < text.length; i += 1) {
32+
if (text[i] === "\\") {
33+
i += 1;
34+
continue;
35+
}
36+
37+
if (text[i] === "`" && !isTripleBacktick(text, i)) {
38+
inInlineCode = !inInlineCode;
39+
continue;
40+
}
41+
42+
if (!inInlineCode && text[i] === "$") {
43+
if (i + 1 < text.length && text[i + 1] === "$") {
44+
i += 1;
45+
} else {
46+
count += 1;
47+
}
48+
}
49+
}
50+
51+
return count;
52+
};
53+
2654
// Helper function to add closing $$ with appropriate formatting
2755
const addClosingKatex = (text: string): string => {
56+
// If the text already ends with a partial closing $ (but not $$),
57+
// just append one more $ to complete the $$ marker.
58+
if (text.endsWith("$") && !text.endsWith("$$")) {
59+
return `${text}$`;
60+
}
61+
2862
const firstDollarIndex = text.indexOf("$$");
2963
const hasNewlineAfterStart =
3064
firstDollarIndex !== -1 && text.indexOf("\n", firstDollarIndex) !== -1;
@@ -46,3 +80,14 @@ export const handleIncompleteBlockKatex = (text: string): string => {
4680

4781
return addClosingKatex(text);
4882
};
83+
84+
// Completes incomplete inline KaTeX formatting ($...$)
85+
export const handleIncompleteInlineKatex = (text: string): string => {
86+
const count = countSingleDollars(text);
87+
88+
if (count % 2 === 1) {
89+
return `${text}$`;
90+
}
91+
92+
return text;
93+
};

0 commit comments

Comments
 (0)