@umami/shiso 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/chunks/App.js +147 -160
  2. package/dist/chunks/architectureDiagram-5GKGNRK7.js +1 -1
  3. package/dist/chunks/chunk-GMAD6QVW.js +1 -1
  4. package/dist/chunks/cose-bilkent-JH36ORCC.js +1 -1
  5. package/dist/chunks/dist.js +1 -1
  6. package/dist/chunks/docs.js +668 -22
  7. package/dist/chunks/ganttDiagram-EL5Y4UJY.js +1 -1
  8. package/dist/chunks/src.js +1 -1
  9. package/dist/entry-client.js +1 -1
  10. package/dist/entry-server.js +1 -1
  11. package/docs.schema.json +4 -0
  12. package/package.json +1 -1
  13. package/scripts/check-content.mjs +2 -0
  14. package/scripts/load-shiso-config.mjs +30 -1
  15. package/src/App.tsx +32 -22
  16. package/src/components/CodeBlock.tsx +3 -1
  17. package/src/components/Docs.tsx +3 -1
  18. package/src/components/Footer.tsx +3 -1
  19. package/src/components/Header.tsx +4 -3
  20. package/src/components/LanguageSwitcher.tsx +17 -4
  21. package/src/components/OpenApiOperation.tsx +22 -15
  22. package/src/components/PageActions.tsx +8 -7
  23. package/src/components/docs/Changelog.tsx +5 -5
  24. package/src/components/docs/CodeGroup.tsx +3 -1
  25. package/src/components/docs/Mermaid.tsx +8 -6
  26. package/src/components/docs/PropertiesTable.tsx +11 -6
  27. package/src/components/docs/Tabs.tsx +3 -1
  28. package/src/components/docs/Tree.tsx +3 -1
  29. package/src/components/docs/ZoomableImage.tsx +4 -2
  30. package/src/components/ui/dialog.tsx +7 -2
  31. package/src/components/ui/sheet.tsx +5 -2
  32. package/src/lib/label-context.tsx +7 -0
  33. package/src/lib/labels.ts +37 -0
  34. package/src/lib/openapi.ts +21 -8
  35. package/src/lib/site-config.ts +66 -2
  36. package/src/lib/site-model.ts +2 -32
  37. package/src/lib/standalone-pages.ts +15 -1
  38. package/src/lib/translations/de.json +74 -0
  39. package/src/lib/translations/en.json +74 -0
  40. package/src/lib/translations/es.json +74 -0
  41. package/src/lib/translations/fr.json +74 -0
  42. package/src/lib/translations/ja.json +74 -0
  43. package/src/lib/translations/zh-Hans.json +74 -0
  44. package/src/lib/translations/zh-Hant.json +74 -0
  45. package/src/lib/types.ts +20 -32
  46. package/types/config.d.ts +7 -1
  47. package/types/labels.d.ts +76 -0
@@ -1,4 +1,4 @@
1
- import { Ar as __commonJSMin, jr as __toESM } from "./docs.js";
1
+ import { Rr as __commonJSMin, zr as __toESM } from "./docs.js";
2
2
  import { n as __name } from "./chunk-Y2CYZVJY.js";
3
3
  import { a as nogamma, c as Rgb, d as define_default, f as extend, g as require_dayjs_min, i as hue, m as log, p as select_default, s as Color, u as rgbConvert } from "./src.js";
4
4
  import { H as setAccDescription, K as setDiagramTitle, U as setAccTitle, a as clear, c as configureSvgSize, s as common_default, v as getAccDescription, w as getDiagramTitle, x as getConfig2, y as getAccTitle } from "./chunk-DU6HZSFF.js";
@@ -1,4 +1,4 @@
1
- import { Ar as __commonJSMin, jr as __toESM } from "./docs.js";
1
+ import { Rr as __commonJSMin, zr as __toESM } from "./docs.js";
2
2
  import { n as __name } from "./chunk-Y2CYZVJY.js";
3
3
 
4
4
  //#region ../../node_modules/.pnpm/dayjs@1.11.23/node_modules/dayjs/dayjs.min.js
@@ -1,4 +1,4 @@
1
- import { yr as BrowserRouter } from "./chunks/docs.js";
1
+ import { Dr as BrowserRouter } from "./chunks/docs.js";
2
2
  import { h as BASE_URL, t as App } from "./chunks/App.js";
