| author | |
| committer | |
| log | 704609c6289c855a7653a8bd26a70e159da97959 |
| tree | 0642211cdb2d432db90cc36e5447b2c4a3bcc9c6 |
| parent | 773a4563ab1ae1b5bbea046264fb214e2f49986e |
| signature |
2 files changed, 72 insertions(+), 0 deletions(-)
src/Predict.ts+31| ... | ... | @@ -36,6 +36,7 @@ interface TailState { |
| 36 | 36 | exclusive: ExclusiveState | null; |
| 37 | 37 | links: LinkState[]; |
| 38 | 38 | pendingDelim: DelimState | null; |
| 39 | pendingHtml: number | null; | |
| 39 | 40 | } |
| 40 | 41 | |
| 41 | 42 | const NESTABLE_DELIMS = new Set<DelimToken>(["*", "**", "_", "__"]); |
| ... | ... | @@ -47,6 +48,7 @@ function parseTail(tail: string): TailState { |
| 47 | 48 | exclusive: null, |
| 48 | 49 | links: [], |
| 49 | 50 | pendingDelim: null, |
| 51 | pendingHtml: null, | |
| 50 | 52 | }; |
| 51 | 53 | |
| 52 | 54 | for (let i = 0; i < tail.length; i++) { |
| ... | ... | @@ -71,6 +73,12 @@ function parseTail(tail: string): TailState { |
| 71 | 73 | continue; |
| 72 | 74 | } |
| 73 | 75 | |
| 76 | const htmlEnd = consumeHtml(state, tail, i); | |
| 77 | if (htmlEnd > i) { | |
| 78 | i = htmlEnd - 1; | |
| 79 | continue; | |
| 80 | } | |
| 81 | ||
| 74 | 82 | const delimEnd = consumeDelim(state, tail, i); |
| 75 | 83 | if (delimEnd > i) { |
| 76 | 84 | i = delimEnd - 1; |
| ... | ... | @@ -169,6 +177,23 @@ function consumeTildes(state: TailState, tail: string, start: number) { |
| 169 | 177 | return start + length; |
| 170 | 178 | } |
| 171 | 179 | |
| 180 | function 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 | ||
| 172 | 197 | function consumeDelim(state: TailState, tail: string, start: number) { |
| 173 | 198 | const token = matchDelim(tail, start); |
| 174 | 199 | if (!token) return start; |
| ... | ... | @@ -277,6 +302,8 @@ function inLinkUrlMode(state: TailState) { |
| 277 | 302 | } |
| 278 | 303 | |
| 279 | 304 | function renderTail(tail: string, state: TailState) { |
| 305 | if (state.pendingHtml !== null) tail = tail.slice(0, state.pendingHtml); | |
| 306 | ||
| 280 | 307 | const link = state.links.at(-1); |
| 281 | 308 | if (link) return renderOpenLink(tail, link); |
| 282 | 309 | |
| ... | ... | @@ -386,3 +413,7 @@ function isEscapedAt(text: string, index: number) { |
| 386 | 413 | function isWordChar(char?: string) { |
| 387 | 414 | return !!char && /[A-Za-z0-9]/.test(char); |
| 388 | 415 | } |
| 416 | ||
| 417 | function 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 | 331 | ])("%s", (_name, input, expected) => check(input, expected)); |
| 332 | 332 | }); |
| 333 | 333 | |
| 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 | 375 | describe("opener-only inputs", () => { |
| 335 | 376 | test.each([ |
| 336 | 377 | ["lone italic delimiter", "*", ""], |