@umami/shiso 1.16.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 (51) hide show
  1. package/bin/shiso.mjs +0 -0
  2. package/dist/chunks/App.js +310 -205
  3. package/dist/chunks/architectureDiagram-5GKGNRK7.js +1 -1
  4. package/dist/chunks/chunk-GMAD6QVW.js +1 -1
  5. package/dist/chunks/cose-bilkent-JH36ORCC.js +1 -1
  6. package/dist/chunks/dist.js +1 -1
  7. package/dist/chunks/docs.js +848 -202
  8. package/dist/chunks/ganttDiagram-EL5Y4UJY.js +1 -1
  9. package/dist/chunks/src.js +1 -1
  10. package/dist/entry-client.js +1 -1
  11. package/dist/entry-server.js +2 -2
  12. package/docs.schema.json +1135 -1003
  13. package/package.json +1 -1
  14. package/scripts/check-content.mjs +2 -0
  15. package/scripts/load-shiso-config.mjs +30 -1
  16. package/src/App.tsx +41 -23
  17. package/src/components/CodeBlock.tsx +3 -1
  18. package/src/components/DocContent.tsx +15 -6
  19. package/src/components/Docs.tsx +3 -1
  20. package/src/components/Footer.tsx +3 -1
  21. package/src/components/Header.tsx +4 -3
  22. package/src/components/LanguageSwitcher.tsx +27 -7
  23. package/src/components/OpenApiOperation.tsx +22 -15
  24. package/src/components/PageActions.tsx +135 -0
  25. package/src/components/docs/Changelog.tsx +27 -13
  26. package/src/components/docs/CodeGroup.tsx +3 -1
  27. package/src/components/docs/Mermaid.tsx +8 -6
  28. package/src/components/docs/PropertiesTable.tsx +11 -6
  29. package/src/components/docs/Tabs.tsx +3 -1
  30. package/src/components/docs/Tree.tsx +3 -1
  31. package/src/components/docs/ZoomableImage.tsx +4 -2
  32. package/src/components/ui/dialog.tsx +7 -2
  33. package/src/components/ui/sheet.tsx +5 -2
  34. package/src/lib/label-context.tsx +7 -0
  35. package/src/lib/labels.ts +37 -0
  36. package/src/lib/locale.ts +25 -0
  37. package/src/lib/openapi.ts +21 -8
  38. package/src/lib/site-config.ts +66 -2
  39. package/src/lib/site-model.ts +4 -32
  40. package/src/lib/standalone-pages.ts +15 -1
  41. package/src/lib/translations/de.json +74 -0
  42. package/src/lib/translations/en.json +74 -0
  43. package/src/lib/translations/es.json +74 -0
  44. package/src/lib/translations/fr.json +74 -0
  45. package/src/lib/translations/ja.json +74 -0
  46. package/src/lib/translations/zh-Hans.json +74 -0
  47. package/src/lib/translations/zh-Hant.json +74 -0
  48. package/src/lib/types.ts +44 -32
  49. package/src/styles/global.css +1 -1
  50. package/types/config.d.ts +7 -1
  51. package/types/labels.d.ts +76 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umami/shiso",
3
- "version": "1.16.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,43 +3,61 @@ 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
 
21
+ // Runtime helpers are public exports, but cannot be rendered as MDX components.
22
+ const {
23
+ mermaidSource: _mermaidSource,
24
+ usePanelContent: _usePanelContent,
25
+ useSetPanelContent: _useSetPanelContent,
26
+ ...mdxDocsComponents
27
+ } = docsComponents;
28
+
15
29
  const mdxComponents = {
16
- ...docsComponents,
30
+ ...mdxDocsComponents,
17
31
  img: docsComponents.ZoomableImage,
18
32
  pre: CodeBlock,
19
33
  };
20
34
 
