@umami/shiso 1.11.0 → 1.12.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 (39) hide show
  1. package/bin/shiso.mjs +20 -1
  2. package/dist/chunks/App.js +360 -26
  3. package/dist/chunks/docs.js +2 -2
  4. package/dist/entry-client.js +1 -1
  5. package/dist/entry-server.js +30 -2
  6. package/docs.schema.json +90 -0
  7. package/mdx.config.ts +13 -97
  8. package/package.json +5 -4
  9. package/scripts/build-runtime.mjs +1 -0
  10. package/scripts/check-content.mjs +358 -0
  11. package/scripts/expand-navigation-globs.mjs +208 -0
  12. package/scripts/expand-openapi-navigation.mjs +94 -0
  13. package/scripts/generate-openapi.mjs +117 -0
  14. package/scripts/generate-search-index.mjs +31 -1
  15. package/scripts/lib/openapi.mjs +653 -0
  16. package/scripts/load-docs-config.mjs +52 -6
  17. package/scripts/load-shiso-config.mjs +45 -3
  18. package/scripts/prerender.mjs +83 -3
  19. package/scripts/vite-docs-config.mjs +27 -6
  20. package/src/App.tsx +0 -1
  21. package/src/components/CodeBlock.tsx +72 -8
  22. package/src/components/DocContent.tsx +18 -0
  23. package/src/components/Docs.tsx +6 -1
  24. package/src/components/OpenApiOperation.tsx +197 -0
  25. package/src/components/SideNav.tsx +8 -2
  26. package/src/components/docs/CodeGroup.tsx +6 -2
  27. package/src/entry-server.tsx +50 -0
  28. package/src/lib/code-blocks.ts +18 -0
  29. package/src/lib/code-meta.ts +87 -0
  30. package/src/lib/docs-config.ts +4 -1
  31. package/src/lib/openapi.generated.ts +4 -0
  32. package/src/lib/openapi.ts +63 -0
  33. package/src/lib/rehype-shiki.ts +196 -0
  34. package/src/lib/site-model.ts +2 -0
  35. package/src/lib/types.ts +116 -2
  36. package/src/styles/global.css +63 -73
  37. package/types/config.d.ts +11 -4
  38. package/vite.config.ts +49 -3
  39. package/CHANGELOG.md +0 -171
@@ -1,5 +1,14 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { expandNavigationGlobs, hasNavigationGlobs } from './expand-navigation-globs.mjs';
4
+ import { expandOpenApiNavigation, hasOpenApiItems } from './expand-openapi-navigation.mjs';
5
+ import {
6
+ generateOpenApiStubs,
7
+ loadOpenApiSpec,
8
+ normalizeOperations,
9
+ resolveApiDirectory,
10
+ } from './lib/openapi.mjs';
11
+ import { loadShisoConfig } from './load-shiso-config.mjs';
3
12
 
4
13
  /** Error raised while locating, reading, or parsing a Shiso configuration file. */
