diff --git a/docs/.vitepress/authInfoLinks.test.ts b/docs/.vitepress/authInfoLinks.test.ts new file mode 100644 index 0000000000..a185cb9ba6 --- /dev/null +++ b/docs/.vitepress/authInfoLinks.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { AUTH_INFO_INTERFACE_ANCHOR, correctAuthInfoTypeHref, isAuthInfoInterfacePage } from './authInfoLinks.ts'; + +const server = 'api/index/@modelcontextprotocol/server/index.md'; +const client = 'api/index/@modelcontextprotocol/client/index.md'; +const middleware = 'api/@modelcontextprotocol/server/server/middleware/bearerAuth.md'; + +test('public client/server interface pages receive a stable target', () => { + assert.equal(AUTH_INFO_INTERFACE_ANCHOR, 'authinfo-interface'); + assert.equal(isAuthInfoInterfacePage(server), true); + assert.equal(isAuthInfoInterfacePage(client), true); + assert.equal(isAuthInfoInterfacePage(middleware), false); +}); + +test('correct AuthInfo type links but never member links', () => { + const href = '../../../../index/@modelcontextprotocol/server/index.md#authinfo'; + assert.equal(correctAuthInfoTypeHref(href, 'AuthInfo', middleware), href.replace('#authinfo', '#authinfo-interface')); + assert.equal(correctAuthInfoTypeHref(href, 'authInfo', middleware), href); + const sibling = '../../../../index/@modelcontextprotocol/server/index.md#authinfo-1'; + assert.equal(correctAuthInfoTypeHref(sibling, 'AuthInfo', middleware), sibling); +}); + +test('preserve unrelated local anchors and targets', () => { + assert.equal(correctAuthInfoTypeHref('#authinfo', 'AuthInfo', server), '#authinfo-interface'); + assert.equal(correctAuthInfoTypeHref('#authinfo', 'authInfo', server), '#authinfo'); + assert.equal(correctAuthInfoTypeHref('#authinfo', 'AuthInfo', middleware), '#authinfo'); + const unrelated = '../../../../auth/index.md#authinfo'; + assert.equal(correctAuthInfoTypeHref(unrelated, 'AuthInfo', middleware), unrelated); + const clientHref = '../../../index/@modelcontextprotocol/client/index.md#authinfo'; + assert.equal(correctAuthInfoTypeHref(clientHref, 'AuthInfo', middleware), clientHref.replace('#authinfo', '#authinfo-interface')); +}); diff --git a/docs/.vitepress/authInfoLinks.ts b/docs/.vitepress/authInfoLinks.ts new file mode 100644 index 0000000000..2e7b526db4 --- /dev/null +++ b/docs/.vitepress/authInfoLinks.ts @@ -0,0 +1,17 @@ +/** Stable anchor for the public AuthInfo interface, distinct from authInfo members. */ +export const AUTH_INFO_INTERFACE_ANCHOR = 'authinfo-interface'; + +const typePage = /^api\/index\/@modelcontextprotocol\/(?:client|server)\/index\.md$/; +const typeTarget = /(?:^|\/)index\/@modelcontextprotocol\/(?:client|server)\/index\.md$/; + +export function isAuthInfoInterfacePage(page: string): boolean { + return typePage.test(page); +} + +/** Rewrite only links whose *text* names the AuthInfo type, never member links. */ +export function correctAuthInfoTypeHref(href: string, label: string | undefined, page: string): string { + if (label !== 'AuthInfo' || !href.endsWith('#authinfo')) return href; + const path = href.slice(0, -'#authinfo'.length); + if (path ? !typeTarget.test(path) : !isAuthInfoInterfacePage(page)) return href; + return `${path}#${AUTH_INFO_INTERFACE_ANCHOR}`; +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index a027bfaafb..9ab11932b9 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -7,6 +7,7 @@ import { defineConfig, type DefaultTheme } from 'vitepress'; import { generateLlmsArtifacts } from './llms'; import { guideSidebar } from './nav'; import { githubSlug, rewriteSkillLink } from './skill'; +import { AUTH_INFO_INTERFACE_ANCHOR, correctAuthInfoTypeHref, isAuthInfoInterfacePage } from './authInfoLinks'; const docsDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -44,11 +45,29 @@ export default defineConfig({ const orig = md.renderer.rules.link_open ?? ((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options)); md.renderer.rules.link_open = (tokens, idx, options, env, self) => { const href = tokens[idx].attrGet('href'); + if (href) { + const corrected = correctAuthInfoTypeHref(href, tokens[idx + 1]?.content, env.relativePath); + if (corrected !== href) tokens[idx].attrSet('href', corrected); + } if (href?.startsWith('/specification/')) { tokens[idx].attrSet('href', `https://modelcontextprotocol.io${href}`); } return orig(tokens, idx, options, env, self); }; + // AuthInfo is a public interface, but earlier authInfo member headings win the + // auto-generated #authinfo slug. Keep all existing headings/links intact and + // give only the interface a stable second anchor. + md.core.ruler.push('authinfo_interface_anchor', state => { + if (!isAuthInfoInterfacePage(state.env.relativePath)) return; + const i = state.tokens.findIndex( + (token, index) => token.type === 'heading_open' && state.tokens[index + 1]?.content === 'AuthInfo' + ); + if (i < 0) return; + const anchor = new state.Token('html_block', '', 0); + anchor.content = `\n`; + state.tokens.splice(i, 0, anchor); + }); + // Resolve skill links on the assembled page, then GitHub-style anchors to VitePress's heading ids. md.core.ruler.push('page_links', state => { const ids = new Set(); diff --git a/package.json b/package.json index 0be02df4ee..cdf17cdccd 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "docs:dev": "vitepress dev docs", "docs:build": "pnpm docs:api && vitepress build docs", "docs:multi": "bash scripts/build-docs-site.sh", - "docs:check": "pnpm docs:build", + "docs:check": "tsx --test docs/.vitepress/authInfoLinks.test.ts && pnpm docs:build", "typecheck:all": "pnpm -r typecheck", "build:all": "pnpm -r build", "ci:publish": "pnpm run build:all && pnpm changeset publish",