diff --git a/docs/plugins/pluginCrossRef.ts b/docs/plugins/pluginCrossRef.ts index 3445442d793..fdb99b74480 100644 --- a/docs/plugins/pluginCrossRef.ts +++ b/docs/plugins/pluginCrossRef.ts @@ -8,6 +8,7 @@ import type { SidebarItem, SidebarSectionHeader, } from '@rspress/core'; +import { encodeTargetBlankHref } from '../shared/blankTargetLink'; // ── 内部类型 ────────────────────────────────────────────────────────── @@ -29,6 +30,23 @@ function shouldProxyLink(obj: Record): boolean { return obj.proxy !== false; } +function isExternalLink(link: string): boolean { + return link.startsWith('http://') || link.startsWith('https://'); +} + +function resolveSidebarLink( + obj: Record, + link: string, + crossRefs: CrossRef[], + topDir: string, +): string { + if (!shouldProxyLink(obj)) { + return isExternalLink(link) ? link : encodeTargetBlankHref(link); + } + + return rewriteIfCrossRef(link, crossRefs, topDir); +} + function collectLeafLinks(entries: unknown[]): string[] { const links: string[] = []; for (const entry of entries) { @@ -47,6 +65,23 @@ function collectLeafLinks(entries: unknown[]): string[] { return links; } +function hasUnproxiedLinks(entries: unknown[]): boolean { + for (const entry of entries) { + const obj = entry as Record; + if (!obj || typeof obj !== 'object') continue; + + if (typeof obj.link === 'string' && !shouldProxyLink(obj)) { + return true; + } + + if (Array.isArray(obj.items) && hasUnproxiedLinks(obj.items as unknown[])) { + return true; + } + } + + return false; +} + /** 从相对路径取顶层模块名:'/api/auth/sub' → '/api' */ function getTopLevelDir(relDir: string): string { const parts = relDir.split('/').filter(Boolean); @@ -166,9 +201,12 @@ function resolveEntries( collapsed: obj.collapsed === true, }; if (typeof obj.link === 'string') { - group.link = shouldProxyLink(obj) - ? rewriteIfCrossRef(obj.link as string, crossRefs, topDir) - : (obj.link as string); + group.link = resolveSidebarLink( + obj, + obj.link as string, + crossRefs, + topDir, + ); } result.push(group); continue; @@ -178,9 +216,7 @@ function resolveEntries( if (type === 'custom-link' && typeof obj.link === 'string') { result.push({ text: (obj.label as string) || '', - link: shouldProxyLink(obj) - ? rewriteIfCrossRef(obj.link as string, crossRefs, topDir) - : (obj.link as string), + link: resolveSidebarLink(obj, obj.link as string, crossRefs, topDir), }); continue; } @@ -270,6 +306,7 @@ export function pluginCrossRefSidebar(): RspressPlugin { config(config) { const root = config.root || path.join(process.cwd(), 'docs'); + let hasTargetBlankLinks = false; crossRefs = []; // 1. 扫描所有 _meta.json 检测跨模块引用(以顶层目录为单位判断) @@ -284,6 +321,7 @@ export function pluginCrossRefSidebar(): RspressPlugin { const meta: unknown = JSON.parse(fs.readFileSync(metaFile, 'utf-8')); if (!Array.isArray(meta) || meta.length === 0) continue; + hasTargetBlankLinks = hasTargetBlankLinks || hasUnproxiedLinks(meta); for (const link of collectLeafLinks(meta)) { if (link.startsWith('http://') || link.startsWith('https://')) @@ -311,7 +349,7 @@ export function pluginCrossRefSidebar(): RspressPlugin { crossRefCanonicalMap[ref.aliasRoute] = ref.targetLink.split('#')[0]; } - if (crossRefs.length === 0) return config; + if (crossRefs.length === 0 && !hasTargetBlankLinks) return config; // 2. 为所有顶层目录生成 sidebar(设置后 _meta.json 不再生效) const sidebar: Sidebar = (config.themeConfig?.sidebar as Sidebar) || {}; diff --git a/docs/shared/blankTargetLink.ts b/docs/shared/blankTargetLink.ts new file mode 100644 index 00000000000..4355d6986b8 --- /dev/null +++ b/docs/shared/blankTargetLink.ts @@ -0,0 +1,19 @@ +export const TARGET_BLANK_PREFIX = '__target_blank__:'; + +export function encodeTargetBlankHref(href: string): string { + return `${TARGET_BLANK_PREFIX}${href}`; +} + +export function decodeTargetBlankHref(href: string) { + if (!href.startsWith(TARGET_BLANK_PREFIX)) { + return { + href, + targetBlank: false, + }; + } + + return { + href: href.slice(TARGET_BLANK_PREFIX.length) || '/', + targetBlank: true, + }; +} diff --git a/docs/theme/index.tsx b/docs/theme/index.tsx index dfbc33722e8..44fadbe82e6 100644 --- a/docs/theme/index.tsx +++ b/docs/theme/index.tsx @@ -1,12 +1,30 @@ -import { NoSSR, useFrontmatter, useLang, useNavigate, usePage, usePages } from '@rspress/core/runtime'; +import { + NoSSR, + useFrontmatter, + useLang, + useNavigate, + usePage, + usePages, +} from '@rspress/core/runtime'; import type { Feature } from '@rspress/core'; -import { Badge, DocContent, DocLayout as OriginalDocLayout, getCustomMDXComponent as basicGetCustomMDXComponent, Layout as BasicLayout, HomeFooter, Link, renderHtmlOrText, Tab, Tabs } from '@rspress/core/theme-original'; +import { + Badge, + DocContent, + DocLayout as OriginalDocLayout, + getCustomMDXComponent as basicGetCustomMDXComponent, + Layout as BasicLayout, + HomeFooter, + Link as OriginalLink, + renderHtmlOrText, + Tab, + Tabs, +} from '@rspress/core/theme-original'; import { LlmsContainer, LlmsCopyButton, LlmsViewOptions, } from '@rspress/plugin-llms/runtime'; -import type { ComponentProps, JSX, ReactNode } from 'react'; +import React, { type ComponentProps, type JSX, type ReactNode } from 'react'; import { PluginCard } from './components/PluginCard'; import { PluginInfo } from './components/PluginInfo'; import { PluginList } from './components/PluginList'; @@ -14,6 +32,7 @@ import { ProvidedBy } from './components/ProvidedBy'; import './index.scss'; import { transformHref, useLangPrefix } from './utils'; import { HomeHero } from './components/HomeHero'; +import { decodeTargetBlankHref } from '../shared/blankTargetLink'; function getCustomMDXComponent() { const { h1: H1, ...mdxComponents } = basicGetCustomMDXComponent(); @@ -46,6 +65,24 @@ function getCustomMDXComponent() { export { getCustomMDXComponent }; +export function Link(props: ComponentProps) { + const { href = '/', rel, target, ...restProps } = props; + const { href: nextHref, targetBlank } = decodeTargetBlankHref(href); + + if (!targetBlank) { + return ; + } + + return ( + + ); +} + export interface HomeLayoutProps { beforeHero?: ReactNode; afterHero?: ReactNode; @@ -78,15 +115,20 @@ type ThemePage = { frontmatter?: ThemeFrontmatter & Record; }; -function getFeatureGroups(page?: ThemePage | { frontmatter?: ThemeFrontmatter }): HomeFeatureGroup[] { +function getFeatureGroups( + page?: ThemePage | { frontmatter?: ThemeFrontmatter }, +): HomeFeatureGroup[] { return page?.frontmatter?.features ?? []; } -function isPluginDetailPage(frontmatter?: ThemeFrontmatter, routePath?: string): boolean { +function isPluginDetailPage( + frontmatter?: ThemeFrontmatter, + routePath?: string, +): boolean { return Boolean( frontmatter?.displayName && - frontmatter?.packageName && - routePath?.includes('/plugins/@nocobase/'), + frontmatter?.packageName && + routePath?.includes('/plugins/@nocobase/'), ); } @@ -117,16 +159,14 @@ export function HomeLayout(props: HomeLayoutProps) { >
{beforeHero} - { - frontmatter.hero && ( - - ) - } + {frontmatter.hero && ( + + )} {afterHero} {beforeFeatures} @@ -168,8 +208,8 @@ export const Layout = () => { }; const isPluginDetailPage = Boolean( frontmatter?.displayName && - frontmatter?.packageName && - routePath?.startsWith('/plugins/@'), + frontmatter?.packageName && + routePath?.startsWith('/plugins/@'), ); const pageClassName = [ isPluginDetailPage ? 'plugin-detail-page' : '', @@ -199,7 +239,9 @@ export const Layout = () => { export function DocLayout(props: ComponentProps) { const { page } = usePage(); - const { frontmatter } = useFrontmatter() as { frontmatter?: ThemeFrontmatter }; + const { frontmatter } = useFrontmatter() as { + frontmatter?: ThemeFrontmatter; + }; if (!isPluginDetailPage(frontmatter, page.routePath)) { return ; @@ -237,7 +279,9 @@ function HomeFeatureItem({ feature }: { feature: Feature }): JSX.Element {
{ if (link) { navigate(transformHref(link, langPrefix)); @@ -252,7 +296,9 @@ function HomeFeatureItem({ feature }: { feature: Feature }): JSX.Element {

{link ? ( {title} - ) : title} + ) : ( + title + )}

currentPage.lang === lang && currentPage.frontmatter?.pageName === 'guide'); + const page = pages.find( + (currentPage) => + currentPage.lang === lang && + currentPage.frontmatter?.pageName === 'guide', + ); if (page) { - const allItems = getFeatureGroups(page).flatMap((group) => group.items ?? []); + const allItems = getFeatureGroups(page).flatMap( + (group) => group.items ?? [], + ); items = [...allItems.filter((item) => item.showOnHome), ...items]; } } else if (index === 3) { - const page = pages.find((currentPage) => currentPage.lang === lang && currentPage.frontmatter?.pageName === 'development'); + const page = pages.find( + (currentPage) => + currentPage.lang === lang && + currentPage.frontmatter?.pageName === 'development', + ); if (page) { // 把 page.frontmatter?.features 里的 items 都拍平合并,取前 8 个 - const allItems = getFeatureGroups(page).flatMap((group) => group.items ?? []); - items = [...allItems.filter((item) => item.showOnHome), ...(feature.items ?? [])]; + const allItems = getFeatureGroups(page).flatMap( + (group) => group.items ?? [], + ); + items = [ + ...allItems.filter((item) => item.showOnHome), + ...(feature.items ?? []), + ]; } } @@ -304,7 +367,7 @@ export function HomeFeature() { })}

- ) + ); })} ); @@ -325,7 +388,7 @@ export function HomeFeature() { })} - ) + ); })} );