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
16 changes: 16 additions & 0 deletions .changeset/html-tag-math-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"remend": patch
---

Stop the incomplete-HTML-tag handler from truncating math expressions.

`handleIncompleteHtmlTag` guarded against code blocks but not math, so an ordinary
comparison inside math — `$$ I = \sum_{j<k} p_j $$` — matched the incomplete-tag
pattern and deleted everything from the `<` to the end of the string. The trailing
`$$` was then auto-closed by the katex handler, so KaTeX rendered a parse error and
the rest of the message never reached the DOM.

The handler now skips candidates inside math blocks (`$`, `$$`, `\(`, `\[`), matching
the guard the emphasis handlers already had. Because the pattern is leftmost-matching,
it also walks forward to later candidates instead of bailing out, so a genuine
incomplete tag after a math expression is still stripped.
23 changes: 23 additions & 0 deletions packages/remend/__tests__/html-tags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,29 @@ describe("incomplete HTML tag stripping", () => {
);
});

it("should not strip < inside block math", () => {
const text = "Intro\n\n$$\nI = \\sum_{j<k} p_j\n$$\n\nTAIL";
expect(remend(text)).toBe(text);
});

it("should not strip < inside inline dollar math", () => {
const text = "inline $A_{j<k}$ more";
expect(remend(text)).toBe(text);
});

it("should not strip < inside LaTeX delimiters", () => {
const inline = "value \\(a<b\\) end";
expect(remend(inline)).toBe(inline);

const block = "value \\[a<b\\] end";
expect(remend(block)).toBe(block);
});

it("should still strip incomplete tags outside math blocks", () => {
expect(remend("$x<y$ then <div")).toBe("$x<y$ then");
expect(remend("$$a<b$$ done <span")).toBe("$$a<b$$ done");
});

it("should be disabled when htmlTags option is false", () => {
expect(remend("Hello <div", { htmlTags: false })).toBe("Hello <div");
});
Expand Down
38 changes: 33 additions & 5 deletions packages/remend/src/html-tag-handler.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,50 @@
import { isInsideCodeBlock } from "./code-block-utils";
import { isWithinMathBlock } from "./utils";

// Matches an incomplete HTML tag at the end of the string.
// Must start with < followed by a letter (opening tag) or / (closing tag),
// and must NOT contain a > (which would close the tag).
const incompleteHtmlTagPattern = /<[a-zA-Z/][^>]*$/;

const tagNameStartPattern = /[a-zA-Z/]/;

const hasMathDelimiters = (text: string): boolean =>
text.includes("$") || text.includes("\\(") || text.includes("\\[");

const startsTag = (text: string, index: number): boolean => {
const nextChar = text[index + 1];
return nextChar !== undefined && tagNameStartPattern.test(nextChar);
};

export const handleIncompleteHtmlTag = (text: string): string => {
const match = text.match(incompleteHtmlTagPattern);

if (!match || match.index === undefined) {
return text;
}

// Don't strip if the < is inside a code block or inline code
if (isInsideCodeBlock(text, match.index)) {
return text;
// The pattern is leftmost-matching and always runs to the end of the string,
// so every later < that starts a tag name is an equally valid candidate.
// Walk forward until we find one that is not inside code or math: a comparison
// operator such as \sum_{j<k} must not swallow the rest of the message.
const checkMath = hasMathDelimiters(text);

for (let index = match.index; index < text.length; index += 1) {
if (text[index] !== "<" || !startsTag(text, index)) {
continue;
}

if (isInsideCodeBlock(text, index)) {
continue;
}

if (checkMath && isWithinMathBlock(text, index)) {
continue;
}

// Strip the incomplete tag and any trailing whitespace before it
return text.substring(0, index).trimEnd();
}

// Strip the incomplete tag and any trailing whitespace before it
return text.substring(0, match.index).trimEnd();
return text;
};
Loading