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 {
3636 exclusive: ExclusiveState | null;
3737 links: LinkState[];
3838 pendingDelim: DelimState | null;
39 pendingHtml: number | null;
3940}
4041
4142const NESTABLE_DELIMS = new Set<DelimToken>(["*", "**", "_", "__"]);
......@@ -47,6 +48,7 @@ function parseTail(tail: string): TailState {
4748 exclusive: null,
4849 links: [],
4950 pendingDelim: null,
51 pendingHtml: null,
5052 };
5153
5254 for (let i = 0; i < tail.length; i++) {
......@@ -71,6 +73,12 @@ function parseTail(tail: string): TailState {
7173 continue;
7274 }
7375
76 const htmlEnd = consumeHtml(state, tail, i);
77 if (htmlEnd > i) {
78 i = htmlEnd - 1;
79 continue;
80 }
81
7482 const delimEnd = consumeDelim(state, tail, i);
7583 if (delimEnd > i) {
7684 i = delimEnd - 1;
......@@ -169,6 +177,23 @@ function consumeTildes(state: TailState, tail: string, start: number) {
169177 return start + length;
170178}
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
172197function consumeDelim(state: TailState, tail: string, start: number) {
173198 const token = matchDelim(tail, start);
174199 if (!token) return start;
......@@ -277,6 +302,8 @@ function inLinkUrlMode(state: TailState) {
277302}
278303
279304function renderTail(tail: string, state: TailState) {
305 if (state.pendingHtml !== null) tail = tail.slice(0, state.pendingHtml);
306
280307 const link = state.links.at(-1);
281308 if (link) return renderOpenLink(tail, link);
282309
......@@ -386,3 +413,7 @@ function isEscapedAt(text: string, index: number) {
386413function isWordChar(char?: string) {
387414 return !!char && /[A-Za-z0-9]/.test(char);
388415}
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) => {
331331 ])("%s", (_name, input, expected) => check(input, expected));
332332 });
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
334375 describe("opener-only inputs", () => {
335376 test.each([
336377 ["lone italic delimiter", "*", ""],