3
3
  import { jsx } from "react/jsx-runtime";
4
4
 
@@ -1,4 +1,4 @@
1
- import { Cr as Router, Dr as createPath, Or as parsePath, kr as ABSOLUTE_URL_REGEX } from "./chunks/docs.js";
1
+ import { Fr as createPath, Ir as parsePath, Lr as ABSOLUTE_URL_REGEX, jr as Router } from "./chunks/docs.js";
2
2
  import { a as docsSite, c as getSeo, d as standalonePages, f as getDocModule, g as toAbsoluteUrl, h as BASE_URL, i as docsHomeUrl, l as siteConfig, m as getScopeForPage, n as buildHead, o as getLocaleByPathname, p as getLastModified, r as renderHeadToString, s as getRedirects, t as App, u as siteName } from "./chunks/App.js";
3
3
  import * as React$1 from "react";
4
4
  import { jsx } from "react/jsx-runtime";
package/docs.schema.json CHANGED
@@ -232,6 +232,10 @@
232
232
  "title": {
233
233
  "allOf": [{ "$ref": "#/definitions/stringValue" }],
234
234
  "description": "Page title used in the document head. Frontmatter title wins."
235
+ },
236
+ "language": {
237
+ "allOf": [{ "$ref": "#/definitions/nonEmptyStringValue" }],
238
+ "description": "Navigation language this page belongs to, e.g. \"ja\". Must match a language in navigation.languages. The language selector links pages whose file slugs match once the language folder is removed, e.g. \"ja/home\" and \"home\"."
235
239
  }
236
240
  }
