diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts index 00f4f26c8303a..1fcd14f1d5e30 100644 --- a/packages/shared/src/threadLinks.test.ts +++ b/packages/shared/src/threadLinks.test.ts @@ -261,4 +261,29 @@ describe("hosted thread links", () => { ref("there", "t:1"), ]); }); + + it("leaves a hosted link that is part of another link as written", () => { + const hosted = "https://app.t3.codes/threads/there/t1"; + const relabeled = "[There](t3-thread://v2/there/t1)"; + const title = titlesIn({ there: { t1: "There" } }); + for (const [markdown, expected] of [ + [`https://example.com/?next=${hosted}`], + [`[Example](https://example.com/?next=${hosted})`], + [`[ ${hosted} ](https://example.com)`], + [`[${hosted}]( "Title")`], + [`[a](https://example.com/x_(y)) ${hosted}`, `[a](https://example.com/x_(y)) ${relabeled}`], + [`(${hosted}) **${hosted}**`, `(${relabeled}) **${relabeled}**`], + [`[[[ [old](${hosted})`, `[[[ ${relabeled}`], + [`\\\\[old](${hosted})`, `\\\\${relabeled}`], + [`\\\\\\\\[old](${hosted})`, `\\\\\\\\${relabeled}`], + [`\\[old](${hosted})`, `\\[old](${relabeled})`], + [`\\[ ${hosted} ](https://example.com)`, `\\[ ${relabeled} ](https://example.com)`], + ["\\[old](t3-thread://v2/there/t1) \\"], + ] as const) { + expect(relabelThreadLinks(markdown, local, title)).toBe(expected ?? markdown); + expect(threadLinkRefs(markdown, local)).toEqual( + expected === undefined ? [] : [ref("there", "t1")], + ); + } + }); }); diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts index a7e38de5003ce..b82fad021eb7e 100644 --- a/packages/shared/src/threadLinks.ts +++ b/packages/shared/src/threadLinks.ts @@ -10,10 +10,23 @@ const V2_PREFIX = `${THREAD_LINK_PROTOCOL}://v2/`; const HOSTED_PREFIX = `${DEFAULT_HOSTED_APP_URL}/threads/`; const HOSTED_SEGMENT = "[A-Za-z0-9_%-]+"; const HOSTED_LINK = `${HOSTED_PREFIX.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}${HOSTED_SEGMENT}/${HOSTED_SEGMENT}`; -// Code comes first in the alternation so a link written inside a code span or fence is skipped. -// A labeled link's destination may be angle-bracketed, as CommonMark allows: [label](). +const THREAD_HREF = String.raw`t3-thread:\/\/v[12]\/`; +// A label stops at any bracket, so a run of unclosed brackets is scanned once, not once per bracket. +const LABEL = String.raw`[^[\]\n]*`; +// Earlier alternatives claim text so later ones cannot match inside it: an escaped character opens +// nothing, code is literal, and a link to anywhere else keeps a thread URL in its label or href. const THREAD_LINK_OUTSIDE_CODE = new RegExp( - String.raw`(?(\`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?(\`+)[^\n]*?\4)|\[(?