authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-20 01:17:08-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-20 14:11:08-07:00
log704609c6289c855a7653a8bd26a70e159da97959
tree0642211cdb2d432db90cc36e5447b2c4a3bcc9c6
parent773a4563ab1ae1b5bbea046264fb214e2f49986e
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat: predict html elements


2 files changed, 72 insertions(+), 0 deletions(-)

src/Predict.ts+31
...@@ -36,6 +36,7 @@ interface TailState {...@@ -36,6 +36,7 @@ interface TailState {
36 exclusive: ExclusiveState | null;36 exclusive: ExclusiveState | null;
37 links: LinkState[];37 links: LinkState[];
38 pendingDelim: DelimState | null;38 pendingDelim: DelimState | null;
39 pendingHtml: number | null;
39}40}
4041
41const NESTABLE_DELIMS = new Set<DelimToken>(["*", "**", "_", "__"]);42const NESTABLE_DELIMS = new Set<DelimToken>(["*", "**", "_", "__"]);
...@@ -47,6 +48,7 @@ function parseTail(tail: string): TailState {...@@ -47,6 +48,7 @@ function parseTail(tail: string): TailState {
47 exclusive: null,48 exclusive: null,
48 links: [],49 links: [],
49 pendingDelim: null,50 pendingDelim: null,
51 pendingHtml: null,
50 };52 };
5153
52 for (let i = 0; i < tail.length; i++) {54 for (let i = 0; i < tail.length; i++) {
...@@ -71,6 +73,12 @@ function parseTail(tail: string): TailState {...@@ -71,6 +73,12 @@ function parseTail(tail: string): TailState {
71 continue;73 continue;
72 }74 }
7375
76 const htmlEnd = consumeHtml(state, tail, i);
77 if (htmlEnd > i) {
78 i = htmlEnd - 1;
79 continue;
80 }
81
74 const delimEnd = consumeDelim(state, tail, i);82 const delimEnd = consumeDelim(state, tail, i);
75 if (delimEnd > i) {83 if (delimEnd > i) {
76 i = delimEnd - 1;84 i = delimEnd - 1;
...@@ -169,6 +177,23 @@ function consumeTildes(state: TailState, tail: string, start: number) {...@@ -169,6 +177,23 @@ function consumeTildes(state: TailState, tail: string, start: number) {
169 return start + length;177 return start + length;
170}178}
171179
180function consumeHtml(state: TailState, tail: string, start: number) {
181 if (tail[start] !== "<" || isEscapedAt(tail, start)) return start;
182
183 const next = tail[start + 1];
184 if (next !== undefined && !isAsciiLetter(next)) return start;
185
186 state.pendingHtml = start;
187 for (let i = start + 1; i < tail.length; i++) {
188 if (tail[i] === ">" || tail[i] === "\n") {
189 state.pendingHtml = null;
190 return i + 1;
191 }
192 }
193
194 return tail.length;
195}
196
172function consumeDelim(state: TailState, tail: string, start: number) {197function consumeDelim(state: TailState, tail: string, start: number) {
173 const token = matchDelim(tail, start);198 const token = matchDelim(tail, start);
174 if (!token) return start;199 if (!token) return start;
...@@ -277,6 +302,8 @@ function inLinkUrlMode(state: TailState) {...@@ -277,6 +302,8 @@ function inLinkUrlMode(state: TailState) {
277}302}
278303
279function renderTail(tail: string, state: TailState) {304function renderTail(tail: string, state: TailState) {
305 if (state.pendingHtml !== null) tail = tail.slice(0, state.pendingHtml);
306
280 const link = state.links.at(-1);307 const link = state.links.at(-1);
281 if (link) return renderOpenLink(tail, link);308 if (link) return renderOpenLink(tail, link);
282309
...@@ -386,3 +413,7 @@ function isEscapedAt(text: string, index: number) {...@@ -386,3 +413,7 @@ function isEscapedAt(text: string, index: number) {
386function isWordChar(char?: string) {413function isWordChar(char?: string) {
387 return !!char && /[A-Za-z0-9]/.test(char);414 return !!char && /[A-Za-z0-9]/.test(char);
388}415}
416
417function isAsciiLetter(char?: string) {
418 return !!char && /[A-Za-z]/.test(char);
419}
tests/prediction.test.ts+41
...@@ -331,6 +331,47 @@ describe.each(["stateless", "stateful"] as const)("%s processing", (mode) => {...@@ -331,6 +331,47 @@ describe.each(["stateless", "stateful"] as const)("%s processing", (mode) => {
331 ])("%s", (_name, input, expected) => check(input, expected));331 ])("%s", (_name, input, expected) => check(input, expected));
332 });332 });
333333
334 describe("html fragments", () => {
335 test.each([
336 ["bare angle gets hidden", "<", ""],
337 ["partial tag gets hidden", "<hello", ""],
338 ["complete opening tag passes through", "<hello-element>", "<hello-element>"],
339 ["incomplete self-closing tag gets hidden", "<hello-element /", ""],
340 ["incomplete attribute list gets hidden", '<hello-element attribute="meow"', ""],
341 ["complete self-closing tag passes through", "<hello-element />", "<hello-element />"],
342 ["completed tag followed by bare angle hides the tail", "<hello-element><", "<hello-element>"],
343 [
344 "completed tag followed by partial tag hides the tail",
345 "<hello-element><hello-element/",
346 "<hello-element>",
347 ],
348 [
349 "completed tags pass through together",
350 "<hello-element><hello-element/>",
351 "<hello-element><hello-element/>",
352 ],
353 ["text after a completed tag stays visible", "<hello-element>hello", "<hello-element>hello"],
354 [
355 "text before an incomplete tag stays visible",
356 "<hello-element>hello<",
357 "<hello-element>hello",
358 ],
359 [
360 "text before a completed tag stays visible",
361 "<hello-element>hello<hello-element/>",
362 "<hello-element>hello<hello-element/>",
363 ],
364 [
365 "mismatched completed tag still passes through",
366 "<hello-element>hello<wrong-tag/>",
367 "<hello-element>hello<wrong-tag/>",
368 ],
369 ["comparison text stays visible", "1 < 2", "1 < 2"],
370 ["comparison text with letters stays visible", "a < b", "a < b"],
371 ["markdown still closes before a hidden html tail", "**bold <hello", "**bold** "],
372 ])("%s", (_name, input, expected) => check(input, expected));
373 });
374
334 describe("opener-only inputs", () => {375 describe("opener-only inputs", () => {
335 test.each([376 test.each([
336 ["lone italic delimiter", "*", ""],377 ["lone italic delimiter", "*", ""],