@umami/shiso 1.10.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 (43) hide show
  1. package/bin/shiso.mjs +20 -1
  2. package/dist/chunks/App.js +363 -29
  3. package/dist/chunks/docs.js +33 -41
  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 +73 -9
  22. package/src/components/DocContent.tsx +24 -3
  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/Card.tsx +1 -1
  27. package/src/components/docs/CodeGroup.tsx +6 -2
  28. package/src/components/docs/Expandable.tsx +1 -1
  29. package/src/components/docs/PropertiesTable.tsx +12 -25
  30. package/src/components/docs/styles.ts +7 -4
  31. package/src/entry-server.tsx +50 -0
  32. package/src/lib/code-blocks.ts +18 -0
  33. package/src/lib/code-meta.ts +87 -0
  34. package/src/lib/docs-config.ts +4 -1
  35. package/src/lib/openapi.generated.ts +4 -0
  36. package/src/lib/openapi.ts +63 -0
  37. package/src/lib/rehype-shiki.ts +196 -0
  38. package/src/lib/site-model.ts +2 -0
  39. package/src/lib/types.ts +116 -2
  40. package/src/styles/global.css +75 -76
  41. package/types/config.d.ts +11 -4
  42. package/vite.config.ts +49 -3
  43. package/CHANGELOG.md +0 -171
