Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions __tests__/compilers/links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<Anchor target="_blank" href="https://example.com">https://example.com</Anchor>';

// 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(
'<Anchor target="_blank" href="https://example.com">https\\://example.com</Anchor>',
);
});
});

describe('mdxish link compiler', () => {
Expand Down
66 changes: 66 additions & 0 deletions __tests__/components/Anchor.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Anchor href="https://example.com">Click me</Anchor>);

expect(screen.getByRole('link')).toMatchInlineSnapshot(`
<a
href="https://example.com"
target=""
title=""
>
Click me
</a>
`);
});

it('unwraps nested anchor elements', () => {
// Simulates what happens when GFM autolinks URL-like text inside an Anchor
const { container } = render(
<Anchor href="https://example.com" target="_blank">
<a href="https://example.com">https://example.com</a>
</Anchor>,
);

// Should only have one <a> tag, not nested
const anchors = container.querySelectorAll('a');
expect(anchors).toHaveLength(1);
expect(anchors[0]).toMatchInlineSnapshot(`
<a
href="https://example.com"
target="_blank"
title=""
>
https://example.com
</a>
`);
});

it('preserves non-anchor children', () => {
render(
<Anchor href="https://example.com">
<strong>Bold</strong> and <em>italic</em>
</Anchor>,
);

expect(screen.getByRole('link')).toMatchInlineSnapshot(`
<a
href="https://example.com"
target=""
title=""
>
<strong>
Bold
</strong>
and
<em>
italic
</em>
</a>
`);
});
});
11 changes: 10 additions & 1 deletion components/Anchor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a> 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
<a {...attrs} href={getHref(href, baseUrl)} target={target} title={title} {...docLink(href)}>
{children}
{unwrappedChildren}
</a>
);
}
Expand Down
13 changes: 12 additions & 1 deletion processor/transform/readme-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to unwrap at both storing (here) and rendering stages (the components) since GFM is used in both cases.

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,
};
Expand Down