diff --git a/website/modules/blog/utils/render-post.ts b/website/modules/blog/utils/render-post.ts index 8cf55856..0a11c863 100644 --- a/website/modules/blog/utils/render-post.ts +++ b/website/modules/blog/utils/render-post.ts @@ -14,9 +14,10 @@ * - `> ` blockquotes * - `- ` bulleted lists (custom-positioned markers via `before:` pseudo-element) * - ```fenced``` code blocks - * - Inline: **bold**, *italic*, `code`, [text](url). An absolute or - * protocol-relative URL opens in a new tab (with a screen-reader cue); - * an internal `/path` link navigates in place. + * - Inline: **bold**, *italic*, `code`, [text](url). The URL may contain + * one level of balanced parens (e.g. a Wikipedia `..._(language)` link). + * An absolute or protocol-relative URL opens in a new tab (with a + * screen-reader cue); an internal `/path` link navigates in place. */ function inline(s: string): string { @@ -24,7 +25,10 @@ function inline(s: string): string { .replace(/&/g, '&') .replace(//g, '>'); - out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, t, u) => { + // The URL group allows one level of balanced nested parens, so a link whose + // address contains a `(...)` (e.g. a Wikipedia `..._(programming_language)` + // URL) is captured whole instead of truncated at the first `)`. + out = out.replace(/\[([^\]]+)\]\(((?:[^()]|\([^()]*\))+)\)/g, (_m, t, u) => { // Absolute (off-site) links open in a new tab so the reader keeps the // article; internal links (starting with /) navigate in place. Escape any // `"` in the URL so it cannot break out of the href attribute. diff --git a/website/test/ssr/compare-ssr.test.ts b/website/test/ssr/compare-ssr.test.ts index 93878dc1..94f7fba0 100644 --- a/website/test/ssr/compare-ssr.test.ts +++ b/website/test/ssr/compare-ssr.test.ts @@ -36,6 +36,23 @@ test('a double quote in a link URL cannot break out of the href attribute', () = assert.match(out, /%22/, 'the quote is percent-escaped in the href'); }); +test('a link URL containing balanced parens is captured whole, not truncated', () => { + const out = renderPostBody('[Ruby](https://en.wikipedia.org/wiki/Ruby_(programming_language))'); + // Counterfactual: the old `[^)]+` capture stopped at the first `)`, so the + // href lost its `)` tail. The full URL must survive. + assert.match(out, /href="https:\/\/en\.wikipedia\.org\/wiki\/Ruby_\(programming_language\)"/, 'href keeps the full parenthesized URL'); + assert.match(out, /target="_blank"/, 'still classified as an external link'); + // Counterfactual: the truncated form left a stray `)` right after the closing + // . The whole-URL form emits no such orphan. + assert.doesNotMatch(out, /<\/a>\)/, 'no orphaned closing paren after the link'); +}); + +test('an empty link URL stays literal text (no empty-href anchor)', () => { + const out = renderPostBody('see [x]() here'); + assert.doesNotMatch(out, / { const out = await renderToString(await ComparePage({ params: { slug: 'webjs-vs-nextjs' } })); // A visible, underlined "Visit the Next.js site" link (distinguishable while