mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
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:
co-authored by
Florian Forster
Claude Fable 5.1
parent
7e34f04361
commit
8ccd5b206f
@@ -63,7 +63,8 @@ export default async function Layout(props: { children: ReactNode; params: Promi
|
|||||||
{...baseOptions()}
|
{...baseOptions()}
|
||||||
sidebar={{
|
sidebar={{
|
||||||
banner: (
|
banner: (
|
||||||
<VersionSelector />
|
// fumadocs renders the banner inside a children array, so it needs a key.
|
||||||
|
<VersionSelector key="version-selector" />
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,32 @@
|
|||||||
import { source } from '@/lib/source';
|
import { getBreadcrumbItems } from 'fumadocs-core/breadcrumb';
|
||||||
import { createFromSource } from 'fumadocs-core/search/server';
|
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
|
// https://docs.orama.com/docs/orama-js/supported-languages
|
||||||
language: 'english',
|
language: 'english',
|
||||||
|
indexes: [
|
||||||
|
...indexPages(source, () => LATEST_VERSION),
|
||||||
|
...indexPages(versionSource, getVersionFromUrl),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { RootProvider } from 'fumadocs-ui/provider/next';
|
import { RootProvider } from 'fumadocs-ui/provider/next';
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
import MixpanelProvider from '@/components/mixpanel-provider';
|
import MixpanelProvider from '@/components/mixpanel-provider';
|
||||||
import PlausibleProvider from '@/components/plausible-provider';
|
import PlausibleProvider from '@/components/plausible-provider';
|
||||||
|
import { getVersionFromSlug } from '@/lib/versions';
|
||||||
const SearchDialog = dynamic(() => import("@/components/inkeep-search"));
|
|
||||||
|
|
||||||
export function Providers({ children }: { children: ReactNode }) {
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
|
const params = useParams();
|
||||||
|
const slug = params?.slug as string[] | undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RootProvider
|
<RootProvider
|
||||||
search={{
|
search={{
|
||||||
enabled: true,
|
options: {
|
||||||
SearchDialog: SearchDialog as any,
|
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 />
|
<PlausibleProvider />
|
||||||
|
|||||||
@@ -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).
|
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`
|
**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**
|
**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.
|
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**
|
**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`
|
**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**
|
**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**
|
**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:
|
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)
|
### Identity Providers & Federation
|
||||||
If you are federating an external Identity Provider (IdP) that sits behind a firewall:
|
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).
|
* **LDAP / Active Directory:** When ZITADEL connects to your LDAP server (typically port `636` for LDAPS).
|
||||||
* **OIDC / OAuth:** When ZITADEL connects to your IdP for:
|
* **OIDC / OAuth:** When ZITADEL connects to your IdP for:
|
||||||
* **Discovery:** Fetching configuration from `/.well-known/openid-configuration`.
|
* **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`.
|
* **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.
|
* **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.
|
* [**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.
|
* [**Webhook / HTTP provider:**](/guides/manage/customize/notification-providers#webhook-http-provider) If you use a custom gateway for SMS or Emails.
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -91,7 +91,7 @@ const nextConfig = {
|
|||||||
headers: [
|
headers: [
|
||||||
{
|
{
|
||||||
key: 'Content-Security-Policy',
|
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',
|
key: 'Strict-Transport-Security',
|
||||||
|
|||||||
@@ -25,7 +25,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.10",
|
"@headlessui/react": "^2.2.10",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@inkeep/cxkit-react": "^0.5.117",
|
|
||||||
"@scalar/api-reference": "^1.55.3",
|
"@scalar/api-reference": "^1.55.3",
|
||||||
"@shikijs/rehype": "^4.0.2",
|
"@shikijs/rehype": "^4.0.2",
|
||||||
"@shikijs/types": "^4.0.2",
|
"@shikijs/types": "^4.0.2",
|
||||||
|
|||||||
@@ -83,7 +83,38 @@ export function resetCache() {
|
|||||||
cachedRef = null;
|
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() {
|
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 token = process.env.GITHUB_TOKEN;
|
||||||
const headers = { 'User-Agent': 'node-fetch' };
|
const headers = { 'User-Agent': 'node-fetch' };
|
||||||
if (token) headers['Authorization'] = `token ${token}`;
|
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 () 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() {
|
function getLocalVersion() {
|
||||||
const vercelBranch = process.env.VERCEL_GIT_COMMIT_REF;
|
const vercelBranch = process.env.VERCEL_GIT_COMMIT_REF;
|
||||||
let branch = vercelBranch;
|
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)
|
// Correctly pass sourceRef here so external files are fetched from the same place (local or remote)
|
||||||
await fixRelativeImports(contentDest, sourceRef);
|
await fixRelativeImports(contentDest, sourceRef);
|
||||||
}
|
}
|
||||||
|
fixHeadingLinks(contentDest);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const versionsJson = [
|
const versionsJson = [
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { test, describe, beforeEach, afterEach, mock } from 'node:test';
|
|||||||
import assert from 'node:assert';
|
import assert from 'node:assert';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
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 TEST_TMP_DIR = path.join(process.cwd(), '.test-tmp');
|
||||||
const MOCK_REPO_ROOT = path.join(TEST_TMP_DIR, 'mock-repo');
|
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 = [
|
||||||
|
'##  Title',
|
||||||
|
'```bash',
|
||||||
|
'# [not a heading](https://example.com)',
|
||||||
|
'```',
|
||||||
|
].join('\n');
|
||||||
|
assert.strictEqual(unwrapHeadingLinks(input), input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+97
-1289
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user