From 8d98f0989530e9ca9a439c4ab38489abc958fa63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E9=80=90?= <2729478828@qq.com> Date: Tue, 17 Mar 2026 00:02:14 +0800 Subject: [PATCH] fix(notion): open article links in new tabs --- __tests__/components/NotionLink.test.js | 66 +++++++++++++++++++++++++ components/NotionLink.js | 36 ++++++++++++++ components/NotionPage.js | 6 ++- 3 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 __tests__/components/NotionLink.test.js create mode 100644 components/NotionLink.js diff --git a/__tests__/components/NotionLink.test.js b/__tests__/components/NotionLink.test.js new file mode 100644 index 00000000000..e9efbb2a1bd --- /dev/null +++ b/__tests__/components/NotionLink.test.js @@ -0,0 +1,66 @@ +import { render, screen } from '@testing-library/react' +import NotionLink, { + shouldOpenNotionLinkInNewTab +} from '@/components/NotionLink' + +describe('NotionLink', () => { + it('opens external http links in a new tab', () => { + render(Example) + + const link = screen.getByRole('link', { name: 'Example' }) + expect(link).toHaveAttribute('href', 'https://example.com') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('preserves existing rel tokens when forcing a new tab', () => { + render( + + Example + + ) + + const link = screen.getByRole('link', { name: 'Example' }) + expect(link).toHaveAttribute('rel', expect.stringContaining('nofollow')) + expect(link).toHaveAttribute('rel', expect.stringContaining('sponsored')) + expect(link).toHaveAttribute('rel', expect.stringContaining('noopener')) + expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer')) + }) + + it('keeps mailto links in the current tab by default', () => { + render(Mail) + + const link = screen.getByRole('link', { name: 'Mail' }) + expect(link).toHaveAttribute('href', 'mailto:test@example.com') + expect(link).not.toHaveAttribute('target') + expect(link).not.toHaveAttribute('rel') + }) + + it('keeps explicit blank targets and adds safe rel tokens', () => { + render( + + Mail + + ) + + const link = screen.getByRole('link', { name: 'Mail' }) + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) +}) + +describe('shouldOpenNotionLinkInNewTab', () => { + it('returns true for explicit blank targets and http links', () => { + expect( + shouldOpenNotionLinkInNewTab('mailto:test@example.com', '_blank') + ).toBe(true) + expect(shouldOpenNotionLinkInNewTab('https://example.com')).toBe(true) + expect(shouldOpenNotionLinkInNewTab('http://example.com')).toBe(true) + }) + + it('returns false for non-http links without an explicit target', () => { + expect(shouldOpenNotionLinkInNewTab('/posts/demo')).toBe(false) + expect(shouldOpenNotionLinkInNewTab('#section-1')).toBe(false) + expect(shouldOpenNotionLinkInNewTab('mailto:test@example.com')).toBe(false) + }) +}) diff --git a/components/NotionLink.js b/components/NotionLink.js new file mode 100644 index 00000000000..89acd58caae --- /dev/null +++ b/components/NotionLink.js @@ -0,0 +1,36 @@ +const EXTERNAL_HTTP_LINK = /^https?:\/\//i + +const mergeRelValues = (...values) => { + const rel = new Set() + + values + .filter(Boolean) + .join(' ') + .split(/\s+/) + .filter(Boolean) + .forEach(token => rel.add(token)) + + return rel.size > 0 ? Array.from(rel).join(' ') : undefined +} + +export const shouldOpenNotionLinkInNewTab = (href, target) => { + if (target === '_blank') { + return true + } + + return typeof href === 'string' && EXTERNAL_HTTP_LINK.test(href) +} + +const NotionLink = ({ href, target, rel, ...props }) => { + const shouldOpenInNewTab = shouldOpenNotionLinkInNewTab(href, target) + const normalizedTarget = shouldOpenInNewTab ? '_blank' : target + const normalizedRel = shouldOpenInNewTab + ? mergeRelValues(rel, 'noopener noreferrer') + : rel + + return ( + + ) +} + +export default NotionLink diff --git a/components/NotionPage.js b/components/NotionPage.js index 50675486269..8bfc87b5b42 100644 --- a/components/NotionPage.js +++ b/components/NotionPage.js @@ -1,5 +1,6 @@ import { siteConfig } from '@/lib/config' import { compressImage, mapImgUrl } from '@/lib/db/notion/mapImage' +import NotionLink from '@/components/NotionLink' import { isBrowser, loadExternalResource } from '@/lib/utils' import mediumZoom from '@fisch0920/medium-zoom' import 'katex/dist/katex.min.css' @@ -122,7 +123,8 @@ const NotionPage = ({ post, className }) => { return (
+ className={`mx-auto overflow-hidden ${className || ''}`} + > { Code, Collection, Equation, + Link: NotionLink, Modal, Pdf, Tweet @@ -143,7 +146,6 @@ const NotionPage = ({ post, className }) => { ) } - /** * 页面的数据库链接禁止跳转,只能查看 */