Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 74 additions & 8 deletions apps/docs/app/[lang]/docs/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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: <span lang={contentLang}>{item.title}</span> }));
}

/**
* Open Graph wants `language_TERRITORY`. Our locale tags carry no territory
* (`en`, `zh-Hans`, `ja`, …), so the honest mapping is the tag with the
Expand Down Expand Up @@ -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
Expand All @@ -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
// `<html lang>` declares for the chrome and the content alike. `<html lang>`
// 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
Expand All @@ -291,17 +338,36 @@ export default async function Page(props: {
dangerouslySetInnerHTML={{ __html: jsonLdHtml(item) }}
/>
))}
<DocsPage toc={loaded.toc} full={page.data.full}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
<DocsPage
toc={isFallback ? tocInLanguage(loaded.toc, contentLang) : loaded.toc}
full={page.data.full}
>
{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.
<Callout type="info" role="note" className="my-0" data-untranslated-notice="">
{text.notTranslated}
</Callout>
)}
<DocsTitle lang={contentLangAttr}>{page.data.title}</DocsTitle>
<DocsDescription lang={contentLangAttr} className="mb-0">
{page.data.description}
</DocsDescription>
<div className="flex flex-row gap-2 items-center border-b pb-6">
<LLMCopyButton markdownUrl={pageMarkdownUrl} />
<LLMCopyButton markdown={copySource} label={text.copyMarkdown} />
<ViewOptions
markdownUrl={pageMarkdownUrl}
githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/content/docs/${page.path}`}
labels={{
open: text.openMenu,
openInGitHub: text.openInGitHub,
openInChatGPT: text.openInChatGPT,
openInClaude: text.openInClaude,
}}
/>
</div>
<DocsBody>
<DocsBody lang={contentLangAttr}>
<MDX
components={getMDXComponents({
a: createRelativeLink(source, page),
Expand Down
10 changes: 7 additions & 3 deletions apps/docs/app/[lang]/docs/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Link from 'next/link';
import type { ReactNode } from 'react';
import { baseOptions } from '@/lib/layout.shared';
import { i18n } from '@/lib/i18n';
import { uiText } from '@/lib/ui-text';

/**
* Links to `/privacy` and `/terms`, at the foot of the docs sidebar (#299).
Expand All @@ -15,17 +16,20 @@ import { i18n } from '@/lib/i18n';
*
* Each link stays in the reader's locale. A locale the page is not written in
* renders the English text, and that route names the English URL as canonical.
* The labels are interface copy, so they come from `lib/ui-text.ts` in the
* reader's locale like the rest of the sidebar's chrome.
*/
function LegalLinks({ lang }: { lang: string }) {
const prefix = lang === i18n.defaultLanguage ? '' : `/${lang}`;
const text = uiText(lang);

return (
<nav aria-label="Legal" className="flex gap-4 px-2 pt-3 text-xs text-fd-muted-foreground">
<nav aria-label={text.legalNav} className="flex gap-4 px-2 pt-3 text-xs text-fd-muted-foreground">
<Link href={`${prefix}/privacy`} className="hover:text-fd-foreground">
Privacy
{text.privacy}
</Link>
<Link href={`${prefix}/terms`} className="hover:text-fd-foreground">
Terms
{text.terms}
</Link>
</nav>
);
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/app/[lang]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -53,6 +54,7 @@ export default async function LanguageLayout({
name: LANGUAGE_NAMES[l] || l,
locale: l,
}))}
translations={fumadocsTranslations(uiText(lang))}
>
{children}
</DocsRootProvider>
Expand Down
12 changes: 11 additions & 1 deletion apps/docs/app/[lang]/root-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Expand All @@ -92,7 +102,7 @@ export function DocsRootProvider({
Link={Link as Framework['Link']}
Image={Image as Framework['Image']}
>
<RootProvider i18n={{ locale, locales }}>{children}</RootProvider>
<RootProvider i18n={{ locale, locales, translations }}>{children}</RootProvider>
</FrameworkProvider>
);
}
48 changes: 37 additions & 11 deletions apps/docs/components/ai/page-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,33 @@ import { Popover, PopoverContent, PopoverTrigger } from 'fumadocs-ui/components/

const cache = new Map<string, string>();

/**
* 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);

Expand Down Expand Up @@ -52,14 +69,15 @@ export function LLMCopyButton({
onClick={onClick}
>
{checked ? <Check /> : <Copy />}
Copy Markdown
{label}
</button>
);
}

export function ViewOptions({
markdownUrl,
githubUrl,
labels,
}: {
/**
* A URL to the raw Markdown/MDX content of page
Expand All @@ -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 =
Expand All @@ -78,7 +104,7 @@ export function ViewOptions({

return [
{
title: 'Open in GitHub',
title: labels.openInGitHub,
href: githubUrl,
icon: (
<svg fill="currentColor" role="img" viewBox="0 0 24 24">
Expand All @@ -88,7 +114,7 @@ export function ViewOptions({
),
},
{
title: 'Open in ChatGPT',
title: labels.openInChatGPT,
href: `https://chatgpt.com/?${new URLSearchParams({
hints: 'search',
q,
Expand All @@ -106,7 +132,7 @@ export function ViewOptions({
),
},
{
title: 'Open in Claude',
title: labels.openInClaude,
href: `https://claude.ai/new?${new URLSearchParams({
q,
})}`,
Expand All @@ -123,7 +149,7 @@ export function ViewOptions({
),
},
];
}, [githubUrl, markdownUrl]);
}, [githubUrl, markdownUrl, labels]);

return (
<Popover>
Expand All @@ -136,7 +162,7 @@ export function ViewOptions({
}),
)}
>
Open
{labels.open}
<ChevronDown className="size-3.5 text-fd-muted-foreground" />
</PopoverTrigger>
<PopoverContent className="flex flex-col">
Expand Down
Loading
Loading