@@ -0,0 +1,197 @@
1
+ import { CodeBlock } from '@/components/CodeBlock';
2
+ import { Badge } from '@/components/docs/Badge';
3
+ import { CodeGroup } from '@/components/docs/CodeGroup';
4
+ import { Expandable } from '@/components/docs/Expandable';
5
+ import { ParamField } from '@/components/docs/ParamField';
6
+ import { ResponseField } from '@/components/docs/ResponseField';
7
+ import { operationSections, statusColor } from '@/lib/openapi';
8
+ import type { NormalizedOperation, SchemaNode } from '@/lib/types';
9
+
10
+ type ParamLocation = 'path' | 'query' | 'header' | 'cookie';
11
+
12
+ const PARAM_LOCATIONS: ParamLocation[] = ['path', 'query', 'header', 'cookie'];
13
+
14
+ function FieldChildren({ node }: { node: SchemaNode }) {
15
+ return (
16
+ <>
17
+ {node.description}
18
+ {node.enum && node.enum.length > 0 && (
19
+ <div className="mt-1">
20
+ Options:{' '}
21
+ {node.enum.map((value, index) => (
22
+ <span key={value}>
23
+ {index > 0 && ', '}
24
+ <code>{value}</code>
25
+ </span>
26
+ ))}
27
+ </div>
28
+ )}
29
+ {node.children && node.children.length > 0 && (
30
+ <Expandable title="properties">
31
+ {node.children.map(child => (
32
+ <SchemaField key={child.name || child.type} node={child} />
33
+ ))}
34
+ </Expandable>
35
+ )}
36
+ </>
37
+ );
38
+ }
39
+
40
+ function SchemaField({ node }: { node: SchemaNode }) {
41
+ return (
42
+ <ResponseField
43
+ name={node.name || node.type}
44
+ type={node.name ? node.type : undefined}
45
+ required={node.required}
46
+ deprecated={node.deprecated}
47
+ default={node.default}
48
+ >
49
+ <FieldChildren node={node} />
50
+ </ResponseField>
51
+ );
52
+ }
53
+
54
+ /** Renders a schema tree: a root object's properties, or the node itself. */
55
+ function SchemaFields({ node }: { node: SchemaNode }) {
56
+ if (!node.name && node.children?.length) {
57
+ return (
58
+ <>
59
+ {node.children.map(child => (
60
+ <SchemaField key={child.name || child.type} node={child} />
61
+ ))}
62
+ </>
63
+ );
64
+ }
65
+
66
+ return <SchemaField node={node} />;
67
+ }
68
+
69
+ function HighlightedCode({
70
+ language,
71
+ title,
72
+ html,
73
+ source,
74
+ lineCount,
75
+ }: {
76
+ language: string;
77
+ title?: string;
78
+ html?: string;
79
+ source: string;
80
+ lineCount?: number;
81
+ }) {
82
+ return (
83
+ <CodeBlock
84
+ data-language={language}
85
+ data-title={title}
86
+ data-line-count={lineCount ? String(lineCount) : undefined}
87
+ >
88
+ {html ? (
89
+ // Highlighted at build time by the openapi generator; same markup as
90
+ // the rehype-shiki pipeline produces for fenced code blocks.
91
+ <code
92
+ className={`language-${language}`}
93
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: build-time generated markup from the project's own OpenAPI spec.
94
+ dangerouslySetInnerHTML={{ __html: html }}
95
+ />
96
+ ) : (
97
+ <code className={`language-${language}`}>{source}</code>
98
+ )}
99
+ </CodeBlock>
100
+ );
101
+ }
102
+
103
+ export interface OpenApiOperationProps {
104
+ operation: NormalizedOperation;
105
+ }
106
+
107
+ /** The generated reference for one API operation, rendered under the page body. */
108
+ export function OpenApiOperation({ operation }: OpenApiOperationProps) {
109
+ const sections = new Map(operationSections(operation).map(entry => [entry.name, entry.id]));
110
+ const showParameters = sections.has('Parameters');
111
+
112
+ return (
113
+ <div className="docs-markdown">
114
+ {showParameters && (
115
+ <section>
116
+ <h2 id={sections.get('Parameters')}>Parameters</h2>
117
+ {operation.security.length > 0 && (
118
+ <ParamField header="Authorization" type="string" required>
119
+ Authentication credentials, e.g. <code>Bearer &lt;token&gt;</code> (
120
+ {operation.security.join(', ')}).
121
+ </ParamField>
122
+ )}
123
+ {PARAM_LOCATIONS.map(location =>
124
+ operation.parameters[location].map(parameter => (
125
+ <ParamField
126
+ key={`${location}-${parameter.name}`}
127
+ {...{ [location]: parameter.name }}
128
+ type={parameter.type}
129
+ required={parameter.required}
130
+ >
131
+ <FieldChildren node={{ ...parameter, name: undefined, description: undefined }} />
132
+ {parameter.description}
133
+ </ParamField>
134
+ )),
135
+ )}
136
+ </section>
137
+ )}
138
+ {operation.requestBody && (
139
+ <section>
140
+ <h2 id={sections.get('Request body')}>Request body</h2>
141
+ <SchemaFields node={operation.requestBody.schema} />
142
+ {operation.requestBody.example && (
143
+ <HighlightedCode
144
+ language="json"
145
+ title="Example request"
146
+ html={operation.requestBody.exampleHtml}
147
+ source={operation.requestBody.example}
148
+ />
149
+ )}
150
+ </section>
151
+ )}
152
+ {operation.responses.length > 0 && (
153
+ <section>
154
+ <h2 id={sections.get('Responses')}>Responses</h2>
155
+ {operation.responses.map(response => (
156
+ <div key={response.status} className="mt-6 first:mt-0">
157
+ <div className="flex items-center gap-2">
158
+ <Badge color={statusColor(response.status)} size="sm">
159
+ {response.status}
160
+ </Badge>
161
+ {response.description && (
162
+ <span className="text-muted-foreground text-sm">{response.description}</span>
163
+ )}
164
+ </div>
165
+ {response.schema && <SchemaFields node={response.schema} />}
166
+ {response.example && (
167
+ <HighlightedCode
168
+ language="json"
169
+ title={`${response.status} example`}
170
+ html={response.exampleHtml}
171
+ source={response.example}
172
+ />
173
+ )}
174
+ </div>
175
+ ))}
176
+ </section>
177
+ )}
178
+ {operation.samples.length > 0 && (
179
+ <section>
180
+ <h2 id={sections.get('Code samples')}>Code samples</h2>
181
+ <CodeGroup>
182
+ {operation.samples.map(sample => (
183
+ <HighlightedCode
184
+ key={sample.language}
185
+ language={sample.language}
186
+ title={sample.label}
187
+ html={sample.html}
188
+ source={sample.source}
189
+ lineCount={sample.lineCount}
190
+ />
191
+ ))}
192
+ </CodeGroup>
193
+ </section>
194
+ )}
195
+ </div>
196
+ );
197
+ }
@@ -1,5 +1,6 @@
1
1
  import { type ReactNode, useEffect, useState } from 'react';
2
2
  import { Link, useLocation, useNavigate } from 'react-router';
3
+ import { Badge as MethodBadge } from '@/components/docs/Badge';
3
4
  import { resolveIcon } from '@/components/docs/utils';
4
5
  import { ChevronRight, ExternalLink } from '@/components/icons';
