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
15 changes: 11 additions & 4 deletions apps/docs/app/[lang]/docs/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,10 +268,10 @@ export default async function Page(props: {
const loaded = await page.data.load();
const MDX = loaded.body;

// Resolved once and handed to both controls (the copy button takes it through
// `copySource` below), so they cannot drift apart and
// Resolved once and handed to both controls (through `copySource` and
// `assistantReadUrl` 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:
// of re-deriving a `.mdx` URL from `page.url`. That re-derivation is the whole defect:
// it is invisible in the rendered markup — `markdownUrl` reaches the browser
// only as a client-component prop in the RSC payload — so a broken value
// produces no build error, no console warning and no failing gate, and shows
Expand Down Expand Up @@ -313,6 +313,13 @@ export default async function Page(props: {
? { url: pageMarkdownUrl }
: { text: await getLLMText(page) };

// "Open in ChatGPT / Claude" follows the same rule (#305): the assistant is
// sent to the page on screen. The English Markdown when that is the English
// page; the translated page itself when it is a translation — `page.url` is
// this locale's URL, the one being read — because its Markdown has no URL and
// is too long to inline into the prompt (`ViewOptions` has the measurement).
const assistantReadUrl = contentLang === i18n.defaultLanguage ? pageMarkdownUrl : page.url;

// 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 Down Expand Up @@ -357,7 +364,7 @@ export default async function Page(props: {
<div className="flex flex-row gap-2 items-center border-b pb-6">
<LLMCopyButton markdown={copySource} label={text.copyMarkdown} />
<ViewOptions
markdownUrl={pageMarkdownUrl}
readUrl={assistantReadUrl}
githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/content/docs/${page.path}`}
labels={{
open: text.openMenu,
Expand Down
105 changes: 104 additions & 1 deletion apps/docs/app/[lang]/docs/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,114 @@
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import type * as PageTree from 'fumadocs-core/page-tree';
import Link from 'next/link';
import type { ReactNode } from 'react';
import { baseOptions } from '@/lib/layout.shared';
import { i18n } from '@/lib/i18n';
import { translatedLocales } from '@/lib/seo';
import { uiText } from '@/lib/ui-text';

/**
* A page-tree name or description in the language it is really written in.
*
* The default language is the only one a page-tree entry can fall back to:
* fumadocs seeds every locale's tree from the English files (see
* `translatedLocales`), so an entry that is not in the route locale is English.
*/
function inDefaultLanguage(text: ReactNode): ReactNode {
return <span lang={i18n.defaultLanguage}>{text}</span>;
}

/**
* Whether a page entry in `lang`'s tree names a page `lang` has no source file
* for — the English page, served as a fallback.
*
* The detection the docs page uses for its own body (#298), and deliberately
* the same function: `translatedLocales` compares the page's AUTHORED file
* (`operate/backup.ja.mdx` or the inherited `operate/backup.mdx`) with the
* English one. The page `getNodePage` resolves is the page the entry was built
* from — its `$ref` is the locale-independent storage key — so the sidebar,
* the breadcrumb and the footer agree with the page body about which pages are
* English, entry by entry, with no second rule to drift from the first.
*/
function isFallbackPage(node: PageTree.Item, lang: string): boolean {
const page = source.getNodePage(node, lang);
return page !== undefined && !translatedLocales(page.slugs).includes(lang);
}

/**
* Whether a folder entry's name is English in `lang`'s tree.
*
* Fumadocs names a folder from its `meta` title, else from its index page,
* else from the directory name. The same file-identity rule applies to the
* first: a `meta.<lang>.json` is the locale's own, an inherited `meta.json` is
* English. Every folder in `content/docs` has a `meta.<locale>.json` with a
* title today, so this marks nothing on the current tree; it is here so a
* folder added without one is marked rather than silently read as the locale.
*/
function isEnglishFolderName(node: PageTree.Folder, lang: string): boolean {
const meta = source.getNodeMeta(node, lang);
if (meta && typeof (meta.data as { title?: unknown }).title === 'string') {
return meta.path === source.getNodeMeta(node, i18n.defaultLanguage)?.path;
}
if (node.index) return isFallbackPage(node.index, lang);
return true;
}

function markItem(node: PageTree.Item, lang: string): PageTree.Item {
if (!isFallbackPage(node, lang)) return node;
return {
...node,
name: inDefaultLanguage(node.name),
...(node.description ? { description: inDefaultLanguage(node.description) } : {}),
};
}

function markNode(node: PageTree.Node, lang: string): PageTree.Node {
if (node.type === 'page') return markItem(node, lang);
if (node.type !== 'folder') return node;
return {
...node,
...(isEnglishFolderName(node, lang) ? { name: inDefaultLanguage(node.name) } : {}),
...(node.index ? { index: markItem(node.index, lang) } : {}),
children: node.children.map((child) => markNode(child, lang)),
};
}

const markedTrees = new Map<string, PageTree.Root>();

/**
* `lang`'s page tree with every English entry marked `lang="en"` (#305).
*
* On a locale route the sidebar, the breadcrumb and the previous/next footer
* all render names from this tree, inside `<html lang>` declaring the route
* locale. A page the locale has no translation of is named by its English
* title (and described by its English description, which the footer shows), so
* without a mark a screen reader reads "Backup and Disaster Recovery" with
* Chinese pronunciation rules — the language-of-parts defect (WCAG 3.1.2) the
* page body already avoids. axe has no rule for it, so nothing else reports it.
*
* One transform here reaches all three, because all three read the tree this
* layout hands to `DocsLayout`; the `llms` routes read `source.getPageTree`
* for English directly and are not affected. Only the name and description
* are wrapped: `url`, `$id` and `$ref` stay as they were, so active-item
* matching and every lookup by node are untouched. An English route returns
* the tree it was given.
*
* Memoized per locale: the tree and the content are fixed for the life of the
* build, and the layout renders once per page.
*/
function treeWithLanguages(lang: string): PageTree.Root {
const tree = source.pageTree[lang];
if (lang === i18n.defaultLanguage || !tree) return tree;
let marked = markedTrees.get(lang);
if (!marked) {
marked = { ...tree, children: tree.children.map((node) => markNode(node, lang)) };
markedTrees.set(lang, marked);
}
return marked;
}

/**
* Links to `/privacy` and `/terms`, at the foot of the docs sidebar (#299).
*
Expand Down Expand Up @@ -46,7 +149,7 @@ export default async function Layout({

return (
<DocsLayout
tree={source.pageTree[lang]}
tree={treeWithLanguages(lang)}
{...baseOptions(lang)}
sidebar={{ footer: <LegalLinks lang={lang} /> }}
i18n
Expand Down
43 changes: 25 additions & 18 deletions apps/docs/app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { i18n } from '@/lib/i18n';
import { uiText } from '@/lib/ui-text';
import type { Metadata } from 'next';

/**
Expand Down Expand Up @@ -72,21 +73,22 @@ import type { Metadata } from 'next';
*/

/**
* 404 copy per locale. English is the source; a locale missing from this table
* keeps English, which is the same fallback Fumadocs applies to an untranslated
* page. `content/docs/` translations are derived artifacts refreshed by a
* separate pass (AGENTS.md, "Translation workflow"); this table is UI copy in
* app code, the shape `app/[lang]/privacy/page.tsx` already uses.
* 404 copy per locale, read from `lib/ui-text.ts` (`notFound`). English is the
* source, written there; the other locales are that table's UI copy, not
* `content/docs/` translations.
*
* It used to be a table of its own here, and that table had no `zh-Hant` entry
* (#305): a Traditional Chinese reader got the English message, because the
* locale is generated from Simplified by `scripts/gen-zh-hant.mjs` and nothing
* generated a file-local constant. In `lib/ui-text/` it is one more key of the
* table that generator already converts — `ui-text/zh-Hant.json` is produced
* from `ui-text/zh-Hans.json` and `gen-zh-hant --check` holds it to the bytes —
* so the Traditional string is derived, never hand-typed, and `UiText`'s
* exact-key check makes every locale in `lib/i18n.ts` carry one.
*/
const COPY: Record<string, string> = {
en: 'This page could not be found.',
'zh-Hans': '找不到此页面。',
ja: 'このページは見つかりませんでした。',
de: 'Diese Seite konnte nicht gefunden werden.',
es: 'No se ha podido encontrar esta página.',
fr: 'Cette page est introuvable.',
ko: '이 페이지를 찾을 수 없습니다.',
};
const COPY: Record<string, string> = Object.fromEntries(
i18n.languages.map((lang) => [lang, uiText(lang).notFound]),
);

/**
* The document title, used in two places that have to agree.
Expand Down Expand Up @@ -123,16 +125,21 @@ export const metadata: Metadata = {

/**
* Inlined verbatim into a `script` element, so it must stay free of anything
* that could close that element early. Every value it embeds is a compile-time
* constant in this file and in `lib/i18n.ts`; none carries markup.
* that could close that element early. Every value it embeds is a build-time
* constant from `lib/ui-text/` and `lib/i18n.ts`. The copy now comes from a data
* file rather than from this one, so every `<` in it is written as the JSON
* escape (the same rule `jsonLdHtml` applies on the docs page): a string that
* ever carried a closing script tag would then still parse back to itself
* instead of ending this element. No current string has one, so the bytes are
* unchanged.
*
* `i18n.languages` is read rather than `Object.keys(COPY)` on purpose: the
* locale list is the authority for what may appear as a first segment, and a
* locale that is in the list but not yet in `COPY` must fall through to English
* rather than be treated as an unknown segment.
*/
const APPLY_LOCALE = `(function(){try{
var copy=${JSON.stringify(COPY)};
var copy=${JSON.stringify(COPY).replace(/</g, '\\u003c')};
var langs=${JSON.stringify(i18n.languages)};
var seg=location.pathname.split('/')[1];
if(langs.indexOf(seg)===-1||!copy[seg])return;
Expand Down Expand Up @@ -197,7 +204,7 @@ export default function NotFound() {
margin: 0,
}}
>
This page could not be found.
{COPY.en}
</h2>
</div>
</div>
Expand Down
26 changes: 19 additions & 7 deletions apps/docs/components/ai/page-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,26 @@ export function LLMCopyButton({
}

export function ViewOptions({
markdownUrl,
readUrl,
githubUrl,
labels,
}: {
/**
* A URL to the raw Markdown/MDX content of page
* The site-relative URL "Open in ChatGPT / Claude" asks the assistant to
* read: the page on screen, by the same rule `MarkdownSource` follows for
* Copy Markdown (#305).
*
* - An English page, or a locale URL serving the English page as a fallback:
* the page's `.mdx` Markdown, which IS the page on screen.
* - A real translation: the translated page's own URL. Its Markdown has no
* URL to point at (the `.mdx` surface is English-only, see `MarkdownSource`),
* and it does not fit in the query string instead — measured over the 230
* locale pages, the prompt with the page inlined runs from 2089 to 25630
* characters, 165 of them over 8 KiB, and the two assistants publish no
* limit to hold that to. The page URL is short, stable and serves exactly
* the text the reader sees.
*/
markdownUrl: string;
readUrl: string;

/**
* Source file URL on GitHub
Expand All @@ -98,9 +110,9 @@ export function ViewOptions({
};
}) {
const items = useMemo(() => {
const fullMarkdownUrl =
typeof window !== 'undefined' ? new URL(markdownUrl, window.location.origin) : 'loading';
const q = `Read ${fullMarkdownUrl}, I want to ask questions about it.`;
const fullReadUrl =
typeof window !== 'undefined' ? new URL(readUrl, window.location.origin) : 'loading';
const q = `Read ${fullReadUrl}, I want to ask questions about it.`;

return [
{
Expand Down Expand Up @@ -149,7 +161,7 @@ export function ViewOptions({
),
},
];
}, [githubUrl, markdownUrl, labels]);
}, [githubUrl, readUrl, labels]);

return (
<Popover>
Expand Down
49 changes: 44 additions & 5 deletions apps/docs/lib/ui-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,28 @@ import zhHant from './ui-text/zh-Hant.json';
/**
* The docs site's interface copy — the chrome around a page, never the page.
*
* Two kinds of string live here. The first ten keys are Fumadocs' own
* Two kinds of string live here. The first nineteen keys are Fumadocs' own
* `Translations` (the search box, "On this page", "Choose a language", the
* previous/next footer, …); `fumadocsTranslations()` hands exactly those to
* `RootProvider`, which is the only channel Fumadocs reads them through. The
* rest are this app's own controls and notices, read by the docs page.
* previous/next footer, the accessible names of its icon buttons, …);
* `fumadocsTranslations()` hands exactly those to `RootProvider`, which is the
* only channel Fumadocs reads them through. The rest are this app's own
* controls and notices, read by the docs page and the 404 page.
*
* English is the source, written here and nowhere else (AGENTS.md rule 1). The
* Fumadocs values are its built-in defaults verbatim, so the English site
* renders byte-identically to before this table existed.
*
* Nine of the Fumadocs keys — `searchOpen` through `navMain` — do not exist in
* fumadocs-ui 16.8.12 as published: there the names are English literals in
* the components, outside the Translations API, so every locale page announced
* "Open Search" and "Copy Anchor Link" inside `<html lang="de">`.
* `patches/fumadocs-ui@16.8.12.patch` adds them: eight are a backport of
* upstream 16.9.0's own fix (the same key names, the same English defaults),
* and `navMain` replaces the `aria-label="Main"` Radix's navigation menu puts
* on the legal pages' header, which upstream has not localized. They arrive
* here through the one channel the other ten already use. The patch header
* says why a patch rather than slot overrides, and when it can go.
*
* The locale tables are UI copy in app code — the shape `app/not-found.tsx`
* and `app/[lang]/privacy/page.tsx` already use — not `content/docs/`
* translations, which the separate pass in `docs/TRANSLATION.md` produces. They
Expand All @@ -42,6 +54,17 @@ const en = {
previousPage: 'Previous Page',
chooseTheme: 'Theme',
editOnGithub: 'Edit on GitHub',
/** The icon buttons' accessible names: no visible text, only `aria-label`. */
searchOpen: 'Open Search',
themeToggle: 'Toggle Theme',
sidebarOpen: 'Open Sidebar',
sidebarCollapse: 'Collapse Sidebar',
headingCopyAnchor: 'Copy Anchor Link',
codeBlockCopy: 'Copy Text',
codeBlockCopied: 'Copied Text',
/** The legal pages' header (`HomeLayout`): its menu button on a narrow screen, and its own name. */
menuToggle: 'Toggle Menu',
navMain: 'Main',

copyMarkdown: 'Copy Markdown',
openMenu: 'Open',
Expand All @@ -59,6 +82,13 @@ const en = {
legalNav: 'Legal',
privacy: 'Privacy',
terms: 'Terms',
/**
* The 404 page's message. `app/not-found.tsx` sits above the locale segment
* and applies it in the browser from the URL's first segment (that file says
* why); it lives here so that its Traditional Chinese string is generated from
* the Simplified one like every other string in this table.
*/
notFound: 'This page could not be found.',
} satisfies Translations & Record<string, string>;

export type UiText = Record<keyof typeof en, string>;
Expand Down Expand Up @@ -123,7 +153,7 @@ export function uiText(lang: string): UiText {
*
* Picked rather than spread: the provider merges whatever it is given into the
* context every Fumadocs component reads, and this app's own keys have no
* business there. It also keeps the serialized client prop to ten strings.
* business there. It also keeps the serialized client prop to nineteen strings.
*/
export function fumadocsTranslations(text: UiText): FumadocsTranslations {
return {
Expand All @@ -137,5 +167,14 @@ export function fumadocsTranslations(text: UiText): FumadocsTranslations {
previousPage: text.previousPage,
chooseTheme: text.chooseTheme,
editOnGithub: text.editOnGithub,
searchOpen: text.searchOpen,
themeToggle: text.themeToggle,
sidebarOpen: text.sidebarOpen,
sidebarCollapse: text.sidebarCollapse,
headingCopyAnchor: text.headingCopyAnchor,
codeBlockCopy: text.codeBlockCopy,
codeBlockCopied: text.codeBlockCopied,
menuToggle: text.menuToggle,
navMain: text.navMain,
};
}
Loading
Loading