5
14
  export class DocsConfigLoadError extends Error {
@@ -220,16 +229,53 @@ async function resolveConfigReferences(entryPath, projectRoot) {
220
229
  * stable place from which to resolve relative references without changing
221
230
  * every build-time consumer again.
222
231
  */
223
- export async function loadDocsConfig({ root = process.cwd(), configFile = 'docs.json' } = {}) {
232
+ export async function loadDocsConfig({
233
+ root = process.cwd(),
234
+ configFile = 'docs.json',
235
+ expandGlobs = true,
236
+ } = {}) {
224
237
  const requestedRoot = path.resolve(root);
225
238
  const requestedSourcePath = path.resolve(requestedRoot, configFile);
226
- const { config, projectRoot, sourcePaths } = await resolveConfigReferences(
227
- requestedSourcePath,
228
- requestedRoot,
229
- );
239
+ const {
240
+ config: sourceConfig,
241
+ projectRoot,
242
+ sourcePaths,
243
+ } = await resolveConfigReferences(requestedSourcePath, requestedRoot);
230
244
  const sourcePath = sourcePaths[0] || requestedSourcePath;
245
+ const hasGlobs = hasNavigationGlobs(sourceConfig.navigation);
246
+ let working = sourceConfig;
247
+ let specPath;
248
+
249
+ // OpenAPI expansion runs before glob expansion so generated stub pages are
250
+ // visible to navigation globs and every downstream consumer.
251
+ if (expandGlobs && working.api?.spec) {
252
+ const loadedSpec = await loadOpenApiSpec({ root: projectRoot, specPath: working.api.spec });
253
+ specPath = loadedSpec.specPath;
254
+ const operations = normalizeOperations(loadedSpec.spec);
255
+ const directory = resolveApiDirectory(working.api);
256
+ const { config: shisoConfig } = await loadShisoConfig({ root: projectRoot });
257
+
258
+ await generateOpenApiStubs({
259
+ root: projectRoot,
260
+ contentDir: shisoConfig.contentDir,
261
+ directory,
262
+ operations,
263
+ });
264
+ working = expandOpenApiNavigation(working, { operations, directory });
265
+ } else if (expandGlobs && hasOpenApiItems(working.navigation)) {
266
+ throw new Error(
267
+ 'Navigation contains an { "openapi" } entry but docs.json has no "api.spec" setting.',
268
+ );
269
+ }
270
+
271
+ const config = expandGlobs
272
+ ? await expandNavigationGlobs(working, {
273
+ root: projectRoot,
274
+ contentDir: (await loadShisoConfig({ root: projectRoot })).config.contentDir,
275
+ })
276
+ : working;
231
277
 
232
- return { config, projectRoot, sourcePath, sourcePaths };
278
+ return { config, projectRoot, sourcePath, sourcePaths, hasGlobs, specPath };
233
279
  }
234
280
 
235
281
  export async function loadDocsSchema({
@@ -15,7 +15,9 @@ import { createJiti } from 'jiti';
15
15
  /** Candidate filenames in precedence order. Exactly one may exist. */
16
16
  export const SHISO_CONFIG_FILES = ['shiso.config.ts', 'shiso.config.mjs', 'shiso.config.js'];
17
17
 
18
- const KNOWN_KEYS = ['docsPrefix', 'contentDir', 'siteUrl', 'locale'];
18
+ const STRING_KEYS = ['docsPrefix', 'contentDir', 'siteUrl', 'locale'];
19
+ const KNOWN_KEYS = [...STRING_KEYS, 'mdx'];
20
+ const MDX_KEYS = ['remarkPlugins', 'rehypePlugins'];
19
21
 
20
22
  let importGeneration = 0;
21
23
 
@@ -53,12 +55,45 @@ function assertStringOption(raw, key, sourcePath) {
53
55
  }
54
56
  }
55
57
 
58
+ function resolveMdxConfig(value, sourcePath) {
59
+ if (value === undefined) return undefined;
60
+
61
+ if (!isPlainObject(value)) {
62
+ throw new ShisoConfigLoadError('Shiso config option "mdx" must be a plain object.', {
63
+ code: 'INVALID_OPTION',
64
+ sourcePath,
65
+ });
66
+ }
67
+
68
+ const unknownKeys = Object.keys(value).filter(key => !MDX_KEYS.includes(key));
69
+ if (unknownKeys.length) {
70
+ throw new ShisoConfigLoadError(
71
+ `Shiso config option "mdx" has unknown ${unknownKeys.length === 1 ? 'key' : 'keys'} ${unknownKeys.map(key => `"${key}"`).join(', ')}. Supported keys: ${MDX_KEYS.join(', ')}.`,
72
+ { code: 'UNKNOWN_OPTION', sourcePath },
73
+ );
74
+ }
75
+
76
+ for (const key of MDX_KEYS) {
77
+ if (value[key] !== undefined && !Array.isArray(value[key])) {
78
+ throw new ShisoConfigLoadError(`Shiso config option "mdx.${key}" must be an array.`, {
79
+ code: 'INVALID_OPTION',
80
+ sourcePath,
81
+ });
82
+ }
83
+ }
84
+
85
+ return {
86
+ remarkPlugins: value.remarkPlugins || [],
87
+ rehypePlugins: value.rehypePlugins || [],
88
+ };
89
+ }
90
+
56
91
  /**
57
92
  * Applies defaults and normalization. Single source of truth for resolved
58
93
  * values, so runtime and build-time consumers never re-implement defaulting.
59
94
  */
60
95
  export function resolveShisoConfig(raw = {}, sourcePath = null) {
61
- for (const key of KNOWN_KEYS) {
96
+ for (const key of STRING_KEYS) {
62
97
  assertStringOption(raw, key, sourcePath);
63
98
  }
64
99
 
@@ -67,6 +102,7 @@ export function resolveShisoConfig(raw = {}, sourcePath = null) {
67
102
  contentDir: (raw.contentDir ?? 'content/docs').trim().replace(/^\/+|\/+$/g, ''),
68
103
  siteUrl: raw.siteUrl?.trim().replace(/\/+$/, '') || undefined,
69
104
  locale: raw.locale?.trim() || 'en-US',
105
+ mdx: resolveMdxConfig(raw.mdx, sourcePath),
70
106
  };
71
107
  }
72
108
 
@@ -108,7 +144,13 @@ export async function loadShisoConfig({ root = process.cwd() } = {}) {
108
144
  }
109
145
 
110
146
  if (found.length === 0) {
111
- return { config: resolveShisoConfig(), raw: {}, projectRoot, sourcePath: null, sourcePaths: [] };
147
+ return {
148
+ config: resolveShisoConfig(),
149
+ raw: {},
150
+ projectRoot,
151
+ sourcePath: null,
152
+ sourcePaths: [],
153
+ };
112
154
  }
113
155
 
114
156
  const sourcePath = found[0];
@@ -16,6 +16,13 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
16
16
  import path from 'node:path';
17
17
  import process from 'node:process';
18
18
  import { pathToFileURL } from 'node:url';
19
+ import {
20
+ loadOpenApiSpec,
21
+ normalizeOperationKey,
22
+ normalizeOperations,
23
+ operationToMarkdown,
24
+ } from './lib/openapi.mjs';
25
+ import { loadDocsConfig } from './load-docs-config.mjs';
19
26
 
20
27
  const DEFAULT_HEAD_OPEN = '<!--shiso-default-head-->';
21
28
  const DEFAULT_HEAD_CLOSE = '<!--/shiso-default-head-->';
@@ -31,8 +38,17 @@ if (!template.includes('<!--app-html-->')) {
31
38
  );
32
39
  }
33
40
 
34
- const { render, getRoutes, getRedirects, getSitemapEntries, getMarkdownPages, docsHomeUrl } =
35
- await import(pathToFileURL(path.join(root, 'dist', 'server', 'entry-server.js')).href);
41
+ const {
42
+ render,
43
+ getRoutes,
44
+ getRedirects,
45
+ getSitemapEntries,
46
+ getMarkdownPages,
47
+ getLlmsPages,
48
+ docsHomeUrl,
49
+ siteName,
50
+ siteDescription,
51
+ } = await import(pathToFileURL(path.join(root, 'dist', 'server', 'entry-server.js')).href);
36
52
 
37
53
  /** Vite's `base`, normalized to "" or "/prefix". */
38
54
  function readBase() {
@@ -104,6 +120,25 @@ if (docsHomeUrl && docsHomeUrl !== '/' && !routes.includes('/')) {
104
120
  );
105
121
  }
106
122
 
123
+ // Pages bound to an API operation publish the generated reference as markdown
124
+ // too, so the .md copies and llms-full.txt stay useful to AI tools.
125
+ let openApiByKey;
126
+ {
127
+ const docsConfig = (await loadDocsConfig({ root, expandGlobs: false })).config;
128
+ if (docsConfig.api?.spec) {
129
+ const { spec } = await loadOpenApiSpec({ root, specPath: docsConfig.api.spec });
130
+ openApiByKey = new Map(normalizeOperations(spec).map(operation => [operation.key, operation]));
131
+ }
132
+ }
133
+
134
+ function withOperationMarkdown(source) {
135
+ if (!openApiByKey) return source;
136
+ const frontmatter = source.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1] || '';
137
+ const key = normalizeOperationKey(frontmatter.match(/^openapi:\s*(.+)$/m)?.[1]);
138
+ const operation = key ? openApiByKey.get(key) : undefined;
139
+ return operation ? `${source.trimEnd()}\n\n${operationToMarkdown(operation)}\n` : source;
140
+ }
141
+
107
142
  // Raw markdown next to every page: "/docs/installation" -> "docs/installation.md".
108
143
  // Served for the contextual menu's copy/view options and for AI tools.
109
144
  const markdownPages = getMarkdownPages();
@@ -111,9 +146,52 @@ const markdownPages = getMarkdownPages();
111
146
  for (const { route, filePath } of markdownPages) {
112
147
  const source = await readFile(path.join(root, ...filePath.split('/').filter(Boolean)), 'utf8');
113
148
  const relative = withBase(route).replace(/^\//, '') || 'index';
114
- await writePage(path.join(clientDir, `${relative}.md`), source);
149
+ await writePage(path.join(clientDir, `${relative}.md`), withOperationMarkdown(source));
150
+ }
151
+
152
+ // AI discovery files. llms.txt is the concise, ordered map; llms-full.txt is
153
+ // the same public corpus concatenated for tools that prefer one fetch.
154
+ const llmsPages = getLlmsPages();
155
+
156
+ function markdownHref(route) {
157
+ const relative = withBase(route).replace(/^\//, '') || 'index';
158
+ return `/${relative}.md`;
159
+ }
160
+
161
+ const llmsHeader = [
162
+ `# ${siteName || 'Documentation'}`,
163
+ siteDescription ? `> ${siteDescription}` : null,
164
+ ]
165
+ .filter(Boolean)
166
+ .join('\n\n');
167
+ const llmsLinks = llmsPages
168
+ .map(
169
+ page =>
170
+ `- [${page.title}](${markdownHref(page.route)})${page.description ? `: ${page.description}` : ''}`,
171
+ )
172
+ .join('\n');
173
+
174
+ await writePage(
175
+ path.join(clientDir, 'llms.txt'),
176
+ `${llmsHeader}\n\n## Documentation\n\n${llmsLinks}\n`,
177
+ );
178
+
179
+ const llmsFullSections = [];
180
+ for (const page of llmsPages) {
181
+ const source = await readFile(
182
+ path.join(root, ...page.filePath.split('/').filter(Boolean)),
183
+ 'utf8',
184
+ );
185
+ llmsFullSections.push(
186
+ [`# ${page.title}`, `Source: ${markdownHref(page.route)}`, source.trim()].join('\n\n'),
187
+ );
115
188
  }
116
189
 
190
+ await writePage(
191
+ path.join(clientDir, 'llms-full.txt'),
192
+ `${llmsHeader}\n\n${llmsFullSections.join('\n\n---\n\n')}\n`,
193
+ );
194
+
117
195
  // Redirect pages. Static hosting cannot serve real 301s, so each redirect
118
196
  // gets the same canonical + meta refresh + immediate replace treatment as
119
197
  // the root entry. Real pages always win over redirect rules.
@@ -187,6 +265,8 @@ if (stray.length) {
187
265
  const extras = [
188
266
  redirects.length ? `${redirects.length} redirects` : null,
189
267
  sitemapEntries.length ? 'sitemap.xml' : null,
268
+ 'llms.txt',
269
+ 'llms-full.txt',
190
270
  ]
191
271
  .filter(Boolean)
192
272
  .join(', ');
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { loadDocsConfig } from './load-docs-config.mjs';
3
- import { SHISO_CONFIG_FILES, loadShisoConfig } from './load-shiso-config.mjs';
3
+ import { loadShisoConfig, SHISO_CONFIG_FILES } from './load-shiso-config.mjs';
4
4
 
5
5
  export const VIRTUAL_DOCS_CONFIG_ID = 'virtual:shiso-docs-config';
6
6
  export const VIRTUAL_SHISO_CONFIG_ID = 'virtual:shiso-config';
@@ -11,6 +11,13 @@ function renderConfigModule(config) {
11
11
  return `export default ${JSON.stringify(config)};`;
12
12
  }
13
13
 
14
+ function renderShisoConfigModule(config) {
15
+ // Compiler plugins are functions used by vite.config.ts and cannot be
16
+ // serialized into the virtual module consumed by the browser runtime.
17
+ const { mdx: _mdx, ...runtimeConfig } = config;
18
+ return renderConfigModule(runtimeConfig);
19
+ }
20
+
14
21
  /**
15
22
  * Creates the single config state shared by a Vite build and application
16
23
  * modules. Two virtual modules keep Node-only file loading out of the browser
@@ -35,6 +42,7 @@ export async function createDocsConfigModule({
35
42
  return {
36
43
  getConfig: () => loaded.config,
37
44
  getShisoConfig: () => loadedShiso.config,
45
+ getSpecPath: () => loaded.specPath,
38
46
  getSourcePaths: () => [...loaded.sourcePaths, ...loadedShiso.sourcePaths],
39
47
  sourcePath: loaded.sourcePath,
40
48
  shisoSourcePath: loadedShiso.sourcePath,
@@ -64,26 +72,39 @@ export async function createDocsConfigModule({
64
72
  for (const sourcePath of loadedShiso.sourcePaths) {
65
73
  this.addWatchFile(sourcePath);
66
74
  }
67
- return renderConfigModule(loadedShiso.config);
75
+ return renderShisoConfigModule(loadedShiso.config);
68
76
  }
69
77
 
70
78
  return undefined;
71
79
  },
72
80
  async handleHotUpdate(context) {
73
81
  const changedPath = path.resolve(context.file);
74
- const isDocsSource = loaded.sourcePaths.includes(changedPath);
82
+ const isDocsSource =
83
+ loaded.sourcePaths.includes(changedPath) || changedPath === loaded.specPath;
75
84
  const isShisoSource = shisoCandidatePaths.includes(changedPath);
85
+ const contentRoot = path.resolve(root, loadedShiso.config.contentDir);
86
+ const relativeContentPath = path.relative(contentRoot, changedPath);
87
+ const isGlobContent =
88
+ loaded.hasGlobs &&
89
+ /\.(?:md|mdx)$/.test(changedPath) &&
90
+ relativeContentPath !== '..' &&
91
+ !relativeContentPath.startsWith(`..${path.sep}`) &&
92
+ !path.isAbsolute(relativeContentPath);
76
93
 
77
- if (!isDocsSource && !isShisoSource) {
94
+ if (!isDocsSource && !isShisoSource && !isGlobContent) {
78
95
  return;
79
96
  }
80
97
 
81
- const resolvedId = isDocsSource ? RESOLVED_DOCS_CONFIG_ID : RESOLVED_SHISO_CONFIG_ID;
98
+ const resolvedId =
99
+ isDocsSource || isGlobContent ? RESOLVED_DOCS_CONFIG_ID : RESOLVED_SHISO_CONFIG_ID;
82
100
 
83
- if (isDocsSource) {
101
+ if (isDocsSource || isGlobContent) {
84
102
  loaded = await loadDocsConfig(options);
85
103
  } else {
86
104
  loadedShiso = await loadShisoConfig({ root });
105
+ // contentDir may have changed, so glob expansion must use the new
106
+ // location before the full reload.
107
+ loaded = await loadDocsConfig(options);
87
108
  }
88
109
 
89
110
  const configModule = context.server.moduleGraph.getModuleById(resolvedId);
package/src/App.tsx CHANGED
@@ -1,6 +1,5 @@
1
1
  import '@fontsource-variable/inter/index.css';
2
2
  import '@fontsource/jetbrains-mono/400.css';
3
- import 'highlight.js/styles/github.css';
4
3
  import '@umami/shiso/styles.css';
5
4
 
6
5
  import { MDXProvider } from '@mdx-js/react';
@@ -1,20 +1,61 @@
1
- import { type ReactNode, useRef, useState } from 'react';
1
+ import { type ComponentProps, type CSSProperties, useRef, useState } from 'react';
2
2
  import { CheckIcon, Copy } from '@/components/icons';
3
3
  import { Button } from '@/components/ui/button';
4
4
  import { ScrollArea } from '@/components/ui/scroll-area';
5
+ import { cn } from '@/lib/utils';
5
6
 
6
- export interface CodeBlockProps {
7
- children?: ReactNode;
8
- className?: string;
7
+ /**
8
+ * Renders a fenced code block. The `data-*` props are produced at build time by
9
+ * `lib/rehype-shiki.ts`; see that file for the markup contract.
10
+ */
11
+ export interface CodeBlockProps extends ComponentProps<'pre'> {
12
+ 'data-title'?: string;
13
+ 'data-language'?: string;
14
+ 'data-line-numbers'?: string;
15
+ 'data-line-start'?: string;
16
+ 'data-line-count'?: string;
17
+ 'data-diff-markers'?: string;
9
18
  }
10
19
 
11
- export function CodeBlock({ children, className }: CodeBlockProps) {
20
+ /**
21
+ * Joins the text of each rendered line. Lines marked as removed by
22
+ * `// [!code --]` are skipped so the clipboard holds the "after" state; in a
23
+ * `diff` block the +/- lines are content and are copied verbatim.
24
+ */
25
+ function copyText(pre: HTMLPreElement | null, language?: string): string {
26
+ if (!pre) {
27
+ return '';
28
+ }
29
+
30
+ const lines = [...pre.querySelectorAll<HTMLElement>('.line')];
31
+ if (!lines.length) {
32
+ return pre.textContent || '';
33
+ }
34
+
35
+ return lines
36
+ .filter(line => language === 'diff' || line.dataset.diff !== 'remove')
37
+ .map(line => line.textContent || '')
38
+ .join('\n');
39
+ }
40
+
41
+ export function CodeBlock({ children, className, style, ...rest }: CodeBlockProps) {
42
+ const {
43
+ 'data-title': title,
44
+ 'data-language': language,
45
+ 'data-line-start': lineStart,
46
+ 'data-line-count': lineCount,
47
+ ...preProps
48
+ } = rest;
12
49
  const textInput = useRef<HTMLPreElement>(null);
13
50
  const [copied, setCopied] = useState(false);
14
51
 
52
+ const start = Number(lineStart) || 1;
53
+ const lastLine = start + Math.max(Number(lineCount) || 1, 1) - 1;
54
+ const gutter = `${String(lastLine).length}ch`;
55
+
15
56
  const handleCopy = () => {
16
57
  setCopied(true);
17
- navigator?.clipboard?.writeText(textInput.current?.textContent || '');
58
+ navigator?.clipboard?.writeText(copyText(textInput.current, language));
18
59
 
19
60
  setTimeout(() => {
20
61
  setCopied(false);
@@ -23,10 +64,30 @@ export function CodeBlock({ children, className }: CodeBlockProps) {
23
64
 
24
65
  return (
25
66
  <div data-slot="code-block" className="relative my-5 overflow-hidden rounded-lg bg-card">
67
+ {title ? (
68
+ <div
69
+ data-slot="code-block-header"
70
+ className="flex h-9 items-center border-border border-b px-3 pr-12 font-mono text-muted-foreground text-xs"
71
+ >
72
+ {title}
73
+ </div>
74
+ ) : null}
26
75
  <ScrollArea scrollbars="horizontal" className="w-full">
27
76
  <pre
28
77
  ref={textInput}
29
- className={`code-block p-3 pr-12 text-sm text-foreground leading-[1.6] font-mono ${className || ''}`}
78
+ {...preProps}
79
+ data-language={language}
80
+ style={
81
+ {
82
+ ...style,
83
+ counterReset: `line ${start - 1}`,
84
+ '--code-gutter': gutter,
85
+ } as CSSProperties
86
+ }
87
+ className={cn(
88
+ 'code-block w-max min-w-full py-3 font-mono text-foreground text-sm leading-[1.6]',
89
+ className,
90
+ )}
30
91
  >
31
92
  {children}
32
93
  </pre>
@@ -35,7 +96,10 @@ export function CodeBlock({ children, className }: CodeBlockProps) {
35
96
  type="button"
36
97
  variant="ghost"
37
98
  size="icon-sm"
38
- className="absolute top-2.5 right-3 inline-flex size-7 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
99
+ className={cn(
100
+ 'absolute right-3 inline-flex size-7 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground',
101
+ title ? 'top-1' : 'top-2.5',
102
+ )}
39
103
  onClick={handleCopy}
40
104
  aria-label="Copy code"
41
105
  >
@@ -1,9 +1,12 @@
1
1
  import { Link } from 'react-router';
2
2
  import { ContextualMenu } from '@/components/ContextualMenu';
3
+ import { Badge } from '@/components/docs/Badge';
3
4
  import { ArrowLeft, ArrowRight, FileText } from '@/components/icons';
5
+ import { OpenApiOperation } from '@/components/OpenApiOperation';
4
6
  import { getLastModified } from '@/lib/content';
5
7
  import { getScopeForPage } from '@/lib/docs-config';
6
8
  import { resolveLocale } from '@/lib/locale';
9
+ import { getOperation, methodColor } from '@/lib/openapi';
7
10
  import { docsSite, getPageByPathname } from '@/lib/site-config';
8
11
  import { resolveContextualOptions } from '@/lib/site-model';
9
12
  import type { DocModule, NormalizedDocsPage, RelatedEntry, SiteModel } from '@/lib/types';
@@ -81,6 +84,7 @@ export function DocContent({ page, doc, site }: DocContentProps) {
81
84
  : page.section;
82
85
  const contextualOptions = resolveContextualOptions(site.contextualOptions, page, site.labels);
83
86
  const related = resolveRelated(doc.frontmatter?.related);
87
+ const operation = getOperation(doc.frontmatter?.openapi);
84
88
  // Dates follow the page's language when it is a valid locale code.
85
89
  const dateFormat = new Intl.DateTimeFormat(resolveLocale(page.language, site.locale), {
86
90
  dateStyle: 'medium',
@@ -111,12 +115,26 @@ export function DocContent({ page, doc, site }: DocContentProps) {
111
115
  )}
112
116
  <ContextualMenu options={contextualOptions} labels={site.labels} />
113
117
  </div>
118
+ {operation && (
119
+ <div className="mt-3 flex flex-wrap items-center gap-2">
120
+ <Badge color={methodColor(operation.method)} size="sm" className="font-mono">
121
+ {operation.method}
122
+ </Badge>
123
+ <code className="font-mono text-muted-foreground text-sm">{operation.path}</code>
124
+ {operation.deprecated && (
125
+ <Badge color="red" size="sm" stroke>
126
+ deprecated
127
+ </Badge>
128
+ )}
129
+ </div>
130
+ )}
114
131
  {description && (
115
132
  <p className="mt-3 mb-8 text-lg text-muted-foreground leading-relaxed">{description}</p>
116
133
  )}
117
134
  <div className="docs-markdown">
118
135
  <Content />
119
136
  </div>
137
+ {operation && <OpenApiOperation operation={operation} />}
120
138
  {lastModified && (
121
139
  <div className="mt-8 text-sm text-muted-foreground">
122
140
  {site.labels.lastUpdated}{' '}
@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button';
11
11
  import { Sheet, SheetContent, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
12
12
  import { VersionSwitcher } from '@/components/VersionSwitcher';
13
13
  import { renderInlineMarkdown } from '@/lib/inline-markdown';
14
+ import { getOperation, operationSections } from '@/lib/openapi';
14
15
  import { docsHomeUrl, getScopeByPathname } from '@/lib/site-config';
15
16
  import type { DocModule, NormalizedDocsPage, SiteModel } from '@/lib/types';
16
17
 
@@ -72,6 +73,10 @@ export function Docs({ page, doc, site }: DocsProps) {
72
73
  );
73
74
  }
74
75
 
76
+ // API reference pages append their generated section anchors to the TOC.
77
+ const operation = getOperation(doc.frontmatter?.openapi);
78
+ const toc = operation ? [...(doc.toc || []), ...operationSections(operation)] : doc.toc;
79
+
75
80
  return (
76
81
  <div className="flex min-h-full flex-col gap-6 lg:gap-0">
77
82
  <Sheet open={menuOpen} onOpenChange={setMenuOpen}>
@@ -124,7 +129,7 @@ export function Docs({ page, doc, site }: DocsProps) {
124
129
  <DocContent page={page} doc={doc} site={site} />
125
130
  <div className="hidden min-w-0 max-w-60 basis-60 self-start lg:sticky lg:top-[calc(var(--header-height)+1.5rem)] lg:block lg:shrink-0">
126
131
  <PageLinks
127
- items={doc.toc}
132
+ items={toc}
128
133
  title={site.labels.tableOfContents}
129
134
  navigationLabel={site.labels.tableOfContentsNavigation}
130
135
  />