From 68f9aa17968a79cbfab67211253168eee78880f4 Mon Sep 17 00:00:00 2001 From: HugoHSun <95517615+HugoHSun@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:24:16 +1100 Subject: [PATCH] fix: prevent nested links when Anchor label looks like a URL GFM's autolink feature converts URL-like text (e.g., "https://example.com") into link nodes, which creates invalid nested links when the text is inside an component with extra attributes like target="_blank". Changes: - Unwrap nested link nodes in readme-components.ts during MDAST transformation - Unwrap nested elements in Anchor.tsx during React rendering - Add link compiler tests for the nested link edge case - Add Anchor component tests for unwrapping behavior Fixes the bug where links with URL-like labels and "Open in new tab" setting would display as nested links after page reload. --- __tests__/compilers/links.test.ts | 10 ++++ __tests__/components/Anchor.test.tsx | 66 ++++++++++++++++++++++++ components/Anchor.tsx | 11 +++- processor/transform/readme-components.ts | 13 ++++- 4 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 __tests__/components/Anchor.test.tsx diff --git a/__tests__/compilers/links.test.ts b/__tests__/compilers/links.test.ts index a70bd332e..ba95f8d39 100644 --- a/__tests__/compilers/links.test.ts +++ b/__tests__/compilers/links.test.ts @@ -14,6 +14,16 @@ describe('link compiler', () => { expect(mdx(mdast(markdown)).trim()).toBe(markdown); }); + + it('does not create nested links when Anchor label looks like a URL', () => { + const markdown = 'https://example.com'; + + // GFM autolinks URL-like text, but we unwrap it and the serializer escapes + // the colon to prevent re-autolinking on next parse + expect(mdx(mdast(markdown)).trim()).toBe( + 'https\\://example.com', + ); + }); }); describe('mdxish link compiler', () => { diff --git a/__tests__/components/Anchor.test.tsx b/__tests__/components/Anchor.test.tsx new file mode 100644 index 000000000..c5c715b83 --- /dev/null +++ b/__tests__/components/Anchor.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import Anchor from '../../components/Anchor'; + +describe('Anchor', () => { + it('renders a basic anchor', () => { + render(Click me); + + expect(screen.getByRole('link')).toMatchInlineSnapshot(` + + Click me + + `); + }); + + it('unwraps nested anchor elements', () => { + // Simulates what happens when GFM autolinks URL-like text inside an Anchor + const { container } = render( + + https://example.com + , + ); + + // Should only have one tag, not nested + const anchors = container.querySelectorAll('a'); + expect(anchors).toHaveLength(1); + expect(anchors[0]).toMatchInlineSnapshot(` + + https://example.com + + `); + }); + + it('preserves non-anchor children', () => { + render( + + Bold and italic + , + ); + + expect(screen.getByRole('link')).toMatchInlineSnapshot(` + + + Bold + + and + + italic + + + `); + }); +}); diff --git a/components/Anchor.tsx b/components/Anchor.tsx index ec55ed49d..c4ed08cf2 100644 --- a/components/Anchor.tsx +++ b/components/Anchor.tsx @@ -59,10 +59,19 @@ function Anchor(props: Props) { const { children, href = '', target = '', title = '', ...attrs } = props; const baseUrl: string = useContext(BaseUrlContext); + // Unwrap any nested anchor elements that GFM's autolinker may have created. + // This prevents invalid nested tags when the Anchor's text content looks like a URL. + const unwrappedChildren = React.Children.map(children, child => { + if (React.isValidElement(child) && child.type === 'a') { + return child.props.children; + } + return child; + }); + return ( // eslint-disable-next-line react/jsx-props-no-spreading - {children} + {unwrappedChildren} ); } diff --git a/processor/transform/readme-components.ts b/processor/transform/readme-components.ts index 01b7e09eb..b238f6db3 100644 --- a/processor/transform/readme-components.ts +++ b/processor/transform/readme-components.ts @@ -201,10 +201,21 @@ const coerceJsxToMd = delete hProperties.href; } + // Unwrap any autolinked children to prevent nested links. + // GFM's autolink feature can convert URL-like text inside Anchor children + // into link nodes, which would create invalid nested links when Anchor + // is converted back to a link node. + const children = (node.children as PhrasingContent[]).flatMap(child => { + if (child.type === 'link') { + return (child as Link).children; + } + return child; + }); + // @ts-expect-error we don't have a mechanism to enforce the URL attribute type right now const mdNode: Link = { ...hProperties, - children: node.children as PhrasingContent[], + children, type: types[node.name], position: node.position, };