From 704609c6289c855a7653a8bd26a70e159da97959 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 20 Mar 2026 01:17:08 -0700 Subject: [PATCH] feat: predict html elements --- src/Predict.ts | 31 ++++++++++++++++++++++++++++++ tests/prediction.test.ts | 41 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/Predict.ts b/src/Predict.ts index 998f6e2c2f6e0a300a277b5bc18aa8ab1cf46b64..9658055c2f9e800df9c55931615e92181e0dbff3 100644 --- a/src/Predict.ts +++ b/src/Predict.ts @@ -36,6 +36,7 @@ interface TailState { exclusive: ExclusiveState | null; links: LinkState[]; pendingDelim: DelimState | null; + pendingHtml: number | null; } const NESTABLE_DELIMS = new Set(["*", "**", "_", "__"]); @@ -47,6 +48,7 @@ function parseTail(tail: string): TailState { exclusive: null, links: [], pendingDelim: null, + pendingHtml: null, }; for (let i = 0; i < tail.length; i++) { @@ -71,6 +73,12 @@ function parseTail(tail: string): TailState { continue; } + const htmlEnd = consumeHtml(state, tail, i); + if (htmlEnd > i) { + i = htmlEnd - 1; + continue; + } + const delimEnd = consumeDelim(state, tail, i); if (delimEnd > i) { i = delimEnd - 1; @@ -169,6 +177,23 @@ function consumeTildes(state: TailState, tail: string, start: number) { return start + length; } +function consumeHtml(state: TailState, tail: string, start: number) { + if (tail[start] !== "<" || isEscapedAt(tail, start)) return start; + + const next = tail[start + 1]; + if (next !== undefined && !isAsciiLetter(next)) return start; + + state.pendingHtml = start; + for (let i = start + 1; i < tail.length; i++) { + if (tail[i] === ">" || tail[i] === "\n") { + state.pendingHtml = null; + return i + 1; + } + } + + return tail.length; +} + function consumeDelim(state: TailState, tail: string, start: number) { const token = matchDelim(tail, start); if (!token) return start; @@ -277,6 +302,8 @@ function inLinkUrlMode(state: TailState) { } function renderTail(tail: string, state: TailState) { + if (state.pendingHtml !== null) tail = tail.slice(0, state.pendingHtml); + const link = state.links.at(-1); if (link) return renderOpenLink(tail, link); @@ -386,3 +413,7 @@ function isEscapedAt(text: string, index: number) { function isWordChar(char?: string) { return !!char && /[A-Za-z0-9]/.test(char); } + +function isAsciiLetter(char?: string) { + return !!char && /[A-Za-z]/.test(char); +} diff --git a/tests/prediction.test.ts b/tests/prediction.test.ts index e7344395f458757442735e9a6759158084bba1b5..77a9f5222cfccaf662db5684fe6e31d81f9198ab 100644 --- a/tests/prediction.test.ts +++ b/tests/prediction.test.ts @@ -331,6 +331,47 @@ describe.each(["stateless", "stateful"] as const)("%s processing", (mode) => { ])("%s", (_name, input, expected) => check(input, expected)); }); + describe("html fragments", () => { + test.each([ + ["bare angle gets hidden", "<", ""], + ["partial tag gets hidden", "", ""], + ["incomplete self-closing tag gets hidden", "", ""], + ["completed tag followed by bare angle hides the tail", "<", ""], + [ + "completed tag followed by partial tag hides the tail", + "", + ], + [ + "completed tags pass through together", + "", + "", + ], + ["text after a completed tag stays visible", "hello", "hello"], + [ + "text before an incomplete tag stays visible", + "hello<", + "hello", + ], + [ + "text before a completed tag stays visible", + "hello", + "hello", + ], + [ + "mismatched completed tag still passes through", + "hello", + "hello", + ], + ["comparison text stays visible", "1 < 2", "1 < 2"], + ["comparison text with letters stays visible", "a < b", "a < b"], + ["markdown still closes before a hidden html tail", "**bold check(input, expected)); + }); + describe("opener-only inputs", () => { test.each([ ["lone italic delimiter", "*", ""], -- 2.54.0