237
241
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umami/shiso",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "Open-source documentation framework for Markdown and MDX sites.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -87,6 +87,8 @@ function collectPageReferences(navigation) {
87
87
 
88
88
  function visitContainer(container) {
89
89
  if (!container || typeof container !== 'object') return;
90
+ // Groups reached through tabs/dropdowns/languages carry their own landing page.
91
+ if (typeof container.root === 'string') references.push(container.root);
90
92
  if (Array.isArray(container.pages)) visitItems(container.pages);
91
93
  for (const key of ['tabs', 'dropdowns', 'groups', 'versions', 'languages']) {
92
94
  if (Array.isArray(container[key])) container[key].forEach(visitContainer);
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { createJiti } from 'jiti';
5
+ import englishLabels from '../src/lib/translations/en.json' with { type: 'json' };
5
6
 
6
7
  /**
7
8
  * Loads the optional project code config (shiso.config.ts/.mjs/.js).
@@ -16,7 +17,7 @@ import { createJiti } from 'jiti';
16
17
  export const SHISO_CONFIG_FILES = ['shiso.config.ts', 'shiso.config.mjs', 'shiso.config.js'];
17
18
 
18
19
  const STRING_KEYS = ['docsPrefix', 'contentDir', 'siteUrl', 'locale'];
19
- const KNOWN_KEYS = [...STRING_KEYS, 'mdx'];
20
+ const KNOWN_KEYS = [...STRING_KEYS, 'mdx', 'translations'];
20
21
  const MDX_KEYS = ['remarkPlugins', 'rehypePlugins'];
21
22
 
22
23
  let importGeneration = 0;
@@ -88,6 +89,31 @@ function resolveMdxConfig(value, sourcePath) {
88
89
  };
89
90
  }
90
91
 
92
+ function resolveTranslations(value, sourcePath) {
93
+ if (value === undefined) return undefined;
94
+ const invalid = message => {
95
+ throw new ShisoConfigLoadError(message, { code: 'INVALID_OPTION', sourcePath });
96
+ };
97
+ if (!isPlainObject(value)) invalid('Shiso config option "translations" must be an object.');
98
+ const result = {};
99
+ for (const [locale, labels] of Object.entries(value)) {
100
+ let canonical;
101
+ try {
102
+ canonical = Intl.getCanonicalLocales(locale)[0];
103
+ } catch {
104
+ invalid(`Invalid translations locale "${locale}". Use a BCP 47 tag such as "fr" or "pt-BR".`);
105
+ }
106
+ if (!isPlainObject(labels)) invalid(`translations.${locale} must be an object.`);
107
+ for (const [key, label] of Object.entries(labels)) {
108
+ if (!Object.hasOwn(englishLabels, key))
109
+ invalid(`Unknown UI label "translations.${locale}.${key}".`);
110
+ if (typeof label !== 'string') invalid(`translations.${locale}.${key} must be a string.`);
111
+ }
112
+ result[canonical] = { ...result[canonical], ...labels };
113
+ }
114
+ return result;
115
+ }
116
+
91
117
  /**
92
118
  * Applies defaults and normalization. Single source of truth for resolved
93
119
  * values, so runtime and build-time consumers never re-implement defaulting.
@@ -103,6 +129,9 @@ export function resolveShisoConfig(raw = {}, sourcePath = null) {
103
129
  siteUrl: raw.siteUrl?.trim().replace(/\/+$/, '') || undefined,
104
130
  locale: raw.locale?.trim() || 'en-US',
105
131
  mdx: resolveMdxConfig(raw.mdx, sourcePath),
132
+ ...(raw.translations === undefined
133
+ ? {}
134
+ : { translations: resolveTranslations(raw.translations, sourcePath) }),
106
135
  };
107
136
  }
108
137
 
package/src/App.tsx CHANGED
@@ -3,12 +3,18 @@ import '@fontsource/jetbrains-mono/400.css';
3
3
  import '@umami/shiso/styles.css';
4
4
 
5
5
  import { MDXProvider } from '@mdx-js/react';
6
- import { Navigate, Route, Routes } from 'react-router';
6
+ import { Navigate, Route, Routes, useLocation } from 'react-router';
7
7
  import { CodeBlock } from '@/components/CodeBlock';
8
8
  import * as docsComponents from '@/components/docs/index';
9
9
  import { Layout } from '@/components/Layout';
10
10
  import { TooltipProvider } from '@/components/ui/tooltip';
11
- import { docsHomeUrl, hasRootStandalonePage, siteModel, standalonePages } from '@/lib/site-config';
11
+ import { LabelContext } from '@/lib/label-context';
12
+ import {
13
+ docsHomeUrl,
14
+ getSiteModelByPathname,
15
+ hasRootStandalonePage,
16
+ standalonePages,
17
+ } from '@/lib/site-config';
12
18
  import { DocPage } from '@/pages/DocPage';
13
19
  import { StandalonePageView } from '@/pages/StandalonePage';
14
20
 
@@ -27,27 +33,31 @@ const mdxComponents = {
27
33
  };
28
34
 
29
35
  export function App() {
36
+ const { pathname } = useLocation();
37
+ const siteModel = getSiteModelByPathname(pathname);
30
38
  return (
31
- <TooltipProvider>
32
- <MDXProvider components={mdxComponents}>
33
- <Layout site={siteModel}>
34
- <Routes>
35
- {standalonePages.map(page => (
36
- <Route
37
- key={page.path}
38
- path={page.path}
39
- element={<StandalonePageView page={page} site={siteModel} />}
40
- />
41
- ))}
42
- {/* When the default scope's landing page is the root, or a
39
+ <LabelContext.Provider value={siteModel.labels}>
40
+ <TooltipProvider>
41
+ <MDXProvider components={mdxComponents}>
42
+ <Layout site={siteModel}>
43
+ <Routes>
44
+ {standalonePages.map(page => (
45
+ <Route
46
+ key={page.path}
47
+ path={page.path}
48
+ element={<StandalonePageView page={page} site={siteModel} />}
49
+ />
50
+ ))}
51
+ {/* When the default scope's landing page is the root, or a
43
52
  standalone page owns "/", there is nothing to redirect. */}
44
- {docsHomeUrl !== '/' && !hasRootStandalonePage ? (
45
- <Route path="/" element={<Navigate to={docsHomeUrl} replace />} />
46
- ) : null}
47
- <Route path="*" element={<DocPage site={siteModel} />} />
48
- </Routes>
49
- </Layout>
50
- </MDXProvider>
51
- </TooltipProvider>
53
+ {docsHomeUrl !== '/' && !hasRootStandalonePage ? (
54
+ <Route path="/" element={<Navigate to={docsHomeUrl} replace />} />
55
+ ) : null}
56
+ <Route path="*" element={<DocPage site={siteModel} />} />
57
+ </Routes>
58
+ </Layout>
59
+ </MDXProvider>
60
+ </TooltipProvider>
61
+ </LabelContext.Provider>
52
62
  );
53
63
  }
