@raystack/chronicle 0.1.0-canary.111b55a → 0.1.0-canary.1e5fdae

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 (87) hide show
  1. package/dist/cli/index.js +212 -833
  2. package/package.json +13 -9
  3. package/src/cli/commands/build.ts +30 -70
  4. package/src/cli/commands/dev.ts +24 -13
  5. package/src/cli/commands/init.ts +38 -123
  6. package/src/cli/commands/serve.ts +35 -50
  7. package/src/cli/commands/start.ts +20 -16
  8. package/src/cli/index.ts +14 -14
  9. package/src/cli/utils/config.ts +25 -26
  10. package/src/cli/utils/index.ts +3 -2
  11. package/src/cli/utils/resolve.ts +7 -3
  12. package/src/cli/utils/scaffold.ts +14 -16
  13. package/src/components/mdx/details.module.css +0 -2
  14. package/src/components/mdx/image.tsx +5 -20
  15. package/src/components/mdx/index.tsx +18 -4
  16. package/src/components/mdx/link.tsx +24 -20
  17. package/src/components/ui/breadcrumbs.tsx +8 -42
  18. package/src/components/ui/footer.tsx +2 -3
  19. package/src/components/ui/search.tsx +116 -71
  20. package/src/lib/api-routes.ts +6 -8
  21. package/src/lib/config.ts +31 -29
  22. package/src/lib/get-llm-text.ts +10 -0
  23. package/src/lib/head.tsx +26 -22
  24. package/src/lib/openapi.ts +8 -8
  25. package/src/lib/page-context.tsx +74 -58
  26. package/src/lib/source.ts +136 -114
  27. package/src/pages/ApiLayout.tsx +22 -18
  28. package/src/pages/ApiPage.tsx +32 -27
  29. package/src/pages/DocsLayout.tsx +7 -7
  30. package/src/pages/DocsPage.tsx +11 -11
  31. package/src/pages/NotFound.tsx +11 -4
  32. package/src/server/App.tsx +35 -27
  33. package/src/server/api/apis-proxy.ts +69 -0
  34. package/src/server/api/health.ts +5 -0
  35. package/src/server/api/page/[...slug].ts +17 -0
  36. package/src/server/api/search.ts +170 -0
  37. package/src/server/api/specs.ts +9 -0
  38. package/src/server/build-search-index.ts +78 -68
  39. package/src/server/entry-client.tsx +67 -55
  40. package/src/server/entry-server.tsx +100 -35
  41. package/src/server/routes/llms.txt.ts +61 -0
  42. package/src/server/routes/og.tsx +75 -0
  43. package/src/server/routes/robots.txt.ts +11 -0
  44. package/src/server/routes/sitemap.xml.ts +40 -0
  45. package/src/server/utils/safe-path.ts +17 -0
  46. package/src/server/vite-config.ts +87 -47
  47. package/src/themes/default/Layout.tsx +78 -47
  48. package/src/themes/default/Page.module.css +0 -16
  49. package/src/themes/default/Page.tsx +9 -11
  50. package/src/themes/default/Toc.tsx +25 -39
  51. package/src/themes/default/index.ts +7 -9
  52. package/src/themes/paper/ChapterNav.tsx +63 -43
  53. package/src/themes/paper/Layout.module.css +1 -1
  54. package/src/themes/paper/Layout.tsx +24 -12
  55. package/src/themes/paper/Page.module.css +16 -4
  56. package/src/themes/paper/Page.tsx +56 -62
  57. package/src/themes/paper/ReadingProgress.tsx +160 -139
  58. package/src/themes/paper/index.ts +5 -5
  59. package/src/themes/registry.ts +7 -7
  60. package/src/types/content.ts +5 -21
  61. package/src/types/globals.d.ts +3 -0
  62. package/src/types/theme.ts +4 -3
  63. package/src/cli/__tests__/config.test.ts +0 -25
  64. package/src/cli/__tests__/scaffold.test.ts +0 -10
  65. package/src/pages/__tests__/head.test.tsx +0 -57
  66. package/src/server/__tests__/entry-server.test.tsx +0 -35
  67. package/src/server/__tests__/handlers.test.ts +0 -77
  68. package/src/server/__tests__/og.test.ts +0 -23
  69. package/src/server/__tests__/router.test.ts +0 -72
  70. package/src/server/__tests__/vite-config.test.ts +0 -25
  71. package/src/server/adapters/vercel.ts +0 -133
  72. package/src/server/dev.ts +0 -156
  73. package/src/server/entry-prod.ts +0 -97
  74. package/src/server/entry-vercel.ts +0 -28
  75. package/src/server/handlers/apis-proxy.ts +0 -52
  76. package/src/server/handlers/health.ts +0 -3
  77. package/src/server/handlers/llms.ts +0 -58
  78. package/src/server/handlers/og.ts +0 -87
  79. package/src/server/handlers/robots.ts +0 -11
  80. package/src/server/handlers/search.ts +0 -172
  81. package/src/server/handlers/sitemap.ts +0 -39
  82. package/src/server/handlers/specs.ts +0 -9
  83. package/src/server/index.html +0 -12
  84. package/src/server/prod.ts +0 -18
  85. package/src/server/request-handler.ts +0 -63
  86. package/src/server/router.ts +0 -42
  87. package/src/themes/default/font.ts +0 -4
