Skip to content

Translating the chrome

Every word on a page that is not your markdown comes from this package — the skip link, the drawer's two buttons, the callout headings, the copy button, the message announced after a copy. There are twenty-two of them, and every one is yours to set.

Where each one is rendered

They are not all rendered in the same place. That is the whole reason the set lives where it does, so it is worth the table.

Rendered byStrings
The shell — docs.Layout4nav, openNav, closeNav, skipToContent
The navigation tree — a Client Component3expandGroup, collapseGroup, externalLink
The table of contents — docs.Page2toc, backToTop
The markdown component map9table, the five callout headings, the three YouTube strings
A rehype plugin, at build time2copyCode, copyCodeFrom
A client runtime, after a copy2copied, copyFailed

externalLink does double duty. It is the screen-reader suffix on an external entry in the sidebar — the ones a meta.json adds as links — and on every external link in your content. One string, both places.

Why the route rather than the layout

labels is an option on createDocsRoute, beside contentDir and the rest of the configuration. A layout prop is upstream of the shell and of the tree the shell renders, and of nothing else. The table of contents is docs.Page's. The nine content strings are closed over by a component map built when the route is created. The copy button's two are substituted into the HTML by a rehype plugin, before React is involved at all. None of those is downstream of a layout.

docs.Layout still takes a labels prop, and it overrides the route's key by key rather than replacing the object — for a site with two shells, or one section in another language. Naming one string must not cost you the other twenty-one. Layout has that prop in context.

The whole set

lib/docs-pt.ts
import { createDocsRoute } from '@waveso/docs/next';

export const docs = createDocsRoute({
  contentDir: 'content/docs',
  labels: {
    // The shell
    nav: 'Documentação',
    openNav: 'Abrir navegação',
    closeNav: 'Fechar navegação',
    skipToContent: 'Ir para o conteúdo',
    // The navigation tree — `{title}` is the group's own name
    expandGroup: 'Abrir {title}',
    collapseGroup: 'Fechar {title}',
    externalLink: '(abre num novo separador)',
    // The table of contents
    toc: 'Nesta página',
    backToTop: 'Voltar ao topo',
    // Your content
    table: 'Tabela',
    calloutNote: 'Nota',
    calloutTip: 'Dica',
    calloutImportant: 'Importante',
    calloutWarning: 'Aviso',
    calloutCaution: 'Atenção',
    youtubeTitle: 'Vídeo do YouTube',
    youtubePlay: 'Reproduzir: {title}',
    youtubeHide: 'Esconder: {title}',
    // Code frames
    copyCode: 'Copiar código',
    copyCodeFrom: 'Copiar código de {title}',
    copied: 'Copiado para a área de transferência.',
    copyFailed: 'Falhou. Selecione o código e prima Control ou Command + C.',
  },
});

The type is DocsLabels and every key on it is optional.

Per-key fallback

Each key falls back on its own. Set calloutTip and nothing else and the tip callouts say yours, while toc stays 'On this page', externalLink stays '(opens in a new tab)' and the copy button stays 'Copy code'. A partial map is a partly translated site rather than a broken one, which is what makes it safe to translate the visible strings first and the accessible names after.

Unset keys cost nothing either. The route splits the object by the runtime that renders each group, and a group nobody set is passed as nothing at all — so a site that overrides none of the twenty-two ships the payload it shipped before the option existed.

Each of the twenty-two is named in next.test.ts alongside the test that proves it reaches the output. A key that is declared, documented and never wired fails there rather than reading as configuration, which is exactly how the first four came to be four.

{title} is a placeholder, not a function

Five of the strings carry {title}: expandGroup and collapseGroup, where it is the group's own name; youtubePlay and youtubeHide, where it is the embed's accessible name; and copyCodeFrom, where it is the fence's own title="…".

It is substituted, not called. Two of the five cross from a Server Component into a Client one, where a function cannot go, and copyCodeFrom is filled in by a rehype plugin at build time, where there is no React to call one in. The second reason outlives both: a translator has to be able to move the name within the sentence. 'Copiar código de {title}' puts it last and another language puts it first, and a callback appended to a fixed prefix — which is what concatenation is — can only ever put it where the English wanted it.

The search dialog is separate

Its strings travel with the dialog's own props, reachable through search={{ … }} on docs.Layout: the trigger, the placeholder, the dialog's accessible name, five state messages and the live region's plural forms. That channel already carries pageSize and minQueryLength, and a second route to the same component would be two places to look. Search has the table.