@@ -3,6 +3,7 @@ import { type ComponentProps, type CSSProperties, useRef, useState } from 'react
3
3
  import { CheckIcon, Copy } from '@/components/icons';
4
4
  import { Button } from '@/components/ui/button';
5
5
  import { ScrollArea } from '@/components/ui/scroll-area';
6
+ import { useLabels } from '@/lib/label-context';
6
7
  import { cn } from '@/lib/utils';
7
8
  import { Mermaid, type MermaidPlacement } from './docs/Mermaid';
8
9
 
@@ -60,6 +61,7 @@ function copyText(pre: HTMLPreElement | null, language?: string): string {
60
61
  }
61
62
 
62
63
  export function CodeBlock({ children, className, style, ...rest }: CodeBlockProps) {
64
+ const labels = useLabels();
63
65
  const {
64
66
  'data-title': title,
65
67
  'data-language': language,
@@ -137,7 +139,7 @@ export function CodeBlock({ children, className, style, ...rest }: CodeBlockProp
137
139
  title ? 'top-1' : 'top-2.5',
138
140
  )}
139
141
  onClick={handleCopy}
140
- aria-label="Copy code"
142
+ aria-label={copied ? labels.copied : labels.copyCode}
141
143
  >
142
144
  {copied ? <CheckIcon className="size-3.5 text-primary" /> : <Copy className="size-3.5" />}
143
145
  </Button>
@@ -60,7 +60,9 @@ export function Docs({ page, doc, site }: DocsProps) {
60
60
 
61
61
  // API reference pages append their generated section anchors to the TOC.
62
62
  const operation = getOperation(doc.frontmatter?.openapi);
63
- const toc = operation ? [...(doc.toc || []), ...operationSections(operation)] : doc.toc;
63
+ const toc = operation
64
+ ? [...(doc.toc || []), ...operationSections(operation, site.labels)]
65
+ : doc.toc;
64
66
 
65
67
  return (
66
68
  <PanelProvider>
@@ -1,4 +1,5 @@
1
1
  import { ConfiguredIcon } from '@/components/ConfiguredIcon';
2
+ import { useLabels } from '@/lib/label-context';
2
3
  import type { NormalizedFooter } from '@/lib/types';
3
4
 
4
5
  export function Footer({
@@ -8,6 +9,7 @@ export function Footer({
8
9
  footer: NormalizedFooter | null;
9
10
  className?: string;
10
11
  }) {
12
+ const labels = useLabels();
11
13
  if (!footer) {
12
14
  return null;
13
15
  }
@@ -48,7 +50,7 @@ export function Footer({
48
50
  href="https://shiso.umami.is?ref=docs-footer"
49
51
  className="text-sm hover:text-foreground"
50
52
  >
51
- Powered by <span className="font-bold">shiso</span>
53
+ {labels.poweredBy} <span className="font-bold">shiso</span>
52
54
  </a>
53
55
  ) : (
54
56
  <span />
@@ -6,7 +6,7 @@ import { ThemeToggle } from '@/components/ThemeToggle';
6
6
  import { TopNav } from '@/components/TopNav';
7
7
  import { VersionSwitcher } from '@/components/VersionSwitcher';
8
8
  import { isExternalHref } from '@/lib/paths';
9
- import { docsHomeUrl, getScopeByPathname, hasRootStandalonePage } from '@/lib/site-config';
9
+ import { getHomeHref, getScopeByPathname } from '@/lib/site-config';
10
10
  import type { NormalizedLink, SiteModel } from '@/lib/types';
11
11
 
12
12
  /**
@@ -62,8 +62,9 @@ export function Header({ site }: { site: SiteModel }) {
62
62
  const { pathname } = useLocation();
63
63
  // The header renders the navigation of whichever scope owns the current page.
64
64
  const docs = getScopeByPathname(pathname).docs;
65
- // The brand links to the standalone home page when one owns "/".
66
- const brandHref = logo?.href || (hasRootStandalonePage ? '/' : docsHomeUrl);
65
+ // The brand links to the standalone home page in the current language when
66
+ // one owns "/", else the docs home.
67
+ const brandHref = logo?.href || getHomeHref(pathname);
67
68
  const hasBrand = !!name || !!logo?.light || !!logo?.dark;
68
69
  const brandClassName =
69
70
  'inline-flex items-center gap-2 text-xl font-bold text-foreground tracking-[-0.03em]';
@@ -9,12 +9,19 @@ import {
9
9
  } from '@/components/ui/dropdown-menu';
10
10
  import { getLanguageScopes } from '@/lib/docs-config';
11
11
  import { getLanguageName, isValidLocale } from '@/lib/locale';
12
- import { docsSite, getScopeByPathname } from '@/lib/site-config';
12
+ import {
13
+ docsSite,
14
+ getScopeByPathname,
15
+ getStandaloneCounterpart,
16
+ getStandalonePage,
17
+ } from '@/lib/site-config';
13
18
 
14
19
  /**
15
20
  * Language selector for multi-language sites. Each option is a language's
16
- * landing scope — its default version — so switching languages always lands
17
- * on that language's default-version first page. Hidden languages never
21
+ * landing scope — its default version — so switching languages lands on that
22
+ * language's default-version first page. On a standalone page (e.g. a home
23
+ * page) it lands on the same page in the chosen language when one exists.
24
+ * Hidden languages never
18
25
  * appear as options. Each language is shown by its native name, e.g. "ja"
19
26
  * as "日本語".
20
27
  */
@@ -23,6 +30,7 @@ export function LanguageSwitcher() {
23
30
  const navigate = useNavigate();
24
31
  const current = getScopeByPathname(pathname);
25
32
  const options = getLanguageScopes(docsSite);
33
+ const standalone = getStandalonePage(pathname);
26
34
 
27
35
  if (!current.language || (options.length < 2 && !current.hidden)) {
28
36
  return null;
@@ -47,7 +55,12 @@ export function LanguageSwitcher() {
47
55
  key={scope.id}
48
56
  onClick={() => {
49
57
  if (scope.language !== current.language) {
50
- navigate(scope.firstPageUrl);
58
+ const counterpart =
59
+ standalone && scope.language
60
+ ? getStandaloneCounterpart(standalone, scope.language)
61
+ : null;
62
+
63
+ navigate(counterpart?.path || scope.firstPageUrl);
51
64
  }
52
65
  }}
53
66
  >
@@ -3,16 +3,18 @@ import { Badge } from '@/components/docs/Badge';
3
3
  import { CodeGroup } from '@/components/docs/CodeGroup';
4
4
  import { Expandable } from '@/components/docs/Expandable';
5
5
  import { PropertiesTable } from '@/components/docs/PropertiesTable';
6
+ import { useLabels } from '@/lib/label-context';
6
7
  import { operationParameterSections, operationSections, statusColor } from '@/lib/openapi';
7
- import type { NormalizedOperation, SchemaNode } from '@/lib/types';
8
+ import type { NormalizedOperation, SchemaNode, ThemeLabels } from '@/lib/types';
8
9
 
9
10
  function FieldChildren({ node }: { node: SchemaNode }) {
11
+ const labels = useLabels();
10
12
  return (
11
13
  <>
12
14
  {node.description}
13
15
  {node.enum && node.enum.length > 0 && (
14
16
  <div className="mt-1">
15
- Options:{' '}
17
+ {labels.fieldOptions}{' '}
16
18
  {node.enum.map((value, index) => (
17
19
  <span key={value}>
18
20
  {index > 0 && ', '}
@@ -25,12 +27,14 @@ function FieldChildren({ node }: { node: SchemaNode }) {
25
27
  );
26
28
  }
27
29
 
28
- function schemaDetails(node: SchemaNode) {
30
+ function schemaDetails(node: SchemaNode, labels: ThemeLabels) {
29
31
  if (!node.children?.length) return undefined;
30
32
 
31
33
  return (
32
34
  <Expandable
33
- title={node.type === 'oneOf' || node.type === 'anyOf' ? 'Allowed types' : 'properties'}
35
+ title={
36
+ node.type === 'oneOf' || node.type === 'anyOf' ? labels.allowedTypes : labels.properties
37
+ }
34
38
  >
35
39
  <SchemaTable nodes={node.children} />
36
40
  </Expandable>
@@ -38,6 +42,7 @@ function schemaDetails(node: SchemaNode) {
38
42
  }
39
43
 
40
44
  function SchemaTable({ nodes }: { nodes: SchemaNode[] }) {
45
+ const labels = useLabels();
41
46
  return (
42
47
  <PropertiesTable>
43
48
  {nodes.map(node => (
@@ -48,7 +53,7 @@ function SchemaTable({ nodes }: { nodes: SchemaNode[] }) {
48
53
  required={node.required}
49
54
  deprecated={node.deprecated}
50
55
  default={node.default}
51
- details={schemaDetails(node)}
56
+ details={schemaDetails(node, labels)}
52
57
  >
53
58
  <FieldChildren node={node} />
54
59
  </PropertiesTable.Row>
@@ -105,17 +110,19 @@ export interface OpenApiOperationProps {
105
110
 
106
111
  /** The generated reference for one API operation, rendered under the page body. */
107
112
  export function OpenApiOperation({ operation }: OpenApiOperationProps) {
108
- const sections = new Map(operationSections(operation).map(entry => [entry.name, entry.id]));
113
+ const labels = useLabels();
114
+ const sectionEntries = operationSections(operation);
115
+ const sections = new Map(sectionEntries.map(entry => [entry.name, entry.id]));
109
116
 
110
117
  return (
111
118
  <div className="docs-markdown">
112
- {operationParameterSections(operation).map(({ location, name }) => (
119
+ {operationParameterSections(operation, labels).map(({ location, name }, index) => (
113
120
  <section key={location}>
114
- <h2 id={sections.get(name)}>{name}</h2>
121
+ <h2 id={sectionEntries[index].id}>{name}</h2>
115
122
  <PropertiesTable>
116
123
  {location === 'header' && operation.security.length > 0 && (
117
124
  <PropertiesTable.Row name="Authorization" type="string" required>
118
- Authentication credentials, e.g. <code>Bearer &lt;token&gt;</code>.
125
+ {labels.apiCredentials} <code>Bearer &lt;token&gt;</code>.
119
126
  </PropertiesTable.Row>
120
127
  )}
121
128
  {operation.parameters[location].map(parameter => (
@@ -126,7 +133,7 @@ export function OpenApiOperation({ operation }: OpenApiOperationProps) {
126
133
  required={parameter.required}
127
134
  deprecated={parameter.deprecated}
128
135
  default={parameter.default}
129
- details={schemaDetails(parameter)}
136
+ details={schemaDetails(parameter, labels)}
130
137
  >
131
138
  <FieldChildren node={parameter} />
132
139
  </PropertiesTable.Row>
@@ -136,12 +143,12 @@ export function OpenApiOperation({ operation }: OpenApiOperationProps) {
136
143
  ))}
137
144
  {operation.requestBody && (
138
145
  <section>
139
- <h2 id={sections.get('Request body')}>Request body</h2>
146
+ <h2 id={sections.get('Request body')}>{labels.apiRequestBody}</h2>
140
147
  <SchemaFields node={operation.requestBody.schema} />
141
148
  {operation.requestBody.example && (
142
149
  <HighlightedCode
143
150
  language="json"
144
- title="Example request"
151
+ title={labels.apiExampleRequest}
145
152
  html={operation.requestBody.exampleHtml}
146
153
  source={operation.requestBody.example}
147
154
  />
@@ -150,7 +157,7 @@ export function OpenApiOperation({ operation }: OpenApiOperationProps) {
150
157
  )}
151
158
  {operation.responses.length > 0 && (
152
159
  <section>
153
- <h2 id={sections.get('Responses')}>Responses</h2>
160
+ <h2 id={sections.get('Responses')}>{labels.apiResponses}</h2>
154
161
  {operation.responses.map(response => (
155
162
  <div key={response.status} className="mt-6 first:mt-0">
156
163
  <div className="flex items-center gap-2">
@@ -165,7 +172,7 @@ export function OpenApiOperation({ operation }: OpenApiOperationProps) {
165
172
  {response.example && (
166
173
  <HighlightedCode
167
174
  language="json"
168
- title={`${response.status} example`}
175
+ title={labels.apiExampleResponse.replace('{status}', () => response.status)}
169
176
  html={response.exampleHtml}
170
177
  source={response.example}
171
178
  />
@@ -176,7 +183,7 @@ export function OpenApiOperation({ operation }: OpenApiOperationProps) {
176
183
  )}
177
184
  {operation.samples.length > 0 && (
178
185
  <section>
179
- <h2 id={sections.get('Code samples')}>Code samples</h2>
186
+ <h2 id={sections.get('Code samples')}>{labels.apiCodeSamples}</h2>
180
187
  <CodeGroup>
181
188
  {operation.samples.map(sample => (
182
189
  <HighlightedCode
@@ -90,7 +90,7 @@ export function PageActions({
90
90
  rel="noopener noreferrer"
91
91
  className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-right no-underline hover:text-primary"
92
92
  >
93
- {site.editLink?.label || 'Edit this page'}
93
+ {site.editLink?.label || site.labels.editPage}
94
94
  <ExternalLink size={14} aria-hidden="true" />
95
95
  </a>
96
96
  )}
@@ -103,11 +103,11 @@ export function PageActions({
103
103
  data-pagefind-ignore
104
104
  >
105
105
  <fieldset
106
- aria-label={feedback.prompt || 'Was this page helpful?'}
106
+ aria-label={feedback.prompt || site.labels.feedbackPrompt}
107
107
  className="flex flex-wrap items-center gap-2"
108
108
  >
109
109
  <span className="mr-2 text-muted-foreground">
110
- {feedback.prompt || 'Was this page helpful?'}
110
+ {feedback.prompt || site.labels.feedbackPrompt}
111
111
  </span>
112
112
  {[true, false].map(helpful => (
113
113
  <button
@@ -118,14 +118,15 @@ export function PageActions({
118
118
  onClick={() => void submit(helpful)}
119
119
  className="rounded-md border border-border px-3 py-1.5 text-foreground hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-default disabled:opacity-60 aria-pressed:border-primary aria-pressed:text-primary"
120
120
  >
121
- {helpful ? feedback.helpfulLabel || 'Yes' : feedback.unhelpfulLabel || 'No'}
121
+ {helpful
122
+ ? feedback.helpfulLabel || site.labels.feedbackYes
123
+ : feedback.unhelpfulLabel || site.labels.feedbackNo}
122
124
  </button>
123
125
  ))}
124
126
  </fieldset>
125
127
  <div role="status" aria-live="polite" className="text-muted-foreground">
126
- {status === 'success' && (feedback.successMessage || 'Thanks for your feedback!')}
127
- {status === 'error' &&
128
- (feedback.errorMessage || 'Could not send feedback. Please try again.')}
128
+ {status === 'success' && (feedback.successMessage || site.labels.feedbackSuccess)}
129
+ {status === 'error' && (feedback.errorMessage || site.labels.feedbackError)}
129
130
  </div>
130
131
  </div>
131
132
  )}
@@ -7,6 +7,7 @@ import {
7
7
  useMemo,
8
8
  } from 'react';
9
9
  import { useSearchParams } from 'react-router';
10
+ import { useLabels } from '@/lib/label-context';
10
11
  import { Badge } from './Badge';
11
12
  import { styles } from './styles';
12
13
  import { Update } from './Update';
@@ -73,6 +74,7 @@ function updateInfo(children: React.ReactNode): Map<string, UpdateChildInfo> {
73
74
  * Pages without any tagged updates render as a plain list.
74
75
  */
75
76
  export function Changelog({ children }: ChangelogProps) {
77
+ const labels = useLabels();
76
78
  const [searchParams, setSearchParams] = useSearchParams();
77
79
 
78
80
  const allTags = useMemo(() => {
@@ -144,7 +146,7 @@ export function Changelog({ children }: ChangelogProps) {
144
146
  return (
145
147
  <div className={styles.changelog}>
146
148
  {/* biome-ignore lint/a11y/useSemanticElements: toggle-button group labelled for the filter */}
147
- <div className={styles.changelogFilters} role="group" aria-label="Filter updates by tag">
149
+ <div className={styles.changelogFilters} role="group" aria-label={labels.filterUpdates}>
148
150
  {allTags.map(tag => {
149
151
  const active = selectedSet.has(tag.toLowerCase());
150
152
  return (
@@ -164,13 +166,11 @@ export function Changelog({ children }: ChangelogProps) {
164
166
  })}
165
167
  {selected.length > 0 ? (
166
168
  <button type="button" onClick={clear} className={styles.changelogClear}>
167
- Clear
169
+ {labels.clear}
168
170
  </button>
169
171
  ) : null}
170
172
  </div>
171
- {visibleUpdates === 0 ? (
172
- <p className={styles.changelogEmpty}>No updates match the selected tags.</p>
173
- ) : null}
173
+ {visibleUpdates === 0 ? <p className={styles.changelogEmpty}>{labels.noUpdates}</p> : null}
174
174
  {filtered}
175
175
  </div>
176
176
  );
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import { useMemo, useState } from 'react';
3
3
  import { TabsContent, TabsList, Tabs as TabsPrimitive, TabsTrigger } from '@/components/ui/tabs';
4
+ import { useLabels } from '@/lib/label-context';
4
5
  import { styles } from './styles';
5
6
  import { findCodeLanguage, findCodeTitle, slugify, toElementArray } from './utils';
6
7
 
@@ -9,6 +10,7 @@ export interface CodeGroupProps {
9
10
  }
10
11
 
11
12
  export function CodeGroup({ children }: CodeGroupProps) {
13
+ const labels = useLabels();
12
14
  const blocks = useMemo(() => {
13
15
  const counts = new Map<string, number>();
14
16
 
@@ -55,7 +57,7 @@ export function CodeGroup({ children }: CodeGroupProps) {
55
57
  <TabsList
56
58
  variant="line"
57
59
  className="h-9 w-full justify-start gap-5 overflow-x-auto overflow-y-hidden rounded-none px-3 py-0 shadow-[inset_0_-1px_0_var(--border)] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
58
- aria-label="Code snippets"
60
+ aria-label={labels.codeSnippets}
59
61
  >
60
62
  {blocks.map(block => (
61
63
  <TabsTrigger
@@ -1,4 +1,5 @@
1
1
  import { type ReactNode, useEffect, useId, useRef, useState } from 'react';
2
+ import { useLabels } from '@/lib/label-context';
2
3
  import { styles } from './styles';
3
4
 
4
5
  export type MermaidPlacement = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
@@ -63,6 +64,7 @@ export function Mermaid({
63
64
  placement = 'bottom-right',
64
65
  children,
65
66
  }: MermaidProps) {
67
+ const labels = useLabels();
66
68
  const source = mermaidSource(chart, children);
67
69
  const containerRef = useRef<HTMLDivElement>(null);
68
70
  const svgHostRef = useRef<HTMLDivElement>(null);
@@ -129,29 +131,29 @@ export function Mermaid({
129
131
  // biome-ignore lint/security/noDangerouslySetInnerHtml: mermaid generates this SVG from the page's own diagram source.
130
132
  dangerouslySetInnerHTML={{ __html: svg }}
131
133
  role="img"
132
- aria-label={title ? String(title) : 'Diagram'}
134
+ aria-label={title ? String(title) : labels.diagram}
133
135
  />
134
136
  ) : (
135
- <pre className={styles.mermaidFallback} title="Diagram source">
137
+ <pre className={styles.mermaidFallback} title={labels.diagramSource}>
136
138
  {source}
137
139
  </pre>
138
140
  )}
139
- {error ? <p className={styles.mermaidError}>Could not render this diagram.</p> : null}
141
+ {error ? <p className={styles.mermaidError}>{labels.diagramError}</p> : null}
140
142
  {showControls && svg && !error ? (
141
143
  <div className={`${styles.mermaidControls} ${PLACEMENT_CLASS[placement]}`}>
142
144
  <button
143
145
  type="button"
144
- aria-label="Zoom out"
146
+ aria-label={labels.zoomOut}
145
147
  onClick={() => setZoom(z => Math.max(0.5, +(z - 0.25).toFixed(2)))}
146
148
  >
147
149
  −
148
150
  </button>
149
- <button type="button" aria-label="Reset view" onClick={() => setZoom(1)}>
151
+ <button type="button" aria-label={labels.resetView} onClick={() => setZoom(1)}>
150
152
  ⟳
151
153
  </button>
152
154
  <button
153
155
  type="button"
154
- aria-label="Zoom in"
156
+ aria-label={labels.zoomIn}
155
157
  onClick={() => setZoom(z => Math.min(2.5, +(z + 0.25).toFixed(2)))}
156
158
  >
157
159
  +