@@ -1,6 +1,10 @@
1
- import path from 'path'
2
- import { fileURLToPath } from 'url'
1
+ import path from 'path';
2
+ import { fileURLToPath } from 'url';
3
3
 
4
4
  // After bundling: dist/cli/index.js → ../.. = package root
5
5
  // After install: node_modules/@raystack/chronicle/dist/cli/index.js → ../.. = package root
6
- export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
6
+ export const PACKAGE_ROOT = path.resolve(
7
+ path.dirname(fileURLToPath(import.meta.url)),
8
+ '..',
9
+ '..'
10
+ );
@@ -1,20 +1,18 @@
1
- import fs from 'fs'
2
- import path from 'path'
3
- import { PACKAGE_ROOT } from './resolve'
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { PACKAGE_ROOT } from './resolve';
4
4
 
5
- export function detectPackageManager(): string {
6
- if (process.env.npm_config_user_agent) {
7
- return process.env.npm_config_user_agent.split('/')[0]
5
+ export async function linkContent(contentDir: string): Promise<void> {
6
+ const linkPath = path.join(PACKAGE_ROOT, '.content');
7
+ const target = path.resolve(contentDir);
8
+
9
+ try {
10
+ const existing = await fs.readlink(linkPath);
11
+ if (existing === target) return;
12
+ await fs.unlink(linkPath);
13
+ } catch {
14
+ // link doesn't exist
8
15
  }
9
- const cwd = process.cwd()
10
- if (fs.existsSync(path.join(cwd, 'bun.lock')) || fs.existsSync(path.join(cwd, 'bun.lockb'))) return 'bun'
11
- if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) return 'pnpm'
12
- if (fs.existsSync(path.join(cwd, 'yarn.lock'))) return 'yarn'
13
- return 'npm'
14
- }
15
16
 
16
- export function getChronicleVersion(): string {
17
- const pkgPath = path.join(PACKAGE_ROOT, 'package.json')
18
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
19
- return pkg.version
17
+ await fs.symlink(target, linkPath);
20
18
  }
@@ -1,6 +1,4 @@
1
1
  .details {
2
- border: 1px solid var(--rs-color-border-base-primary);
3
- border-radius: var(--rs-radius-2);
4
2
  margin: var(--rs-space-5) 0;
5
3
  }
6
4
 
@@ -1,24 +1,9 @@
1
- 'use client'
1
+ import type { ComponentProps } from 'react';
2
2
 
3
- import type { ComponentProps } from 'react'
3
+ type ImageProps = ComponentProps<'img'>;
4
4
 
5
- type ImageProps = Omit<ComponentProps<'img'>, 'src'> & {
6
- src?: string
7
- width?: number | string
8
- height?: number | string
9
- }
10
-
11
- export function Image({ src, alt, width, height, ...props }: ImageProps) {
12
- if (!src || typeof src !== 'string') return null
5
+ export function Image({ src, alt, ...props }: ImageProps) {
6
+ if (!src) return null;
13
7
 
14
- return (
15
- <img
16
- src={src}
17
- alt={alt ?? ''}
18
- width={width}
19
- height={height}
20
- loading="lazy"
21
- {...props}
22
- />
23
- )
8
+ return <img src={src} alt={alt ?? ''} loading='lazy' {...props} />;
24
9
  }
@@ -1,6 +1,6 @@
1
1
  import type { MDXComponents } from 'mdx/types'
2
2
  import { Image } from './image'
3
- import { MdxLink } from './link'
3
+ import { Link } from './link'
4
4
  import { MdxTable, MdxThead, MdxTbody, MdxTr, MdxTh, MdxTd } from './table'
5
5
  import { MdxPre, MdxCode } from './code'
6
6
  import { MdxDetails, MdxSummary } from './details'
@@ -8,11 +8,25 @@ import { Mermaid } from './mermaid'
8
8
  import { MdxParagraph } from './paragraph'
9
9
  import { CalloutContainer, CalloutTitle, CalloutDescription, MdxBlockquote } from '@/components/common/callout'
10
10
  import { Tabs } from '@raystack/apsara'
11
+ import { type ComponentProps, useEffect, useState } from 'react'
12
+
13
+ function ClientOnly({ children }: { children: React.ReactNode }) {
14
+ const [mounted, setMounted] = useState(false)
15
+ useEffect(() => setMounted(true), [])
16
+ return mounted ? <>{children}</> : null
17
+ }
18
+
19
+ function MdxTabs(props: ComponentProps<typeof Tabs>) {
20
+ return <ClientOnly><Tabs {...props} /></ClientOnly>
21
+ }
22
+ MdxTabs.List = Tabs.List
23
+ MdxTabs.Trigger = Tabs.Trigger
24
+ MdxTabs.Content = Tabs.Content
11
25
 
12
26
  export const mdxComponents: MDXComponents = {
13
27
  p: MdxParagraph,
14
28
  img: Image,
15
- a: MdxLink,
29
+ a: Link,
16
30
  table: MdxTable,
17
31
  thead: MdxThead,
18
32
  tbody: MdxTbody,
@@ -27,9 +41,9 @@ export const mdxComponents: MDXComponents = {
27
41
  Callout: CalloutContainer,
28
42
  CalloutTitle,
29
43
  CalloutDescription,
30
- Tabs,
44
+ Tabs: MdxTabs,
31
45
  Mermaid,
32
46
  }
33
47
 
34
48
  export { Image } from './image'
35
- export { MdxLink } from './link'
49
+ export { Link } from './link'
@@ -1,37 +1,41 @@
1
- 'use client'
1
+ import { Link as ApsaraLink } from '@raystack/apsara';
2
+ import type { ComponentProps } from 'react';
3
+ import { Link as RouterLink } from 'react-router';
2
4
 
3
- import { Link } from 'react-router-dom'
4
- import type { ComponentProps } from 'react'
5
+ type LinkProps = ComponentProps<'a'>;
5
6
 
6
- type LinkProps = ComponentProps<'a'>
7
-
8
- export function MdxLink({ href, children, ...props }: LinkProps) {
7
+ export function Link({ href, children, ...props }: LinkProps) {
9
8
  if (!href) {
10
- return <span {...props}>{children}</span>
9
+ return <span {...props}>{children}</span>;
11
10
  }
12
11
 
13
- const isExternal = href.startsWith('http://') || href.startsWith('https://')
14
- const isAnchor = href.startsWith('#')
12
+ const isExternal = href.startsWith('http://') || href.startsWith('https://');
13
+ const isAnchor = href.startsWith('#');
15
14
 
16
- if (isExternal) {
15
+ if (isAnchor) {
17
16
  return (
18
- <a href={href} target="_blank" rel="noopener noreferrer" {...props}>
17
+ <ApsaraLink href={href} {...props}>
19
18
  {children}
20
- </a>
21
- )
19
+ </ApsaraLink>
20
+ );
22
21
  }
23
22
 
24
- if (isAnchor) {
23
+ if (isExternal) {
25
24
  return (
26
- <a href={href} {...props}>
25
+ <ApsaraLink
26
+ href={href}
27
+ target='_blank'
28
+ rel='noopener noreferrer'
29
+ {...props}
30
+ >
27
31
  {children}
28
- </a>
29
- )
32
+ </ApsaraLink>
33
+ );
30
34
  }
31
35
 
32
36
  return (
33
- <Link to={href} className={props.className}>
37
+ <RouterLink to={href} className={props.className}>
34
38
  {children}
35
- </Link>
36
- )
39
+ </RouterLink>
40
+ );
37
41
  }
@@ -1,53 +1,19 @@
1
1
  'use client'
2
2
 
3
3
  import { Breadcrumb } from '@raystack/apsara'
4
- import type { PageTree, PageTreeItem } from '@/types'
4
+ import { getBreadcrumbItems } from 'fumadocs-core/breadcrumb'
5
+ import type { Root } from 'fumadocs-core/page-tree'
5
6
 
6
7
  interface BreadcrumbsProps {
7
8
  slug: string[]
8
- tree: PageTree
9
- }
10
-
11
- function findInTree(items: PageTreeItem[], targetPath: string): PageTreeItem | undefined {
12
- for (const item of items) {
13
- const itemUrl = item.url || `/${item.name.toLowerCase().replace(/\s+/g, '-')}`
14
- if (itemUrl === targetPath || itemUrl === `/${targetPath}`) {
15
- return item
16
- }
17
- if (item.children) {
18
- const found = findInTree(item.children, targetPath)
19
- if (found) return found
20
- }
21
- }
22
- return undefined
23
- }
24
-
25
- function getFirstPageUrl(item: PageTreeItem): string | undefined {
26
- if (item.type === 'page' && item.url) {
27
- return item.url
28
- }
29
- if (item.children) {
30
- for (const child of item.children) {
31
- const url = getFirstPageUrl(child)
32
- if (url) return url
33
- }
34
- }
35
- return undefined
9
+ tree: Root
36
10
  }
37
11
 
38
12
  export function Breadcrumbs({ slug, tree }: BreadcrumbsProps) {
39
- const items: { label: string; href: string }[] = []
13
+ const url = slug.length === 0 ? '/' : `/${slug.join('/')}`
14
+ const items = getBreadcrumbItems(url, tree, { includePage: true })
40
15
 
41
- for (let i = 0; i < slug.length; i++) {
42
- const currentPath = `/${slug.slice(0, i + 1).join('/')}`
43
- const node = findInTree(tree.children, currentPath)
44
- const href = node?.url || (node && getFirstPageUrl(node)) || currentPath
45
- const label = node?.name ?? slug[i]
46
- items.push({
47
- label: label.charAt(0).toUpperCase() + label.slice(1),
48
- href,
49
- })
50
- }
16
+ if (items.length === 0) return null
51
17
 
52
18
  return (
53
19
  <Breadcrumb size="small">
@@ -55,10 +21,10 @@ export function Breadcrumbs({ slug, tree }: BreadcrumbsProps) {
55
21
  const breadcrumbItem = (
56
22
  <Breadcrumb.Item
57
23
  key={`item-${index}`}
58
- href={item.href}
24
+ href={item.url}
59
25
  current={index === items.length - 1}
60
26
  >
61
- {item.label}
27
+ {item.name}
62
28
  </Breadcrumb.Item>
63
29
  )
64
30
  if (index === 0) return [breadcrumbItem]
@@ -1,5 +1,4 @@
1
- import { Link } from "react-router-dom";
2
- import { Flex, Text } from "@raystack/apsara";
1
+ import { Flex, Link, Text } from "@raystack/apsara";
3
2
  import type { FooterConfig } from "@/types";
4
3
  import styles from "./footer.module.css";
5
4
 
@@ -19,7 +18,7 @@ export function Footer({ config }: FooterProps) {
19
18
  {config?.links && config.links.length > 0 && (
20
19
  <Flex gap="medium" className={styles.links}>
21
20
  {config.links.map((link) => (
22
- <Link key={link.href} to={link.href} className={styles.link}>
21
+ <Link key={link.href} href={link.href} className={styles.link}>
23
22
  {link.label}
24
23
  </Link>
25
24
  ))}
@@ -1,50 +1,19 @@
1
- "use client";
2
-
3
- import { useState, useEffect, useCallback, useRef } from "react";
4
- import { useNavigate } from "react-router-dom";
5
- import { Button, Command, Dialog, Text } from "@raystack/apsara";
6
- import { cx } from "class-variance-authority";
7
- import { DocumentIcon, HashtagIcon } from "@heroicons/react/24/outline";
8
- import { MethodBadge } from "@/components/api/method-badge";
9
- import styles from "./search.module.css";
10
-
11
- interface SearchResult {
12
- id: string;
13
- url: string;
14
- type: "page" | "api";
15
- content: string;
16
- }
17
-
18
- function useSearch(query: string) {
19
- const [results, setResults] = useState<SearchResult[]>([]);
20
- const [isLoading, setIsLoading] = useState(false);
21
- const timerRef = useRef<ReturnType<typeof setTimeout>>();
22
-
23
- useEffect(() => {
24
- clearTimeout(timerRef.current);
25
- timerRef.current = setTimeout(async () => {
26
- setIsLoading(true);
27
- try {
28
- const params = new URLSearchParams();
29
- if (query) params.set("query", query);
30
- const res = await fetch(`/api/search?${params}`);
31
- setResults(await res.json());
32
- } catch {
33
- setResults([]);
34
- }
35
- setIsLoading(false);
36
- }, 100);
37
- return () => clearTimeout(timerRef.current);
38
- }, [query]);
39
-
40
- return { results, isLoading };
41
- }
1
+ import { DocumentIcon, HashtagIcon } from '@heroicons/react/24/outline';
2
+ import { Button, Command, Dialog, Text } from '@raystack/apsara';
3
+ import { cx } from 'class-variance-authority';
4
+ import type { SortedResult } from 'fumadocs-core/search';
5
+ import { useDocsSearch } from 'fumadocs-core/search/client';
6
+ import { useCallback, useEffect, useState } from 'react';
7
+ import { useNavigate } from 'react-router';
8
+ import { MethodBadge } from '@/components/api/method-badge';
9
+ import styles from './search.module.css';
42
10
 
43
11
  function SearchShortcutKey({ className }: { className?: string }) {
44
- const [key, setKey] = useState("\u2318");
12
+ const [key, setKey] = useState('⌘');
45
13
 
46
14
  useEffect(() => {
47
- setKey(navigator.platform?.startsWith("Mac") ? "\u2318" : "Ctrl");
15
+ const isMac = navigator.platform?.toUpperCase().includes('MAC');
16
+ setKey(isMac ? '⌘' : 'Ctrl');
48
17
  }, []);
49
18
 
50
19
  return (
@@ -61,35 +30,44 @@ interface SearchProps {
61
30
  export function Search({ className }: SearchProps) {
62
31
  const [open, setOpen] = useState(false);
63
32
  const navigate = useNavigate();
64
- const [search, setSearch] = useState("");
65
- const { results, isLoading } = useSearch(search);
33
+
34
+ const { search, setSearch, query } = useDocsSearch({
35
+ type: 'fetch',
36
+ api: '/api/search',
37
+ delayMs: 100,
38
+ allowEmpty: true
39
+ });
66
40
 
67
41
  const onSelect = useCallback(
68
42
  (url: string) => {
69
43
  setOpen(false);
70
44
  navigate(url);
71
45
  },
72
- [navigate],
46
+ [navigate]
73
47
  );
74
48
 
75
49
  useEffect(() => {
76
50
  const down = (e: KeyboardEvent) => {
77
- if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
51
+ if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
78
52
  e.preventDefault();
79
- setOpen((open) => !open);
53
+ setOpen(open => !open);
80
54
  }
81
55
  };
82
56
 
83
- document.addEventListener("keydown", down);
84
- return () => document.removeEventListener("keydown", down);
57
+ document.addEventListener('keydown', down);
58
+ return () => document.removeEventListener('keydown', down);
85
59
  }, []);
86
60
 
61
+ const results = deduplicateByUrl(
62
+ query.data === 'empty' ? [] : (query.data ?? [])
63
+ );
64
+
87
65
  return (
88
66
  <>
89
67
  <Button
90
- variant="outline"
91
- color="neutral"
92
- size="small"
68
+ variant='outline'
69
+ color='neutral'
70
+ size='small'
93
71
  onClick={() => setOpen(true)}
94
72
  className={cx(styles.trigger, className)}
95
73
  trailingIcon={<SearchShortcutKey className={styles.kbd} />}
@@ -104,24 +82,24 @@ export function Search({ className }: SearchProps) {
104
82
  </Dialog.Title>
105
83
  <Command loop>
106
84
  <Command.Input
107
- placeholder="Search"
85
+ placeholder='Search'
108
86
  value={search}
109
87
  onValueChange={setSearch}
110
88
  className={styles.input}
111
89
  />
112
90
 
113
91
  <Command.List className={styles.list}>
114
- {isLoading && <Command.Empty>Loading...</Command.Empty>}
115
- {!isLoading &&
92
+ {query.isLoading && <Command.Empty>Loading...</Command.Empty>}
93
+ {!query.isLoading &&
116
94
  search.length > 0 &&
117
95
  results.length === 0 && (
118
96
  <Command.Empty>No results found.</Command.Empty>
119
97
  )}
120
- {!isLoading &&
98
+ {!query.isLoading &&
121
99
  search.length === 0 &&
122
100
  results.length > 0 && (
123
- <Command.Group heading="Suggestions">
124
- {results.slice(0, 8).map((result) => (
101
+ <Command.Group heading='Suggestions'>
102
+ {results.slice(0, 8).map((result: SortedResult) => (
125
103
  <Command.Item
126
104
  key={result.id}
127
105
  value={result.id}
@@ -131,7 +109,9 @@ export function Search({ className }: SearchProps) {
131
109
  <div className={styles.itemContent}>
132
110
  {getResultIcon(result)}
133
111
  <Text className={styles.pageText}>
134
- {result.content}
112
+ <HighlightedText
113
+ html={stripMethod(result.content)}
114
+ />
135
115
  </Text>
136
116
  </div>
137
117
  </Command.Item>
@@ -139,7 +119,7 @@ export function Search({ className }: SearchProps) {
139
119
  </Command.Group>
140
120
  )}
141
121
  {search.length > 0 &&
142
- results.map((result) => (
122
+ results.map((result: SortedResult) => (
143
123
  <Command.Item
144
124
  key={result.id}
145
125
  value={result.id}
@@ -148,9 +128,27 @@ export function Search({ className }: SearchProps) {
148
128
  >
149
129
  <div className={styles.itemContent}>
150
130
  {getResultIcon(result)}
151
- <Text className={styles.pageText}>
152
- {result.content}
153
- </Text>
131
+ <div className={styles.resultText}>
132
+ {result.type === 'heading' ? (
133
+ <>
134
+ <Text className={styles.headingText}>
135
+ <HighlightedText
136
+ html={stripMethod(result.content)}
137
+ />
138
+ </Text>
139
+ <Text className={styles.separator}>-</Text>
140
+ <Text className={styles.pageText}>
141
+ {getPageTitle(result.url)}
142
+ </Text>
143
+ </>
144
+ ) : (
145
+ <Text className={styles.pageText}>
146
+ <HighlightedText
147
+ html={stripMethod(result.content)}
148
+ />
149
+ </Text>
150
+ )}
151
+ </div>
154
152
  </div>
155
153
  </Command.Item>
156
154
  ))}
@@ -162,12 +160,59 @@ export function Search({ className }: SearchProps) {
162
160
  );
163
161
  }
164
162
 
165
- function getResultIcon(result: SearchResult): React.ReactNode {
166
- if (result.type === "api") {
167
- const method = result.content.split(" ")[0];
168
- return ["GET", "POST", "PUT", "DELETE", "PATCH"].includes(method)
169
- ? <MethodBadge method={method} size="micro" />
170
- : null;
163
+ function deduplicateByUrl(results: SortedResult[]): SortedResult[] {
164
+ const seen = new Set<string>();
165
+ return results.filter(r => {
166
+ const base = r.url.split('#')[0];
167
+ if (seen.has(base)) return false;
168
+ seen.add(base);
169
+ return true;
170
+ });
171
+ }
172
+
173
+ const API_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']);
174
+
175
+ function extractMethod(content: string): string | null {
176
+ const first = content.split(' ')[0];
177
+ return API_METHODS.has(first) ? first : null;
178
+ }
179
+
180
+ function stripMethod(content: string): string {
181
+ const first = content.split(' ')[0];
182
+ return API_METHODS.has(first) ? content.slice(first.length + 1) : content;
183
+ }
184
+
185
+ function HighlightedText({
186
+ html,
187
+ className
188
+ }: {
189
+ html: string;
190
+ className?: string;
191
+ }) {
192
+ return (
193
+ <span className={className} dangerouslySetInnerHTML={{ __html: html }} />
194
+ );
195
+ }
196
+
197
+ function getResultIcon(result: SortedResult): React.ReactNode {
198
+ if (!result.url.startsWith('/apis/')) {
199
+ return result.type === 'page' ? (
200
+ <DocumentIcon className={styles.icon} />
201
+ ) : (
202
+ <HashtagIcon className={styles.icon} />
203
+ );
171
204
  }
172
- return <DocumentIcon className={styles.icon} />;
205
+ const method = extractMethod(result.content);
206
+ return method ? <MethodBadge method={method} size='micro' /> : null;
207
+ }
208
+
209
+ function getPageTitle(url: string): string {
210
+ const path = url.split('#')[0];
211
+ const segments = path.split('/').filter(Boolean);
212
+ const lastSegment = segments[segments.length - 1];
213
+ if (!lastSegment) return 'Home';
214
+ return lastSegment
215
+ .split('-')
216
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
217
+ .join(' ');
173
218
  }
@@ -1,6 +1,6 @@
1
1
  import type { OpenAPIV3 } from 'openapi-types'
2
2
  import slugify from 'slugify'
3
- import type { PageTree, PageTreeItem } from '@/types/content'
3
+ import type { Root, Node, Item, Folder } from 'fumadocs-core/page-tree'
4
4
  import type { ApiSpec } from './openapi'
5
5
 
6
6
  export function getSpecSlug(spec: ApiSpec): string {
@@ -56,16 +56,15 @@ export function findApiOperation(specs: ApiSpec[], slug: string[]): ApiRouteMatc
56
56
  return null
57
57
  }
58
58
 
59
- export function buildApiPageTree(specs: ApiSpec[]): PageTree {
60
- const children: PageTreeItem[] = []
59
+ export function buildApiPageTree(specs: ApiSpec[]): Root {
60
+ const children: Node[] = []
61
61
 
62
62
  for (const spec of specs) {
63
63
  const specSlug = getSpecSlug(spec)
64
64
  const paths = spec.document.paths ?? {}
65
65
  const tags = spec.document.tags ?? []
66
66
 
67
- // Group operations by tag (case-insensitive to avoid duplicates)
68
- const opsByTag = new Map<string, PageTreeItem[]>()
67
+ const opsByTag = new Map<string, Item[]>()
69
68
  const tagDisplayName = new Map<string, string>()
70
69
 
71
70
  for (const [, pathItem] of Object.entries(paths)) {
@@ -90,7 +89,6 @@ export function buildApiPageTree(specs: ApiSpec[]): PageTree {
90
89
  }
91
90
  }
92
91
 
93
- // Use doc.tags display names where available
94
92
  for (const t of tags) {
95
93
  const key = t.name.toLowerCase()
96
94
  if (opsByTag.has(key)) {
@@ -98,7 +96,7 @@ export function buildApiPageTree(specs: ApiSpec[]): PageTree {
98
96
  }
99
97
  }
100
98
 
101
- const tagFolders: PageTreeItem[] = Array.from(opsByTag.entries()).map(([key, ops]) => ({
99
+ const tagFolders: Folder[] = Array.from(opsByTag.entries()).map(([key, ops]) => ({
102
100
  type: 'folder' as const,
103
101
  name: tagDisplayName.get(key) ?? key,
104
102
  icon: 'rectangle-stack',
@@ -110,7 +108,7 @@ export function buildApiPageTree(specs: ApiSpec[]): PageTree {
110
108
  type: 'folder',
111
109
  name: spec.name,
112
110
  children: tagFolders,
113
- })
111
+ } as Folder)
114
112
  } else {
115
113
  children.push(...tagFolders)
116
114
  }