diff --git a/.changeset/add-inline-katex-completion.md b/.changeset/add-inline-katex-completion.md new file mode 100644 index 00000000..9ffed1f1 --- /dev/null +++ b/.changeset/add-inline-katex-completion.md @@ -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 `$`. diff --git a/packages/remend/README.md b/packages/remend/README.md index a66499f8..0735bde9 100644 --- a/packages/remend/README.md +++ b/packages/remend/README.md @@ -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 @@ -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"; @@ -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 | @@ -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 | |---------|----------| @@ -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 diff --git a/packages/remend/__tests__/katex.test.ts b/packages/remend/__tests__/katex.test.ts index c576c276..af5c375e 100644 --- a/packages/remend/__tests__/katex.test.ts +++ b/packages/remend/__tests__/katex.test.ts @@ -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$$"); }); @@ -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"; diff --git a/packages/remend/src/index.ts b/packages/remend/src/index.ts index e60c8580..67986689 100644 --- a/packages/remend/src/index.ts +++ b/packages/remend/src/index.ts @@ -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, @@ -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 { @@ -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$$`) */ @@ -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, @@ -92,6 +103,7 @@ const PRIORITY = { INLINE_CODE: 50, STRIKETHROUGH: 60, KATEX: 70, + INLINE_KATEX: 75, DEFAULT: 100, } as const; @@ -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 @@ -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 }) => { diff --git a/packages/remend/src/katex-handler.ts b/packages/remend/src/katex-handler.ts index 3eb08cb0..765542f1 100644 --- a/packages/remend/src/katex-handler.ts +++ b/packages/remend/src/katex-handler.ts @@ -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; @@ -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); + + if (count % 2 === 1) { + return `${text}$`; + } + + return text; +};