21
35
  export function App() {
36
+ const { pathname } = useLocation();
37
+ const siteModel = getSiteModelByPathname(pathname);
22
38
  return (
23
- <TooltipProvider>
24
- <MDXProvider components={mdxComponents}>
25
- <Layout site={siteModel}>
26
- <Routes>
27
- {standalonePages.map(page => (
28
- <Route
29
- key={page.path}
30
- path={page.path}
31
- element={<StandalonePageView page={page} site={siteModel} />}
32
- />
33
- ))}
34
- {/* 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
35
52
  standalone page owns "/", there is nothing to redirect. */}
36
- {docsHomeUrl !== '/' && !hasRootStandalonePage ? (
37
- <Route path="/" element={<Navigate to={docsHomeUrl} replace />} />
38
- ) : null}
39
- <Route path="*" element={<DocPage site={siteModel} />} />
40
- </Routes>
41
- </Layout>
42
- </MDXProvider>
43
- </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>
44
62
  );
45
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>
@@ -3,6 +3,7 @@ import { ContextualMenu } from '@/components/ContextualMenu';
3
3
  import { Badge } from '@/components/docs/Badge';
4
4
  import { ArrowLeft, ArrowRight, FileText } from '@/components/icons';
5
5
  import { OpenApiOperation } from '@/components/OpenApiOperation';
6
+ import { PageActions } from '@/components/PageActions';
6
7
  import { getLastModified } from '@/lib/content';
7
8
  import { getScopeForPage } from '@/lib/docs-config';
8
9
  import { resolveLocale } from '@/lib/locale';
@@ -135,12 +136,20 @@ export function DocContent({ page, doc, site }: DocContentProps) {
135
136
  <Content />
136
137
  </div>
137
138
  {operation && <OpenApiOperation operation={operation} />}
138
- {lastModified && (
139
- <div className="mt-8 text-sm text-muted-foreground">
140
- {site.labels.lastUpdated}{' '}
141
- <time dateTime={lastModified}>{dateFormat.format(new Date(lastModified))}</time>
142
- </div>
143
- )}
139
+ <PageActions
140
+ key={page.url}
141
+ page={page}
142
+ frontmatter={doc.frontmatter}
143
+ site={site}
144
+ lastUpdated={
145
+ lastModified ? (
146
+ <>
147
+ {site.labels.lastUpdated}{' '}
148
+ <time dateTime={lastModified}>{dateFormat.format(new Date(lastModified))}</time>
149
+ </>
150
+ ) : undefined
151
+ }
152
+ />
144
153
  {related.length > 0 && (
145
154
  <nav className="mt-8" aria-label={site.labels.relatedTopics} data-pagefind-ignore>
146
155
  <div className="text-sm text-muted-foreground">{site.labels.relatedTopics}</div>
@@ -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]';
@@ -8,19 +8,29 @@ import {
8
8
  DropdownMenuTrigger,
9
9
  } from '@/components/ui/dropdown-menu';
10
10
  import { getLanguageScopes } from '@/lib/docs-config';
11
- import { docsSite, getScopeByPathname } from '@/lib/site-config';
11
+ import { getLanguageName, isValidLocale } from '@/lib/locale';
12
+ import {
13
+ docsSite,
14
+ getScopeByPathname,
15
+ getStandaloneCounterpart,
16
+ getStandalonePage,
17
+ } from '@/lib/site-config';
12
18
 
13
19
  /**
14
20
  * Language selector for multi-language sites. Each option is a language's
15
- * landing scope — its default version — so switching languages always lands
16
- * on that language's default-version first page. Hidden languages never
17
- * appear as options.
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
25
+ * appear as options. Each language is shown by its native name, e.g. "ja"
26
+ * as "日本語".
18
27
  */
19
28
  export function LanguageSwitcher() {
20
29
  const { pathname } = useLocation();
21
30
  const navigate = useNavigate();
22
31
  const current = getScopeByPathname(pathname);
23
32
  const options = getLanguageScopes(docsSite);
33
+ const standalone = getStandalonePage(pathname);
24
34
 
25
35
  if (!current.language || (options.length < 2 && !current.hidden)) {
26
36
  return null;
@@ -36,7 +46,7 @@ export function LanguageSwitcher() {
36
46
  />
37
47
  }
38
48
  >
39
- {current.language}
49
+ {getLanguageName(current.language)}
40
50
  <ChevronRight className="size-3.5 rotate-90 text-muted-foreground" />
41
51
  </DropdownMenuTrigger>
42
52
  <DropdownMenuContent align="start" className="min-w-32">
@@ -45,11 +55,21 @@ export function LanguageSwitcher() {
45
55
  key={scope.id}
46
56
  onClick={() => {
47
57
  if (scope.language !== current.language) {
48
- navigate(scope.firstPageUrl);
58
+ const counterpart =
59
+ standalone && scope.language
60
+ ? getStandaloneCounterpart(standalone, scope.language)
61
+ : null;
62
+
63
+ navigate(counterpart?.path || scope.firstPageUrl);
49
64
  }
50
65
  }}
51
66
  >
52
- <span className="grow">{scope.language}</span>
67
+ <span
68
+ className="grow"
69
+ lang={isValidLocale(scope.language) ? scope.language : undefined}
70
+ >
71
+ {scope.language ? getLanguageName(scope.language) : null}
72
+ </span>
53
73
  {scope.language === current.language ? <Check className="size-3.5" /> : null}
54
74
  </DropdownMenuItem>
55
75
  ))}
@@ -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
@@ -0,0 +1,135 @@
1
+ import { type ReactNode, useEffect, useRef, useState } from 'react';
2
+ import { ExternalLink } from '@/components/icons';
3
+ import { toHref } from '@/lib/paths';
4
+ import type { DocFrontmatter, NormalizedDocsPage, SiteModel } from '@/lib/types';
5
+
6
+ function editHref(template: string | undefined, filePath: string): string | undefined {
7
+ if (!template) return undefined;
8
+ const file = filePath.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
9
+ const href = template.replaceAll('$file', file);
10
+ try {
11
+ const url = new URL(href);
12
+ if (url.protocol === 'https:' || url.protocol === 'http:') return url.href;
13
+ } catch {
14
+ // Invalid frontmatter URLs should never become executable links.
15
+ }
16
+ return undefined;
17
+ }
18
+
19
+ export function PageActions({
20
+ page,
21
+ frontmatter,
22
+ site,
23
+ lastUpdated,
24
+ }: {
25
+ page: NormalizedDocsPage;
26
+ frontmatter?: DocFrontmatter;
27
+ site: SiteModel;
28
+ lastUpdated?: ReactNode;
29
+ }) {
30
+ const [status, setStatus] = useState<'idle' | 'pending' | 'success' | 'error'>('idle');
31
+ const [rating, setRating] = useState<boolean>();
32
+ const request = useRef<AbortController | null>(null);
33
+ useEffect(() => () => request.current?.abort(), []);
34
+
35
+ const feedback = frontmatter?.feedback === false ? null : site.feedback;
36
+ const href =
37
+ frontmatter?.editLink === false
38
+ ? undefined
39
+ : editHref(
40
+ typeof frontmatter?.editLink === 'string' ? frontmatter.editLink : site.editLink?.url,
41
+ page.filePath,
42
+ );
43
+
44
+ async function submit(helpful: boolean) {
45
+ if (!feedback || request.current || status === 'success') return;
46
+ const controller = new AbortController();
47
+ request.current = controller;
48
+ setRating(helpful);
49
+ setStatus('pending');
50
+ const timeout = window.setTimeout(() => controller.abort(), 15000);
51
+ try {
52
+ const endpoint = new URL(toHref(feedback.endpoint), window.location.origin);
53
+ if (!['https:', 'http:'].includes(endpoint.protocol)) throw new Error('Invalid endpoint');
54
+ const response = await fetch(endpoint.href, {
55
+ method: 'POST',
56
+ headers: { 'Content-Type': 'application/json' },
57
+ body: JSON.stringify({
58
+ helpful,
59
+ path: toHref(page.url),
60
+ title: frontmatter?.title || page.label,
61
+ language: page.language,
62
+ version: page.version,
63
+ }),
64
+ signal: controller.signal,
65
+ });
66
+ if (!response.ok) throw new Error('Feedback submission failed');
67
+ setStatus('success');
68
+ } catch {
69
+ setStatus('error');
70
+ } finally {
71
+ window.clearTimeout(timeout);
72
+ request.current = null;
73
+ }
74
+ }
75
+
76
+ if (!href && !feedback && !lastUpdated) return null;
77
+
78
+ return (
79
+ <>
80
+ {(lastUpdated || href) && (
81
+ <div
82
+ className="mt-8 flex items-baseline gap-4 text-sm text-muted-foreground"
83
+ data-pagefind-ignore
84
+ >
85
+ {lastUpdated && <div className="min-w-0">{lastUpdated}</div>}
86
+ {href && (
87
+ <a
88
+ href={href}
89
+ target="_blank"
90
+ rel="noopener noreferrer"
91
+ className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-right no-underline hover:text-primary"
92
+ >
93
+ {site.editLink?.label || site.labels.editPage}
94
+ <ExternalLink size={14} aria-hidden="true" />
95
+ </a>
96
+ )}
97
+ </div>
98
+ )}
99
+ {feedback && (
100
+ <div
101
+ className="mt-8 flex flex-col gap-2 border-t border-border pt-6 text-sm"
102
+ aria-busy={status === 'pending'}
103
+ data-pagefind-ignore
104
+ >
105
+ <fieldset
106
+ aria-label={feedback.prompt || site.labels.feedbackPrompt}
107
+ className="flex flex-wrap items-center gap-2"
108
+ >
109
+ <span className="mr-2 text-muted-foreground">
110
+ {feedback.prompt || site.labels.feedbackPrompt}
111
+ </span>
112
+ {[true, false].map(helpful => (
113
+ <button
114
+ key={String(helpful)}
115
+ type="button"
116
+ aria-pressed={rating === helpful}
117
+ disabled={status === 'pending' || status === 'success'}
118
+ onClick={() => void submit(helpful)}
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
+ >
121
+ {helpful
122
+ ? feedback.helpfulLabel || site.labels.feedbackYes
123
+ : feedback.unhelpfulLabel || site.labels.feedbackNo}
124
+ </button>
125
+ ))}
126
+ </fieldset>
127
+ <div role="status" aria-live="polite" className="text-muted-foreground">
128
+ {status === 'success' && (feedback.successMessage || site.labels.feedbackSuccess)}
129
+ {status === 'error' && (feedback.errorMessage || site.labels.feedbackError)}
130
+ </div>
131
+ </div>
132
+ )}
133
+ </>
134
+ );
135
+ }