5
6
  import { Badge } from '@/components/ui/badge';
@@ -7,6 +8,7 @@ import { Button } from '@/components/ui/button';
7
8
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
8
9
  import { ScrollArea } from '@/components/ui/scroll-area';
9
10
  import { flattenNav, isNodeHidden } from '@/lib/docs-config';
11
+ import { methodColor } from '@/lib/openapi';
10
12
  import type { DocsTab, NavGroupNode, NavNode } from '@/lib/types';
11
13
  import { cn } from '@/lib/utils';
12
14
 
@@ -256,7 +258,7 @@ function NavNodes({
256
258
  }
257
259
 
258
260
  if (node.kind === 'page') {
259
- const { url, label, icon, tag } = node.page;
261
+ const { url, label, icon, tag, method } = node.page;
260
262
  const isSelected = url === pathname;
261
263
 
262
264
  rendered.push(
@@ -274,7 +276,11 @@ function NavNodes({
274
276
  >
275
277
  {resolveIcon(icon)}
276
278
  {label}
277
- {tag ? (
279
+ {method ? (
280
+ <MethodBadge color={methodColor(method)} size="xs" className="ml-auto font-mono">
281
+ {method === 'DELETE' ? 'DEL' : method}
282
+ </MethodBadge>
283
+ ) : tag ? (
278
284
  <Badge
279
285
  variant="secondary"
280
286
  className="ml-auto h-auto rounded-sm px-[0.35rem] py-[0.05rem] text-[0.7rem] text-muted-foreground uppercase"
@@ -110,7 +110,7 @@ export function Card({
110
110
  }: CardProps) {
111
111
  const external = typeof href === 'string' && /^https?:\/\//i.test(href);
112
112
  const showArrow = arrow ?? external;
113
- const className = `${styles.card} ${type ? `${styles.cardTyped} ${styles[type]} ${CARD_TYPE_HOVER[type]}` : ''} ${horizontal ? styles.cardHorizontal : ''} ${img ? styles.cardImageLayout : ''}`;
113
+ const className = `${styles.card} ${type ? `${styles.cardTyped} ${styles[type]} ${CARD_TYPE_HOVER[type]}` : styles.cardPlain} ${horizontal ? styles.cardHorizontal : ''} ${img ? styles.cardImageLayout : ''}`;
114
114
  const content = (
115
115
  <CardPrimitive className={className}>
116
116
  <DocsCardContent
@@ -39,14 +39,18 @@ export function CodeGroup({ children }: CodeGroupProps) {
39
39
  }
40
40
 
41
41
  if (blocks.length === 1) {
42
- return <div className={styles.tabs}>{blocks[0].content}</div>;
42
+ return (
43
+ <div className={`${styles.tabs} [&_[data-slot=code-block-header]]:hidden`}>
44
+ {blocks[0].content}
45
+ </div>
46
+ );
43
47
  }
44
48
 
45
49
  return (
46
50
  <TabsPrimitive
47
51
  value={selected}
48
52
  onValueChange={setSelectedKey}
49
- className="my-4 gap-0 overflow-hidden rounded-lg bg-muted/50 [&_[data-slot=code-block]]:my-0 [&_[data-slot=code-block]]:rounded-none [&_[data-slot=code-block]]:border-0 [&_[data-slot=code-block]]:bg-transparent"
53
+ className="my-4 gap-0 overflow-hidden rounded-lg bg-card [&_[data-slot=code-block]]:my-0 [&_[data-slot=code-block]]:rounded-none [&_[data-slot=code-block]]:border-0 [&_[data-slot=code-block]]:bg-transparent [&_[data-slot=code-block-header]]:hidden"
50
54
  >
51
55
  <TabsList
52
56
  variant="line"
@@ -20,7 +20,7 @@ export function Expandable({ title, children, defaultOpen = false }: ExpandableP
20
20
  {title}
21
21
  </CollapsibleTrigger>
22
22
  <CollapsibleContent>
23
- <div className={styles.accordionContent}>{children}</div>
23
+ <div className={`${styles.accordionContent} ${styles.expandableContent}`}>{children}</div>
24
24
  </CollapsibleContent>
25
25
  </div>
26
26
  </Collapsible>
@@ -22,33 +22,26 @@ export function PropertiesTable({ children }: PropertiesTableProps) {
22
22
  return null;
23
23
  }
24
24
 
25
+ // Rendered as a plain table so the .docs-markdown table rules in global.css
26
+ // style it exactly like a GFM table; only column widths are set here.
25
27
  return (
26
- <div className="my-4 overflow-x-auto rounded-lg border border-border px-6 py-2">
27
- <table className="m-0 min-w-[40rem] table-fixed border-collapse border-0">
28
+ <div className="my-4 overflow-x-auto">
29
+ <table className="m-0 min-w-[40rem] table-fixed">
28
30
  <thead>
29
31
  <tr>
30
- <th className="w-1/5 border-x-0 border-t-0 border-b border-border bg-transparent px-0 py-2 pr-6 text-left text-sm font-semibold text-foreground">
31
- Name
32
- </th>
33
- <th className="w-1/4 border-x-0 border-t-0 border-b border-border bg-transparent px-0 py-2 pr-6 text-left text-sm font-semibold text-foreground">
34
- Type
35
- </th>
36
- <th className="border-x-0 border-t-0 border-b border-border bg-transparent px-0 py-2 text-left text-sm font-semibold text-foreground">
37
- Description
38
- </th>
32
+ <th className="w-1/5">Name</th>
33
+ <th className="w-1/4">Type</th>
34
+ <th>Description</th>
39
35
  </tr>
40
36
  </thead>
41
37
  <tbody>
42
- {rows.map((row, index) => {
38
+ {rows.map(row => {
43
39
  const { name, type, required, deprecated, children: description } = row.props;
44
40
  const defaultValue = displayValue(row.props.default);
45
- const borderClass = index === rows.length - 1 ? 'border-b-0' : 'border-b';
46
41
 
47
42
  return (
48
43
  <tr key={row.key ?? name}>
49
- <td
50
- className={`${borderClass} border-x-0 border-t-0 border-border px-0 py-2 pr-6 align-top text-sm text-foreground`}
51
- >
44
+ <td>
52
45
  <span className="inline-flex flex-wrap items-center gap-2">
53
46
  {name}
54
47
  {required ? (
@@ -59,17 +52,11 @@ export function PropertiesTable({ children }: PropertiesTableProps) {
59
52
  {deprecated ? <Badge size="xs">deprecated</Badge> : null}
60
53
  </span>
61
54
  </td>
62
- <td
63
- className={`${borderClass} border-x-0 border-t-0 border-border px-0 py-2 pr-6 align-top text-sm text-foreground`}
64
- >
65
- {displayValue(type)}
66
- </td>
67
- <td
68
- className={`${borderClass} border-x-0 border-t-0 border-border px-0 py-2 align-top text-sm leading-6 text-foreground [&_p]:m-0`}
69
- >
55
+ <td>{displayValue(type)}</td>
56
+ <td className="[&_p]:m-0">
70
57
  {description}
71
58
  {defaultValue ? (
72
- <span className="mt-1 block text-sm text-muted-foreground">
59
+ <span className="mt-1 block text-muted-foreground">
73
60
  Default: {defaultValue}
74
61
  </span>
75
62
  ) : null}
@@ -9,8 +9,9 @@ export const styles = {
9
9
  accordionItem: '',
10
10
  accordionTrigger: 'text-foreground',
11
11
  accordionContent: 'text-muted-foreground [&_p]:mt-0 [&_p:last-child]:mb-0',
12
- expandableTrigger:
13
- 'items-center px-4 py-3 text-sm font-normal text-foreground hover:no-underline',
12
+ expandableTrigger: 'items-center py-3 text-sm font-normal text-foreground hover:no-underline',
13
+ /* Indent by chevron width + gap so body text lines up with the title. */
14
+ expandableContent: 'pl-6',
14
15
 
15
16
  callout: 'my-4 flex items-start gap-3 rounded-lg border px-3 py-2.5 text-sm leading-6',
16
17
  calloutIcon: 'flex h-6 w-5 shrink-0 items-center justify-center [&_svg]:block [&_svg]:size-4',
@@ -27,7 +28,9 @@ export const styles = {
27
28
  'border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-400/40 dark:bg-emerald-400/10 dark:text-emerald-300',
28
29
  danger: 'border-destructive/40 bg-destructive/10 text-destructive',
29
30
 
30
- card: 'block h-full gap-0 rounded-lg border border-border bg-transparent p-4 text-base ring-0 hover:border-primary',
31
+ card: 'block h-full gap-0 rounded-lg border border-border p-4 text-sm ring-0 hover:border-primary',
32
+ /* Typed cards bring their own tinted background; plain cards get the surface tint. */
33
+ cardPlain: 'bg-card',
31
34
  cardHorizontal:
32
35
  '[&_[data-slot=card-inner]]:items-center [&_[data-slot=card-main]]:flex-row [&_[data-slot=card-main]]:items-center [&_[data-slot=card-main]]:gap-3 [&_[data-slot=card-body]]:m-0 [&_[data-slot=card-header]]:flex-row [&_[data-slot=card-header]]:items-center [&_[data-slot=card-header]]:gap-2',
33
36
  cardTyped:
@@ -38,7 +41,7 @@ export const styles = {
38
41
  cardMain: 'flex min-w-0 flex-col gap-2',
39
42
  cardHeader: 'flex flex-col items-start gap-3',
40
43
  cardIcon: 'inline-flex shrink-0 items-center justify-center [&_img]:size-6 [&_svg]:size-6',
41
- cardTitle: 'text-base font-semibold text-foreground',
44
+ cardTitle: 'text-sm font-semibold text-foreground',
42
45
  cardBody: 'text-muted-foreground [&>:first-child]:mt-0 [&>:last-child]:mb-0',
43
46
  cardCta:
44
47
  'flex shrink-0 items-center gap-[0.35rem] text-[0.9rem] font-medium text-muted-foreground',
@@ -11,6 +11,7 @@ import {
11
11
  getLocaleByPathname,
12
12
  getRedirects,
13
13
  getSeo,
14
+ siteConfig,
14
15
  siteName,
15
16
  standalonePages,
16
17
  } from '@/lib/site-config';
@@ -27,6 +28,13 @@ export interface SitemapEntry {
27
28
  lastmod?: string;
28
29
  }
29
30
 
31
+ export interface LlmsPage {
32
+ route: string;
33
+ filePath: string;
34
+ title: string;
35
+ description?: string;
36
+ }
37
+
30
38
  /** Base-relative routes for every scope. The prerenderer prepends the deploy base itself. */
31
39
  export function getRoutes(): string[] {
32
40
  return [...docsSite.pages.map(page => page.url), ...standalonePages.map(page => page.path)];
@@ -51,6 +59,46 @@ export function getMarkdownPages(): { route: string; filePath: string }[] {
51
59
  ];
52
60
  }
53
61
 
62
+ /** Navigable Markdown pages used to generate llms.txt and llms-full.txt. */
63
+ export function getLlmsPages(): LlmsPage[] {
64
+ const { indexing } = getSeo();
65
+ const pages: LlmsPage[] = [];
66
+
67
+ for (const page of docsSite.pages) {
68
+ const doc = getDocModule(page.filePath);
69
+
70
+ if (
71
+ doc?.frontmatter?.noindex === true ||
72
+ ((page.hidden || getScopeForPage(docsSite, page).hidden) && indexing !== 'all')
73
+ ) {
74
+ continue;
75
+ }
76
+
77
+ pages.push({
78
+ route: page.url,
79
+ filePath: page.filePath,
80
+ title: doc?.frontmatter?.title || page.label,
81
+ description: doc?.frontmatter?.description,
82
+ });
83
+ }
84
+
85
+ for (const page of standalonePages) {
86
+ if (page.filePath.endsWith('.tsx')) continue;
87
+
88
+ const doc = getDocModule(page.filePath);
89
+ if (doc?.frontmatter?.noindex === true) continue;
90
+
91
+ pages.push({
92
+ route: page.path,
93
+ filePath: page.filePath,
94
+ title: doc?.frontmatter?.title || page.title || page.path,
95
+ description: doc?.frontmatter?.description,
96
+ });
97
+ }
98
+
99
+ return pages;
100
+ }
101
+
54
102
  /**
55
103
  * Absolute URLs for the sitemap, honoring `seo.indexing` and per-page
56
104
  * noindex. Empty when the shiso.config `siteUrl` is not configured, since a sitemap
@@ -106,4 +154,6 @@ export function render(url: string): RenderResult {
106
154
  return { html, head: renderHeadToString(buildHead(url)), htmlAttrs: getLocaleByPathname(url) };
107
155
  }
108
156
 
157
+ export const siteDescription = siteConfig.description;
158
+
109
159
  export { docsHomeUrl, siteName };
@@ -0,0 +1,18 @@
1
+ // Relative import: this module is also loaded by vite.config.ts, which esbuild
2
+ // bundles without applying the '@/' resolve alias.
3
+ import type { ResolvedCodeBlockConfig, StylingConfig } from './types.ts';
4
+
5
+ export const DEFAULT_CODE_THEME = { light: 'github-light', dark: 'github-dark' } as const;
6
+
7
+ /** Applies defaults to `styling.codeBlocks` from docs.json. */
8
+ export function resolveCodeBlockConfig(styling?: StylingConfig): ResolvedCodeBlockConfig {
9
+ const codeBlocks = styling?.codeBlocks;
10
+
11
+ return {
12
+ lineNumbers: codeBlocks?.lineNumbers === true,
13
+ theme: {
14
+ light: codeBlocks?.theme?.light || DEFAULT_CODE_THEME.light,
15
+ dark: codeBlocks?.theme?.dark || DEFAULT_CODE_THEME.dark,
16
+ },
17
+ };
18
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Parses the fenced code meta string (the text after the language tag) into
3
+ * the directives Shiso understands:
4
+ *
5
+ * ```ts title="app.ts" {1,3-5} showLineNumbers=10
6
+ *
7
+ * Anything unrecognized becomes the title, so the bare label form used by
8
+ * `CodeGroup` (```bash npm) keeps working.
9
+ */
10
+
11
+ export interface CodeMeta {
12
+ title?: string;
13
+ highlightLines: number[];
14
+ showLineNumbers?: boolean;
15
+ startLine?: number;
16
+ /** Unrecognized tokens, in order. */
17
+ rest: string[];
18
+ }
19
+
20
+ /** Splits on whitespace while keeping quoted segments (title="my file.ts") intact. */
21
+ function tokenize(meta: string): string[] {
22
+ return meta.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
23
+ }
24
+
25
+ function parseLineList(list: string): number[] {
26
+ const lines = new Set<number>();
27
+
28
+ for (const part of list.split(',')) {
29
+ const range = part.trim();
30
+ if (!range) {
31
+ continue;
32
+ }
33
+
34
+ const [from, to = from] = range.split('-').map(Number);
35
+ if (!Number.isInteger(from) || from < 1 || !Number.isInteger(to)) {
36
+ continue;
37
+ }
38
+
39
+ for (let line = from; line <= to; line += 1) {
40
+ lines.add(line);
41
+ }
42
+ }
43
+
44
+ return [...lines].sort((a, b) => a - b);
45
+ }
46
+
47
+ function unquote(value: string): string {
48
+ return value.replace(/^(["'])(.*)\1$/, '$2');
49
+ }
50
+
51
+ export function parseCodeMeta(meta: string | undefined): CodeMeta {
52
+ const result: CodeMeta = { highlightLines: [], rest: [] };
53
+ const lines = new Set<number>();
54
+
55
+ for (const token of tokenize((meta || '').trim())) {
56
+ const highlight = token.match(/^\{([\d,\s-]+)\}$/);
57
+ const title = token.match(/^title=(.+)$/);
58
+ const numbered = token.match(/^showLineNumbers(?:=(\d+))?$/);
59
+
60
+ if (highlight) {
61
+ for (const line of parseLineList(highlight[1])) {
62
+ lines.add(line);
63
+ }
64
+ } else if (title) {
65
+ result.title = unquote(title[1]);
66
+ } else if (numbered) {
67
+ result.showLineNumbers = true;
68
+ if (numbered[1]) {
69
+ result.startLine = Number(numbered[1]);
70
+ }
71
+ } else if (token === 'hideLineNumbers') {
72
+ result.showLineNumbers = false;
73
+ } else {
74
+ result.rest.push(unquote(token));
75
+ }
76
+ }
77
+
78
+ result.highlightLines = [...lines].sort((a, b) => a - b);
79
+
80
+ // Back-compat: a bare label (```bash npm) is the title, which CodeGroup uses
81
+ // as the tab name.
82
+ if (!result.title && result.rest.length) {
83
+ result.title = result.rest.join(' ');
84
+ }
85
+
86
+ return result;
87
+ }
@@ -391,6 +391,7 @@ interface PendingPage {
391
391
  hidden?: boolean;
392
392
  icon?: string;
393
393
  tag?: string;
394
+ method?: string;
394
395
  }
395
396
 
396
397
  type PendingNode =
@@ -424,7 +425,7 @@ function addPage(
424
425
  pageRef: string,
425
426
  context: WalkContext,
426
427
  state: WalkState,
427
- extra: { label?: string; icon?: string; tag?: string; hidden?: boolean } = {},
428
+ extra: { label?: string; icon?: string; tag?: string; method?: string; hidden?: boolean } = {},
428
429
  ): number {
429
430
  const { fileSlug, slug } = normalizePageReference(pageRef);
430
431
  const order = state.order.value++;
@@ -440,6 +441,7 @@ function addPage(
440
441
  hidden: extra.hidden || context.hidden || undefined,
441
442
  icon: extra.icon,
442
443
  tag: extra.tag,
444
+ method: extra.method,
443
445
  });
444
446
 
445
447
  return order;
@@ -497,6 +499,7 @@ function collectPages(items: PageItem[], context: WalkContext, state: WalkState)
497
499
  label,
498
500
  icon: typeof item.icon === 'string' ? item.icon : undefined,
499
501
  tag: typeof item.tag === 'string' ? item.tag : undefined,
502
+ method: typeof item.method === 'string' ? item.method : undefined,
500
503
  hidden: item.hidden === true,
501
504
  }),
502
505
  });
@@ -0,0 +1,4 @@
1
+ // Build-time alias target. Shiso replaces this module with the project's generated operations.
2
+ import type { NormalizedOperation } from '@/lib/types';
3
+
4
+ export const OPENAPI_OPERATIONS: Record<string, NormalizedOperation> = {};
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Runtime helpers for OpenAPI reference pages. Operation data is produced at
3
+ * build time by scripts/generate-openapi.mjs and reaches the client through
4
+ * the aliased openapi.generated module.
5
+ */
6
+
7
+ import type { BadgeColor } from '@/components/docs/Badge';
8
+ import { OPENAPI_OPERATIONS } from '@/lib/openapi.generated';
9
+ import { createSlugger } from '@/lib/slug';
10
+ import type { NormalizedOperation, TocEntry } from '@/lib/types';
11
+
12
+ export const METHOD_COLORS: Record<string, BadgeColor> = {
13
+ GET: 'green',
14
+ POST: 'blue',
15
+ PUT: 'orange',
16
+ PATCH: 'purple',
17
+ DELETE: 'red',
18
+ };
19
+
20
+ export function methodColor(method?: string): BadgeColor {
21
+ return (method && METHOD_COLORS[method.toUpperCase()]) || 'gray';
22
+ }
23
+
24
+ export function statusColor(status: string): BadgeColor {
25
+ if (status.startsWith('2')) return 'green';
26
+ if (status.startsWith('3')) return 'blue';
27
+ if (status.startsWith('4') || status.startsWith('5')) return 'red';
28
+ return 'gray';
29
+ }
30
+
31
+ /** Looks up the operation bound by an `openapi:` frontmatter value. */
32
+ export function getOperation(key?: unknown): NormalizedOperation | undefined {
33
+ if (typeof key !== 'string' || !key.trim()) {
34
+ return undefined;
35
+ }
36
+
37
+ const [method, ...rest] = key.trim().split(/\s+/);
38
+ return OPENAPI_OPERATIONS[`${method.toUpperCase()} ${rest.join(' ')}`];
39
+ }
40
+
41
+ export function hasParameters(operation: NormalizedOperation): boolean {
42
+ const { query, path, header, cookie } = operation.parameters;
43
+ return (
44
+ query.length + path.length + header.length + cookie.length > 0 || operation.security.length > 0
45
+ );
46
+ }
47
+
48
+ /**
49
+ * Section headings for an operation, in render order. The single source of
50
+ * truth for section ids: the component, the table of contents, the content
51
+ * checker, and the search indexer all derive their anchors from these labels.
52
+ */
53
+ export function operationSections(operation: NormalizedOperation): TocEntry[] {
54
+ const slugger = createSlugger();
55
+ const names = [
56
+ hasParameters(operation) ? 'Parameters' : undefined,
57
+ operation.requestBody ? 'Request body' : undefined,
58
+ operation.responses.length ? 'Responses' : undefined,
59
+ operation.samples.length ? 'Code samples' : undefined,
60
+ ].filter((name): name is string => Boolean(name));
61
+
62
+ return names.map(name => ({ name, id: slugger.slug(name), size: 2 }));
63
+ }