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 }) => {
)
}
-
/**
* 页面的数据库链接禁止跳转,只能查看
*/