docs: migrate to native Fumadocs search & UI fixes (#12732)

Changes:

- **Search**: Replaced Inkeep with Fumadocs' native built-in search
(createFromSource). Removed @inkeep/cxkit-react and custom search
dialogs.

- **Hydration Fix**: Removed markdown links from ### headings in
egress.mdx (across all versions) to fix the <a inside a> DOM mismatch
error.

- **Console Fix**: Added the missing key prop to <VersionSelector/> in
the layout to resolve the React list warning.

---------

Co-authored-by: Florian Forster <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Federico Coppede
2026-09-11 10:47:53 -07:00
committed by GitHub
co-authored by Florian Forster Claude Fable 5.1
parent 7e34f04361
commit 8ccd5b206f
12 changed files with 284 additions and 1385 deletions
+2 -1
View File
@@ -63,7 +63,8 @@ export default async function Layout(props: { children: ReactNode; params: Promi
{...baseOptions()}
sidebar={{
banner: (
<VersionSelector />
// fumadocs renders the banner inside a children array, so it needs a key.
<VersionSelector key="version-selector" />
),
}}
>
+28 -3
View File
@@ -1,7 +1,32 @@
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
import { getBreadcrumbItems } from 'fumadocs-core/breadcrumb';
import { createSearchAPI } from 'fumadocs-core/search/server';
import { source, versionSource } from '@/lib/source';
import { getVersionFromUrl, LATEST_VERSION } from '@/lib/versions';
export const { GET } = createFromSource(source, {
// Both loaders share one index. Every entry is tagged with its docs version so the
// client can scope results to the version being viewed (see app/providers.tsx).
function indexPages(loader: typeof source | typeof versionSource, getTag: (url: string) => string) {
const tree = loader.getPageTree();
return loader.getPages().map((page) => ({
id: page.url,
url: page.url,
title: page.data.title,
description: page.data.description,
structuredData: page.data.structuredData,
// Sidebar path (e.g. "Deploy & Operate › Self-Hosted"), shown with each result.
breadcrumbs: getBreadcrumbItems(page.url, tree)
.map((item) => item.name)
.filter((name): name is string => typeof name === 'string'),
tag: getTag(page.url),
}));
}
export const { GET } = createSearchAPI('advanced', {
// https://docs.orama.com/docs/orama-js/supported-languages
language: 'english',
indexes: [
...indexPages(source, () => LATEST_VERSION),
...indexPages(versionSource, getVersionFromUrl),
],
});
+12 -5
View File
@@ -1,20 +1,27 @@
'use client';
import { RootProvider } from 'fumadocs-ui/provider/next';
import dynamic from "next/dynamic";
import { ReactNode } from 'react';
import { useParams } from 'next/navigation';
import MixpanelProvider from '@/components/mixpanel-provider';
import PlausibleProvider from '@/components/plausible-provider';
const SearchDialog = dynamic(() => import("@/components/inkeep-search"));
import { getVersionFromSlug } from '@/lib/versions';
export function Providers({ children }: { children: ReactNode }) {
const params = useParams();
const slug = params?.slug as string[] | undefined;
return (
<RootProvider
search={{
enabled: true,
SearchDialog: SearchDialog as any,
options: {
api: '/docs/api/search',
// Every search index entry is tagged with its docs version (see
// app/api/search/route.ts). `defaultTag` applies that filter; `tags`
// would only add a visible filter pill.
defaultTag: getVersionFromSlug(slug),
},
}}
>
<PlausibleProvider />
-71
View File
@@ -1,71 +0,0 @@
"use client";
import type { SharedProps } from "fumadocs-ui/components/dialog/search";
import {
InkeepModalSearchAndChat,
type InkeepModalSearchAndChatProps,
} from "@inkeep/cxkit-react";
import { useEffect, useState } from "react";
export default function CustomDialog(props: SharedProps) {
const [syncTarget, setSyncTarget] = useState<HTMLElement | null>(null);
const { open, onOpenChange } = props;
// We do this because document is not available in the server
useEffect(() => {
setSyncTarget(document.documentElement);
}, []);
const config: InkeepModalSearchAndChatProps = {
baseSettings: {
apiKey: process.env.NEXT_PUBLIC_INKEEP_API_KEY,
primaryBrandColor: '#425b76',
organizationDisplayName: "Zitadel",
colorMode: {
sync: {
target: syncTarget,
attributes: ["class"],
isDarkMode: (attributes) => !!attributes.class?.includes("dark"),
},
},
},
modalSettings: {
isOpen: open,
onOpenChange,
// optional settings
},
searchSettings: {
// optional settings
},
aiChatSettings: {
aiAssistantName: 'Zitadel AI Assistant',
chatSubjectName: 'Zitadel',
placeholder: 'How can I enable MFA?',
introMessage: 'Hey! I’m the Zitadel AI assistant 🤖 — throw your questions my way!',
aiAssistantAvatar: '/icons/favicon-32x32.png',
userAvatar: '/icons/user.png',
exampleQuestionsLabel: 'Example Questions:',
exampleQuestions: [
'What is an Instance?',
'What is an Organization?',
'How to create an Action?',
'How to integrate Zitadel to my React app?',
],
isFirstExampleQuestionHighlighted: true,
shouldOpenLinksInNewTab: true,
isCopyChatButtonVisible: true,
disclaimerSettings: {
isEnabled: true,
label: 'AI Assistant',
tooltip: 'Responses are AI-generated and may require verification.',
},
toolbarButtonLabels: {
clear: 'Start Over',
share: 'Share Chat',
getHelp: 'Get Help',
stop: 'Stop',
copyChat: 'Copy Chat',
},
},
};
return <InkeepModalSearchAndChat {...config} />;
}
@@ -222,9 +222,9 @@ The base URL is: **https://`${CUSTOM_DOMAIN}`/auth/v1**
Let’s start with a user who has multiple roles in different organizations in a multi-tenanted setup. You can use the logged-in user’s token or the service account’s token to retrieve the roles assigned to this user using the [APIs listed under user authorizations/grants in the auth API](/reference/api/authorization/zitadel.authorization.v2.AuthorizationService.ListAuthorizations).
**Scope used:** `openid urn:zitadel:iam:org:project:id:zitadel:aud`
#### **1. [List my project roles](/reference/api/auth/zitadel.auth.v1.AuthService.ListMyProjectPermissions)**
#### **1. List my project roles**
Returns a list of roles for the authenticated user and for the requesting project (based on the token).
Returns a list of roles for the authenticated user and for the requesting project (based on the token). See the [ListMyProjectPermissions](/reference/api/auth/zitadel.auth.v1.AuthService.ListMyProjectPermissions) API reference.
**URL: https://`${CUSTOM_DOMAIN}`/auth/v1/permissions/me/_search**
@@ -245,9 +245,9 @@ curl -L -X POST 'https://${CUSTOM_DOMAIN}/auth/v1/permissions/me/_search' \
}
```
#### **2.[List my ZITADEL permissions](/reference/api/auth/zitadel.auth.v1.AuthService.ListMyZitadelPermissions)​**
#### **2. List my ZITADEL permissions**
Returns a list of permissions the authenticated user has in ZITADEL based on the administrator roles the user has. (e.g: `ORG_OWNER` = `org.read`, `org.write`, ...).
Returns a list of permissions the authenticated user has in ZITADEL based on the administrator roles the user has. (e.g: `ORG_OWNER` = `org.read`, `org.write`, ...). See the [ListMyZitadelPermissions](/reference/api/auth/zitadel.auth.v1.AuthService.ListMyZitadelPermissions) API reference.
This request can be used if you are building a management UI. For instance, if the UI is managing users, you can show the management functionality based on the permissions the user has. Here’s an example: if the user has `user.read` and `user.write` permission you can show the edit buttons, if the user only has `user.read` permission, you can hide the edit buttons.
@@ -293,9 +293,9 @@ curl -L -X POST 'https://${CUSTOM_DOMAIN}/auth/v1/permissions/zitadel/me/_search
}
```
#### **[3. List my role assignments](/reference/api/auth/zitadel.auth.v1.AuthService.ListMyUserGrants)**
#### **3. List my role assignments**
Returns a list of roles the authenticated user has been assigned. Role assignments (referred to as user grants in the APIs) consist of an organization, a project, and roles.
Returns a list of roles the authenticated user has been assigned. Role assignments (referred to as user grants in the APIs) consist of an organization, a project, and roles. See the [ListMyUserGrants](/reference/api/auth/zitadel.auth.v1.AuthService.ListMyUserGrants) API reference.
**URL: https://`${CUSTOM-DOMAIN}`/auth/v1/usergrants/me/_search**
@@ -405,9 +405,9 @@ In [APIs listed under user grants in the management API](/reference/api/manageme
**Scope used:** `openid urn:zitadel:iam:org:project:id:zitadel:aud`
#### **1. [Search user grants](/reference/api/management/zitadel.management.v1.ManagementService.ListUserGrants)​**
#### **1. Search user grants**
Returns a list of user roles that match the search queries. A user with administrator permissions will call this API and will also have to reside in the same organization as the user.
Returns a list of user roles that match the search queries. A user with administrator permissions will call this API and will also have to reside in the same organization as the user. See the [ListUserGrants](/reference/api/management/zitadel.management.v1.ManagementService.ListUserGrants) API reference.
**URL: https://`${CUSTOM_DOMAIN}`/management/v1/users/grants/_search**
@@ -474,9 +474,9 @@ curl -L -X POST 'https://${CUSTOM_DOMAIN}/management/v1/users/grants/_search' \
}
```
#### **2. [User grant by ID](/reference/api/management/zitadel.management.v1.ManagementService.GetUserGrantByID)​**
#### **2. User grant by ID**
Returns a user grant per ID. A user grant is a role a user has for a specific project and organization.
Returns a user grant per ID. A user grant is a role a user has for a specific project and organization. See the [GetUserGrantByID](/reference/api/management/zitadel.management.v1.ManagementService.GetUserGrantByID) API reference.
**URL: https://`${CUSTOM_DOMAIN}`/management/v1/users/:userId/grants/:grantId**
@@ -12,8 +12,8 @@ This page lists the static Egress (outgoing) IP addresses used by ZITADEL Cloud
You need to allowlist these IP addresses if you use features where ZITADEL initiates a connection to your systems. This is commonly required for the following scenarios:
### [Identity Providers & Federation](/guides/integrate/identity-providers/introduction)
If you are federating an external Identity Provider (IdP) that sits behind a firewall:
### Identity Providers & Federation
If you are federating an [external Identity Provider (IdP)](/guides/integrate/identity-providers/introduction) that sits behind a firewall:
* **LDAP / Active Directory:** When ZITADEL connects to your LDAP server (typically port `636` for LDAPS).
* **OIDC / OAuth:** When ZITADEL connects to your IdP for:
* **Discovery:** Fetching configuration from `/.well-known/openid-configuration`.
@@ -22,7 +22,8 @@ If you are federating an external Identity Provider (IdP) that sits behind a fir
* **Keys:** Fetching signing keys from the `jwks_uri`.
* **SAML:** If ZITADEL needs to fetch the `metadata.xml` or artifact resolution services from an internal SAML IdP.
### [Notification Providers](/guides/manage/customize/notification-providers)
### Notification Providers
For custom [Notification Providers](/guides/manage/customize/notification-providers):
* [**SMTP:**](/guides/manage/customize/notification-providers#smtp-providers) If you configured a custom SMTP sender pointing to your own mail server.
* [**Webhook / HTTP provider:**](/guides/manage/customize/notification-providers#webhook-http-provider) If you use a custom gateway for SMS or Emails.
+20
View File
@@ -0,0 +1,20 @@
/**
* Versioned docs are downloaded into `content/v{major}.{minor}/` by
* `scripts/fetch-remote-content.mjs` and served under `/docs/v{major}.{minor}/...`.
* The search index tags every page with its version so results can be scoped to
* the version currently being viewed.
*/
export const LATEST_VERSION = 'latest';
const VERSION_SEGMENT = /^v\d+\.\d+$/;
/** `['v4.17', 'guides', ...]` -> `'v4.17'`; anything else -> `'latest'`. */
export function getVersionFromSlug(slug: string[] | undefined): string {
const first = slug?.[0];
return first && VERSION_SEGMENT.test(first) ? first : LATEST_VERSION;
}
/** `/v4.17/guides/...` -> `'v4.17'`; `/guides/...` -> `'latest'`. */
export function getVersionFromUrl(url: string): string {
return getVersionFromSlug(url.split('/').filter(Boolean));
}
+1 -1
View File
@@ -91,7 +91,7 @@ const nextConfig = {
headers: [
{
key: 'Content-Security-Policy',
value: `default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.youtube.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://www.gstatic.com/charts/ https://www.youtube.com/; child-src zitadel.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://www.gstatic.com/charts/ zitadel.com; font-src 'self' https://fonts.gstatic.com https://fonts.googleapis.com; object-src 'none'; connect-src 'self' https://raw.githubusercontent.com/zitadel/ https://api.inkeep.com https://api.io.inkeep.com https://www.youtube.com; frame-src https://www.youtube.com/ https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/; img-src 'self' https://raw.githubusercontent.com/devicons/devicon/master/icons/ https://i.ytimg.com https://yt3.ggpht.com data: `,
value: `default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.youtube.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://www.gstatic.com/charts/ https://www.youtube.com/; child-src zitadel.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://www.gstatic.com/charts/ zitadel.com; font-src 'self' https://fonts.gstatic.com https://fonts.googleapis.com; object-src 'none'; connect-src 'self' https://raw.githubusercontent.com/zitadel/ https://www.youtube.com; frame-src https://www.youtube.com/ https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/; img-src 'self' https://raw.githubusercontent.com/devicons/devicon/master/icons/ https://i.ytimg.com https://yt3.ggpht.com data: `,
},
{
key: 'Strict-Transport-Security',
-1
View File
@@ -25,7 +25,6 @@
"dependencies": {
"@headlessui/react": "^2.2.10",
"@heroicons/react": "^2.2.0",
"@inkeep/cxkit-react": "^0.5.117",
"@scalar/api-reference": "^1.55.3",
"@shikijs/rehype": "^4.0.2",
"@shikijs/types": "^4.0.2",
@@ -83,7 +83,38 @@ export function resetCache() {
cachedRef = null;
}
// Parses `git ls-remote --tags` output into the `{ name }` shape the GitHub API returns.
export function parseLsRemoteTags(output) {
const names = new Set();
for (const line of output.split('\n')) {
const ref = line.split('\t')[1];
if (!ref?.startsWith('refs/tags/')) continue;
// annotated tags are listed twice, the second time peeled as `<tag>^{}`
names.add(ref.slice('refs/tags/'.length).replace(/\^\{\}$/, ''));
}
return [...names].map((name) => ({ name }));
}
async function fetchTags() {
// Unauthenticated GitHub REST API calls are limited to 60/hour per IP, which shared
// build runners such as Vercel regularly exhaust. `git ls-remote` is not rate limited.
const remote = `https://github.com/${REPO}.git`;
try {
console.log(`Listing tags via git ls-remote ${remote}...`);
const output = execSync(`git ls-remote --tags ${remote}`, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 16 * 1024 * 1024,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
const tags = parseLsRemoteTags(output);
if (tags.length === 0) throw new Error('no tags returned');
console.log(`Found ${tags.length} tags.`);
return tags;
} catch (err) {
console.warn(`[tags] git ls-remote failed (${safeLog(err.message)}), falling back to the GitHub API...`);
}
const token = process.env.GITHUB_TOKEN;
const headers = { 'User-Agent': 'node-fetch' };
if (token) headers['Authorization'] = `token ${token}`;
@@ -449,6 +480,42 @@ async function fixRelativeImports(versionDir, tagOrBranch) {
}
}
/**
* fumadocs wraps heading text in its own anchor (`<a href="#id">`), so a markdown
* link inside a heading ends up as nested `<a>` tags. Browsers split those while
* parsing, which breaks React hydration (error #418) and the heading's anchor.
* Latest content is fixed at the source; versioned content is downloaded from git
* tags, so the link is unwrapped into plain heading text here instead.
*/
export function unwrapHeadingLinks(content) {
let inFence = false;
return content
.split('\n')
.map((line) => {
if (/^\s*(```|~~~)/.test(line)) inFence = !inFence;
if (inFence || !/^#{1,6}\s/.test(line)) return line;
// [text](url) -> text; leaves images (![alt](src)) alone
return line.replace(/(?<!!)\[([^\]]+)\]\([^)]*\)/g, '$1');
})
.join('\n');
}
function fixHeadingLinks(versionDir) {
if (!fs.existsSync(versionDir)) return;
for (const file of fs.readdirSync(versionDir, { recursive: true })) {
const filePath = join(versionDir, file);
if (!filePath.endsWith('.mdx') && !filePath.endsWith('.md')) continue;
if (!fs.statSync(filePath).isFile()) continue;
const content = fs.readFileSync(filePath, 'utf8');
const fixed = unwrapHeadingLinks(content);
if (fixed !== content) {
console.log(`[fix-headings] Unwrapped link in heading: ${file}`);
fs.writeFileSync(filePath, fixed);
}
}
}
function getLocalVersion() {
const vercelBranch = process.env.VERCEL_GIT_COMMIT_REF;
let branch = vercelBranch;
@@ -515,6 +582,7 @@ async function run() {
// Correctly pass sourceRef here so external files are fetched from the same place (local or remote)
await fixRelativeImports(contentDest, sourceRef);
}
fixHeadingLinks(contentDest);
}));
const versionsJson = [
@@ -2,7 +2,7 @@ import { test, describe, beforeEach, afterEach, mock } from 'node:test';
import assert from 'node:assert';
import fs from 'fs';
import path from 'path';
import { getCurrentRef, resetCache, downloadFileContent, isValidRef, safeLog } from './fetch-remote-content.mjs';
import { getCurrentRef, resetCache, downloadFileContent, isValidRef, safeLog, unwrapHeadingLinks, parseLsRemoteTags } from './fetch-remote-content.mjs';
const TEST_TMP_DIR = path.join(process.cwd(), '.test-tmp');
const MOCK_REPO_ROOT = path.join(TEST_TMP_DIR, 'mock-repo');
@@ -158,4 +158,45 @@ describe('fetch-remote-content', () => {
});
});
// --- parseLsRemoteTags Tests ---
describe('parseLsRemoteTags', () => {
test('extracts tag names and de-duplicates peeled annotated tags', () => {
const output = [
'aaa\trefs/tags/v4.17.3',
'bbb\trefs/tags/v4.17.3^{}',
'ccc\trefs/tags/v4.16.0',
'ddd\trefs/heads/main',
'',
].join('\n');
assert.deepStrictEqual(parseLsRemoteTags(output), [{ name: 'v4.17.3' }, { name: 'v4.16.0' }]);
});
});
// --- unwrapHeadingLinks Tests ---
describe('unwrapHeadingLinks', () => {
test('unwraps markdown links inside headings only', () => {
const input = [
'### [Identity Providers & Federation](/guides/integrate/identity-providers/introduction)',
'#### **1. [List my project roles](/reference/api/auth/AuthService.ListMyProjectPermissions)**',
'A paragraph with a [link](/kept) is left untouched.',
].join('\n');
const expected = [
'### Identity Providers & Federation',
'#### **1. List my project roles**',
'A paragraph with a [link](/kept) is left untouched.',
].join('\n');
assert.strictEqual(unwrapHeadingLinks(input), expected);
});
test('leaves images in headings and comments inside code fences alone', () => {
const input = [
'## ![logo](/img/logo.png) Title',
'```bash',
'# [not a heading](https://example.com)',
'```',
].join('\n');
assert.strictEqual(unwrapHeadingLinks(input), input);
});
});
});