diff --git a/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx b/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx
index 8108eb5..e4a2779 100644
--- a/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx
+++ b/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx
@@ -1,17 +1,20 @@
-import { SITE_NAME, getPageImage, source } from '@/lib/source';
+import { SITE_NAME, getLLMText, getPageImage, source } from '@/lib/source';
import type { Metadata } from 'next';
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';
import { notFound } from 'next/navigation';
import { getMDXComponents } from '@/mdx-components';
import { createRelativeLink } from 'fumadocs-ui/mdx';
+import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { File, Folder, Files } from 'fumadocs-ui/components/files';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
-import { LLMCopyButton, ViewOptions } from '@/components/ai/page-actions';
+import { LLMCopyButton, type MarkdownSource, ViewOptions } from '@/components/ai/page-actions';
import { gitConfig } from '@/lib/layout.shared';
import { SITE_URL, languageAlternates, localeUrl, translatedLocales } from '@/lib/seo';
import { i18n } from '@/lib/i18n';
+import { uiText } from '@/lib/ui-text';
import type { InferPageType } from 'fumadocs-core/source';
+import type { TOCItemType } from 'fumadocs-core/toc';
/**
* The page type the loader actually produces, frontmatter schema included.
@@ -128,6 +131,21 @@ function canonicalUrl(lang: string, slugs: string[]): string {
return localeUrl(canonicalLocale(lang, translatedLocales(slugs)), docsPath(slugs));
}
+/**
+ * The table of contents of a page whose headings are in `contentLang`, ready to
+ * render inside a document declaring a different language.
+ *
+ * The TOC is the page's own headings, so on a fallback they are English — but
+ * Fumadocs renders them in its own container next to the localized "On this
+ * page" label, outside the element that carries the content's `lang`. Marking
+ * the container would mislabel that heading (and the "no headings" notice), so
+ * each item's title is wrapped instead: the list, the mobile popover and the
+ * popover trigger all render `title`, so this one wrap reaches all three.
+ */
+function tocInLanguage(toc: TOCItemType[], contentLang: string): TOCItemType[] {
+ return toc.map((item) => ({ ...item, title: {item.title} }));
+}
+
/**
* Open Graph wants `language_TERRITORY`. Our locale tags carry no territory
* (`en`, `zh-Hans`, `ja`, …), so the honest mapping is the tag with the
@@ -250,7 +268,8 @@ export default async function Page(props: {
const loaded = await page.data.load();
const MDX = loaded.body;
- // Resolved once and handed to both controls, so they cannot drift apart and
+ // Resolved once and handed to both controls (the copy button takes it through
+ // `copySource` below), so they cannot drift apart and
// so a third control added below inherits the locale-independent URL instead
// of re-deriving one from `page.url`. That re-derivation is the whole defect:
// it is invisible in the rendered markup — `markdownUrl` reaches the browser
@@ -266,6 +285,34 @@ export default async function Page(props: {
// the English page is Japanese.
const contentLang = canonicalLocale(params.lang, translatedLocales(page.slugs));
+ // A fallback is a locale route serving the English page because that locale
+ // has no source file for it. Exact, not inferred: fumadocs builds each
+ // locale's file system by copying the English file OBJECTS in and letting
+ // real translations overwrite them, so a fallback page carries the English
+ // file's own `path` (`operate/backup.mdx`, never `operate/backup.ja.mdx`) —
+ // the discriminator `translatedLocales` reads. `page.locale` cannot answer
+ // this: it is the locale the page was looked up under, on both kinds.
+ const isFallback = contentLang !== params.lang;
+
+ // What the content elements declare. Only a fallback needs anything: a
+ // translated or English page is already in the document's language, which
+ // `` declares for the chrome and the content alike. ``
+ // itself stays the route locale (#181) — the sidebar, search and buttons
+ // around this page ARE in that language.
+ const contentLangAttr = isFallback ? contentLang : undefined;
+ const text = uiText(params.lang);
+
+ // Copy Markdown copies the page on screen. For an English page and for a
+ // fallback that is the English page, served at `pageMarkdownUrl`. For a real
+ // translation it is this locale's text, which the `.mdx` surface does not
+ // serve (English-only, see `markdownUrl`), so it is rendered into the page
+ // instead: `getLLMText` builds it exactly as `/llms.mdx/docs/...` builds the
+ // English body, from the module `page.data.load()` above already loaded.
+ const copySource: MarkdownSource =
+ contentLang === i18n.defaultLanguage
+ ? { url: pageMarkdownUrl }
+ : { text: await getLLMText(page) };
+
// Structured data. Emitted from the page rather than from `generateMetadata`,
// which can only produce meta/link elements — the Metadata API has no channel
// for a JSON-LD script. Google reads `application/ld+json` from either the
@@ -291,17 +338,36 @@ export default async function Page(props: {
dangerouslySetInnerHTML={{ __html: jsonLdHtml(item) }}
/>
))}
-
- {page.data.title}
- {page.data.description}
+
+ {isFallback && (
+ // In the route locale, outside every element marked `lang` below:
+ // the notice is addressed to this locale's reader, and it comes
+ // before the English it describes so it is read first.
+
+ {text.notTranslated}
+
+ )}
+ {page.data.title}
+
+ {page.data.description}
+
-
+
-
+
+
);
diff --git a/apps/docs/app/[lang]/layout.tsx b/apps/docs/app/[lang]/layout.tsx
index 32e0843..d59faee 100644
--- a/apps/docs/app/[lang]/layout.tsx
+++ b/apps/docs/app/[lang]/layout.tsx
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react';
import { i18n } from '@/lib/i18n';
+import { fumadocsTranslations, uiText } from '@/lib/ui-text';
import { DocsRootProvider } from './root-provider';
// Language display names mapping
@@ -53,6 +54,7 @@ export default async function LanguageLayout({
name: LANGUAGE_NAMES[l] || l,
locale: l,
}))}
+ translations={fumadocsTranslations(uiText(lang))}
>
{children}
diff --git a/apps/docs/app/[lang]/root-provider.tsx b/apps/docs/app/[lang]/root-provider.tsx
index 16fbfea..505e3e6 100644
--- a/apps/docs/app/[lang]/root-provider.tsx
+++ b/apps/docs/app/[lang]/root-provider.tsx
@@ -11,6 +11,7 @@ import {
import { FrameworkProvider, type Framework } from 'fumadocs-core/framework';
import { RootProvider } from 'fumadocs-ui/provider/base';
import { i18n as i18nConfig } from '@/lib/i18n';
+import type { FumadocsTranslations } from '@/lib/ui-text';
/**
* Map an internal Next.js route pathname onto the public URL path the browser
@@ -73,10 +74,19 @@ function usePublicPathname(): string {
export function DocsRootProvider({
locale,
locales,
+ translations,
children,
}: {
locale: string;
locales: { name: string; locale: string }[];
+ /**
+ * Fumadocs' interface strings for `locale` — the search box, "On this
+ * page", the language picker's heading, the previous/next footer. Required:
+ * without it every Fumadocs component falls back to its built-in English,
+ * which is the defect `lib/ui-text.ts` exists to end, and an optional prop is
+ * how the next caller would reproduce it without anyone noticing.
+ */
+ translations: FumadocsTranslations;
children: ReactNode;
}) {
return (
@@ -92,7 +102,7 @@ export function DocsRootProvider({
Link={Link as Framework['Link']}
Image={Image as Framework['Image']}
>
- {children}
+ {children}
);
}
diff --git a/apps/docs/components/ai/page-actions.tsx b/apps/docs/components/ai/page-actions.tsx
index 7bd762c..1067cbe 100644
--- a/apps/docs/components/ai/page-actions.tsx
+++ b/apps/docs/components/ai/page-actions.tsx
@@ -8,16 +8,33 @@ import { Popover, PopoverContent, PopoverTrigger } from 'fumadocs-ui/components/
const cache = new Map();
+/**
+ * Where the Markdown of the page being shown comes from.
+ *
+ * - `url`: fetched on click from the site's `.mdx` surface. That surface is
+ * English-only by design (see `markdownUrl` in the docs page), so this is the
+ * source for an English page and for a locale URL serving the English page
+ * as a fallback — in both cases the page on screen IS the English one.
+ * - `text`: the Markdown itself, rendered into the page at build time. Used for
+ * a real translation, whose Markdown has no URL to fetch from: publishing
+ * one would make the `.mdx` surface locale-aware, which is a decision about
+ * that whole surface and not this button's to make.
+ */
+export type MarkdownSource = { url: string } | { text: string };
+
export function LLMCopyButton({
- /**
- * A URL to fetch the raw Markdown/MDX content of page
- */
- markdownUrl,
+ markdown,
+ label,
}: {
- markdownUrl: string;
+ markdown: MarkdownSource;
+ /** The button's visible text, in the route locale. */
+ label: string;
}) {
const [isLoading, setLoading] = useState(false);
const [checked, onClick] = useCopyButton(async () => {
+ if ('text' in markdown) return navigator.clipboard.writeText(markdown.text);
+
+ const markdownUrl = markdown.url;
const cached = cache.get(markdownUrl);
if (cached) return navigator.clipboard.writeText(cached);
@@ -52,7 +69,7 @@ export function LLMCopyButton({
onClick={onClick}
>
{checked ? : }
- Copy Markdown
+ {label}
);
}
@@ -60,6 +77,7 @@ export function LLMCopyButton({
export function ViewOptions({
markdownUrl,
githubUrl,
+ labels,
}: {
/**
* A URL to the raw Markdown/MDX content of page
@@ -70,6 +88,14 @@ export function ViewOptions({
* Source file URL on GitHub
*/
githubUrl: string;
+
+ /** The trigger's and the menu items' visible text, in the route locale. */
+ labels: {
+ open: string;
+ openInGitHub: string;
+ openInChatGPT: string;
+ openInClaude: string;
+ };
}) {
const items = useMemo(() => {
const fullMarkdownUrl =
@@ -78,7 +104,7 @@ export function ViewOptions({
return [
{
- title: 'Open in GitHub',
+ title: labels.openInGitHub,
href: githubUrl,
icon: (