From 76fb01cff1002f2a37e93869378802156c4eca7c Mon Sep 17 00:00:00 2001 From: hippotastic <6137925+hippotastic@users.noreply.github.com> Date: Fri, 10 Jun 2022 05:31:36 +0200 Subject: [PATCH] Fix autolinking of URLs inside links in Markdown (#3564) --- .changeset/afraid-stingrays-sell.md | 5 + packages/markdown/remark/src/index.ts | 9 +- packages/markdown/remark/src/rehype-jsx.ts | 46 ++++++--- .../markdown/remark/test/autolinking.test.js | 96 +++++++++++++++++++ 4 files changed, 137 insertions(+), 19 deletions(-) create mode 100644 .changeset/afraid-stingrays-sell.md create mode 100644 packages/markdown/remark/test/autolinking.test.js diff --git a/.changeset/afraid-stingrays-sell.md b/.changeset/afraid-stingrays-sell.md new file mode 100644 index 000000000..3c30c423b --- /dev/null +++ b/.changeset/afraid-stingrays-sell.md @@ -0,0 +1,5 @@ +--- +'@astrojs/markdown-remark': patch +--- + +Fix autolinking of URLs inside links diff --git a/packages/markdown/remark/src/index.ts b/packages/markdown/remark/src/index.ts index 917976d5e..a11419474 100644 --- a/packages/markdown/remark/src/index.ts +++ b/packages/markdown/remark/src/index.ts @@ -99,14 +99,13 @@ export async function renderMarkdown( parser .use(isMDX ? [rehypeJsx, rehypeExpressions] : [rehypeRaw]) .use(rehypeEscape) - .use(rehypeIslands); + .use(rehypeIslands) + .use([rehypeCollectHeaders]) + .use(rehypeStringify, { allowDangerousHtml: true }) let result: string; try { - const vfile = await parser - .use([rehypeCollectHeaders]) - .use(rehypeStringify, { allowDangerousHtml: true }) - .process(input); + const vfile = await parser.process(input); result = vfile.toString(); } catch (err) { // Ensure that the error message contains the input filename diff --git a/packages/markdown/remark/src/rehype-jsx.ts b/packages/markdown/remark/src/rehype-jsx.ts index daeb4d56a..06783ba85 100644 --- a/packages/markdown/remark/src/rehype-jsx.ts +++ b/packages/markdown/remark/src/rehype-jsx.ts @@ -1,15 +1,14 @@ +import type { RehypePlugin } from './types.js'; import { visit } from 'unist-util-visit'; const MDX_ELEMENTS = ['mdxJsxFlowElement', 'mdxJsxTextElement']; -export default function rehypeJsx(): any { - return function (node: any): any { - visit(node, 'element', (child: any) => { - child.tagName = `${child.tagName}`; - }); - visit(node, MDX_ELEMENTS, (child: any, index: number | null, parent: any) => { + +export default function rehypeJsx(): ReturnType { + return function (tree) { + visit(tree, MDX_ELEMENTS, (node: any, index: number | null, parent: any) => { if (index === null || !Boolean(parent)) return; - const attrs = child.attributes.reduce((acc: any[], entry: any) => { + const attrs = node.attributes.reduce((acc: any[], entry: any) => { let attr = entry.value; if (attr && typeof attr === 'object') { attr = `{${attr.value}}`; @@ -26,23 +25,42 @@ export default function rehypeJsx(): any { return acc + ` ${entry.name}${attr ? '=' : ''}${attr}`; }, ''); - if (child.children.length === 0) { - child.type = 'raw'; - child.value = `<${child.name}${attrs} />`; + if (node.children.length === 0) { + node.type = 'raw'; + node.value = `<${node.name}${attrs} />`; return; } - // Replace the current child node with its children + // If the current node is a JSX element, remove autolinks from its children + // to prevent Markdown code like `**Go to www.example.com now!**` + // from creating a nested link to `www.example.com` + if (node.name === 'a') { + visit(node, 'element', (el, elIndex, elParent) => { + const isAutolink = ( + el.tagName === 'a' && + el.children.length === 1 && + el.children[0].type === 'text' && + el.children[0].value.match(/^(https?:\/\/|www\.)/i) + ); + + // If we found an autolink, remove it by replacing it with its text-only child + if (isAutolink) { + elParent.children.splice(elIndex, 1, el.children[0]); + } + }); + } + + // Replace the current node with its children // wrapped by raw opening and closing tags const openingTag = { type: 'raw', - value: `\n<${child.name}${attrs}>`, + value: `\n<${node.name}${attrs}>`, }; const closingTag = { type: 'raw', - value: `\n`, + value: `\n`, }; - parent.children.splice(index, 1, openingTag, ...child.children, closingTag); + parent.children.splice(index, 1, openingTag, ...node.children, closingTag); }); }; } diff --git a/packages/markdown/remark/test/autolinking.test.js b/packages/markdown/remark/test/autolinking.test.js new file mode 100644 index 000000000..9224247e0 --- /dev/null +++ b/packages/markdown/remark/test/autolinking.test.js @@ -0,0 +1,96 @@ +import { renderMarkdown } from '../dist/index.js'; +import chai from 'chai'; + +describe('autolinking', () => { + it('autolinks URLs starting with a protocol in plain text', async () => { + const { code } = await renderMarkdown( + `See https://example.com for more.`, + {} + ); + + chai + .expect(code.replace(/\n/g, '')) + .to.equal(`

See https://example.com for more.

`); + }); + + it('autolinks URLs starting with "www." in plain text', async () => { + const { code } = await renderMarkdown( + `See www.example.com for more.`, + {} + ); + + chai + .expect(code.trim()) + .to.equal(`

See www.example.com for more.

`); + }); + + it('does not autolink URLs in code blocks', async () => { + const { code } = await renderMarkdown( + 'See `https://example.com` or `www.example.com` for more.', + {} + ); + + chai + .expect(code.trim()) + .to.equal(`

See https://example.com or ` + + `www.example.com for more.

`); + }); + + it('does not autolink URLs in fenced code blocks', async () => { + const { code } = await renderMarkdown( + 'Example:\n```\nGo to https://example.com or www.example.com now.\n```', + {} + ); + + chai + .expect(code) + .to.contain(`
 {
+		const { code } = await renderMarkdown(
+			`See [http://example.com](http://example.com) or ` +
+			`https://example.com`,
+			{}
+		);
+
+		chai
+			.expect(code.replace(/\n/g, ''))
+			.to.equal(
+				`

See http://example.com or ` + + `https://example.com

` + ); + }); + + it('does not autolink URLs starting with "www." when nested inside links', async () => { + const { code } = await renderMarkdown( + `See [www.example.com](https://www.example.com) or ` + + `www.example.com`, + {} + ); + + chai + .expect(code.replace(/\n/g, '')) + .to.equal( + `

See www.example.com or ` + + `www.example.com

` + ); + }); + + it('does not autolink URLs when nested several layers deep inside links', async () => { + const { code } = await renderMarkdown( + `**Visit _our www.example.com or ` + + `http://localhost pages_ for more!**`, + {} + ); + + chai + .expect(code.replace(/\n/g, '')) + .to.equal( + `` + + `Visit our www.example.com or http://localhost pages for more!` + + `` + ); + }); +});