diff --git a/.changeset/bright-links-preserve.md b/.changeset/bright-links-preserve.md new file mode 100644 index 00000000..569513cf --- /dev/null +++ b/.changeset/bright-links-preserve.md @@ -0,0 +1,5 @@ +--- +"markdown-to-jsx": patch +--- + +Link destinations now preserve punctuation such as underscores while markdown is streamed, preventing generated links from being changed or broken. diff --git a/lib/src/parse.streaming.spec.ts b/lib/src/parse.streaming.spec.ts index 1862dd0b..779fde5a 100644 --- a/lib/src/parse.streaming.spec.ts +++ b/lib/src/parse.streaming.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test' import { compiler as htmlCompiler } from './html.ts' import { parser } from './parse.ts' +import type { MarkdownToJSX } from './types.ts' describe('Streaming optimization - inline code', () => { it('should remove incomplete inline code backtick and content', () => { @@ -36,6 +37,28 @@ describe('Streaming optimization - inline code', () => { }) }) +describe('Streaming optimization - link destinations', () => { + it('should preserve underscores in complete link destinations', () => { + const url = 'https://example.com/search?geoShape=KLUv_WCPBdUbAFa' + const ast = parser(`[View results](${url})`, { + optimizeForStreaming: true, + }) + const paragraph = ast[0] as MarkdownToJSX.ParagraphNode + const link = paragraph.children[0] as MarkdownToJSX.LinkNode + + expect(link.target).toBe(url) + }) + + it('should still suppress delimiters in incomplete links', () => { + const html = htmlCompiler('[View_results](https://example.com/search?x=1', { + optimizeForStreaming: true, + }) + + expect(html).not.toContain('_') + expect(html).toContain('Viewresults') + }) +}) + describe('Streaming optimization - code blocks', () => { it('should show incomplete code block content', () => { const html = htmlCompiler('```js\nconst x = 1', { diff --git a/lib/src/parse.ts b/lib/src/parse.ts index 74691adb..d78f8a92 100644 --- a/lib/src/parse.ts +++ b/lib/src/parse.ts @@ -5306,7 +5306,7 @@ function _skipLinkOrImage(s: string, i: number, e: number): number { // j is now past the ] if (j >= e) { - return j // just [text] + return i // shortcut links may not resolve, so keep their text parseable } const nextChar = s.charCodeAt(j) @@ -5329,7 +5329,7 @@ function _skipLinkOrImage(s: string, i: number, e: number): number { } j++ } - return j + return parenDepth === 0 ? j : i } // Reference link: [text][ref] @@ -5346,10 +5346,10 @@ function _skipLinkOrImage(s: string, i: number, e: number): number { } j++ } - return j + return depth2 === 0 ? j : i } - return j // shortcut link [text] + return i // shortcut links may not resolve, so keep their text parseable } /** Scan strikethrough ~~text~~ */ @@ -7224,6 +7224,17 @@ function parseInline( var lastTilDbl = -1 for (var ei = 0; ei < content.length; ei++) { var ec = content.charCodeAt(ei) + // Link destinations are opaque to emphasis parsing. Skip complete links + // and images so punctuation such as `_` in a URL is not treated as a + // streaming delimiter. Incomplete links return their start position from + // _skipLinkOrImage and continue through the normal suppression logic. + if (ec === $.CHAR_BRACKET_OPEN) { + var linkEnd = _skipLinkOrImage(content, ei, content.length) + if (linkEnd > ei) { + ei = linkEnd - 1 + continue + } + } if (ec === $.CHAR_ASTERISK) { if ( ei + 1 < content.length &&