@raystack/chronicle 0.1.0-canary.1f5227c
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.
- package/bin/chronicle.js +2 -0
- package/dist/cli/index.js +543 -0
- package/package.json +68 -0
- package/src/cli/__tests__/config.test.ts +25 -0
- package/src/cli/__tests__/scaffold.test.ts +10 -0
- package/src/cli/commands/build.ts +52 -0
- package/src/cli/commands/dev.ts +21 -0
- package/src/cli/commands/init.ts +154 -0
- package/src/cli/commands/serve.ts +55 -0
- package/src/cli/commands/start.ts +24 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/utils/config.ts +43 -0
- package/src/cli/utils/index.ts +2 -0
- package/src/cli/utils/resolve.ts +6 -0
- package/src/cli/utils/scaffold.ts +20 -0
- package/src/components/api/code-snippets.module.css +7 -0
- package/src/components/api/code-snippets.tsx +76 -0
- package/src/components/api/endpoint-page.module.css +58 -0
- package/src/components/api/endpoint-page.tsx +283 -0
- package/src/components/api/field-row.module.css +126 -0
- package/src/components/api/field-row.tsx +204 -0
- package/src/components/api/field-section.module.css +24 -0
- package/src/components/api/field-section.tsx +100 -0
- package/src/components/api/index.ts +8 -0
- package/src/components/api/json-editor.module.css +9 -0
- package/src/components/api/json-editor.tsx +61 -0
- package/src/components/api/key-value-editor.module.css +13 -0
- package/src/components/api/key-value-editor.tsx +62 -0
- package/src/components/api/method-badge.module.css +4 -0
- package/src/components/api/method-badge.tsx +29 -0
- package/src/components/api/response-panel.module.css +8 -0
- package/src/components/api/response-panel.tsx +44 -0
- package/src/components/common/breadcrumb.tsx +3 -0
- package/src/components/common/button.tsx +3 -0
- package/src/components/common/callout.module.css +7 -0
- package/src/components/common/callout.tsx +27 -0
- package/src/components/common/code-block.tsx +3 -0
- package/src/components/common/dialog.tsx +3 -0
- package/src/components/common/index.ts +10 -0
- package/src/components/common/input-field.tsx +3 -0
- package/src/components/common/sidebar.tsx +3 -0
- package/src/components/common/switch.tsx +3 -0
- package/src/components/common/table.tsx +3 -0
- package/src/components/common/tabs.tsx +3 -0
- package/src/components/mdx/code.module.css +42 -0
- package/src/components/mdx/code.tsx +36 -0
- package/src/components/mdx/details.module.css +14 -0
- package/src/components/mdx/details.tsx +17 -0
- package/src/components/mdx/image.tsx +24 -0
- package/src/components/mdx/index.tsx +35 -0
- package/src/components/mdx/link.tsx +37 -0
- package/src/components/mdx/mermaid.module.css +9 -0
- package/src/components/mdx/mermaid.tsx +37 -0
- package/src/components/mdx/paragraph.module.css +8 -0
- package/src/components/mdx/paragraph.tsx +19 -0
- package/src/components/mdx/table.tsx +40 -0
- package/src/components/ui/breadcrumbs.tsx +72 -0
- package/src/components/ui/client-theme-switcher.tsx +18 -0
- package/src/components/ui/footer.module.css +27 -0
- package/src/components/ui/footer.tsx +31 -0
- package/src/components/ui/search.module.css +111 -0
- package/src/components/ui/search.tsx +174 -0
- package/src/lib/api-routes.ts +120 -0
- package/src/lib/config.ts +56 -0
- package/src/lib/head.tsx +45 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/openapi.ts +188 -0
- package/src/lib/page-context.tsx +95 -0
- package/src/lib/remark-unused-directives.ts +30 -0
- package/src/lib/schema.ts +99 -0
- package/src/lib/snippet-generators.ts +87 -0
- package/src/lib/source.ts +138 -0
- package/src/pages/ApiLayout.module.css +22 -0
- package/src/pages/ApiLayout.tsx +29 -0
- package/src/pages/ApiPage.tsx +68 -0
- package/src/pages/DocsLayout.tsx +18 -0
- package/src/pages/DocsPage.tsx +43 -0
- package/src/pages/NotFound.tsx +10 -0
- package/src/pages/__tests__/head.test.tsx +57 -0
- package/src/server/App.tsx +59 -0
- package/src/server/__tests__/entry-server.test.tsx +35 -0
- package/src/server/__tests__/handlers.test.ts +77 -0
- package/src/server/__tests__/og.test.ts +23 -0
- package/src/server/__tests__/router.test.ts +72 -0
- package/src/server/__tests__/vite-config.test.ts +25 -0
- package/src/server/dev.ts +156 -0
- package/src/server/entry-client.tsx +74 -0
- package/src/server/entry-prod.ts +127 -0
- package/src/server/entry-server.tsx +35 -0
- package/src/server/handlers/apis-proxy.ts +52 -0
- package/src/server/handlers/health.ts +3 -0
- package/src/server/handlers/llms.ts +58 -0
- package/src/server/handlers/og.ts +87 -0
- package/src/server/handlers/robots.ts +11 -0
- package/src/server/handlers/search.ts +140 -0
- package/src/server/handlers/sitemap.ts +39 -0
- package/src/server/handlers/specs.ts +9 -0
- package/src/server/index.html +12 -0
- package/src/server/prod.ts +18 -0
- package/src/server/router.ts +42 -0
- package/src/server/vite-config.ts +71 -0
- package/src/themes/default/Layout.module.css +81 -0
- package/src/themes/default/Layout.tsx +132 -0
- package/src/themes/default/Page.module.css +102 -0
- package/src/themes/default/Page.tsx +21 -0
- package/src/themes/default/Toc.module.css +48 -0
- package/src/themes/default/Toc.tsx +66 -0
- package/src/themes/default/font.ts +4 -0
- package/src/themes/default/index.ts +13 -0
- package/src/themes/paper/ChapterNav.module.css +71 -0
- package/src/themes/paper/ChapterNav.tsx +95 -0
- package/src/themes/paper/Layout.module.css +33 -0
- package/src/themes/paper/Layout.tsx +25 -0
- package/src/themes/paper/Page.module.css +174 -0
- package/src/themes/paper/Page.tsx +106 -0
- package/src/themes/paper/ReadingProgress.module.css +132 -0
- package/src/themes/paper/ReadingProgress.tsx +294 -0
- package/src/themes/paper/index.ts +8 -0
- package/src/themes/registry.ts +14 -0
- package/src/types/config.ts +80 -0
- package/src/types/content.ts +36 -0
- package/src/types/index.ts +3 -0
- package/src/types/theme.ts +22 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useState } from 'react'
|
|
4
|
+
import styles from './mermaid.module.css'
|
|
5
|
+
|
|
6
|
+
interface MermaidProps {
|
|
7
|
+
chart: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Mermaid({ chart }: MermaidProps) {
|
|
11
|
+
const mermaidId = useId().replace(/:/g, '-')
|
|
12
|
+
const [svg, setSvg] = useState<string>('')
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
let cancelled = false
|
|
16
|
+
|
|
17
|
+
async function render() {
|
|
18
|
+
const { default: mermaid } = await import('mermaid')
|
|
19
|
+
mermaid.initialize({ startOnLoad: false, theme: 'default' })
|
|
20
|
+
const { svg: rendered } = await mermaid.render(
|
|
21
|
+
mermaidId,
|
|
22
|
+
chart
|
|
23
|
+
)
|
|
24
|
+
if (!cancelled) setSvg(rendered)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
render()
|
|
28
|
+
return () => { cancelled = true }
|
|
29
|
+
}, [chart])
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
className={styles.mermaid}
|
|
34
|
+
dangerouslySetInnerHTML={{ __html: svg }}
|
|
35
|
+
/>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Children, isValidElement, type ComponentProps } from 'react'
|
|
2
|
+
import styles from './paragraph.module.css'
|
|
3
|
+
|
|
4
|
+
const BLOCK_ELEMENTS = new Set(['summary', 'details', 'div', 'table', 'ul', 'ol'])
|
|
5
|
+
|
|
6
|
+
function hasBlockChild(children: React.ReactNode): boolean {
|
|
7
|
+
return Children.toArray(children).some(
|
|
8
|
+
(child) => isValidElement(child) && typeof child.type === 'string' && BLOCK_ELEMENTS.has(child.type)
|
|
9
|
+
)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function MdxParagraph({ children, className, ...props }: ComponentProps<'p'>) {
|
|
13
|
+
const Tag = hasBlockChild(children) ? 'div' : 'p'
|
|
14
|
+
return (
|
|
15
|
+
<Tag className={`${styles.paragraph} ${className ?? ''}`} {...props}>
|
|
16
|
+
{children}
|
|
17
|
+
</Tag>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Table } from '@raystack/apsara'
|
|
4
|
+
import type { ComponentProps, ReactNode } from 'react'
|
|
5
|
+
|
|
6
|
+
type TableProps = ComponentProps<'table'>
|
|
7
|
+
|
|
8
|
+
export function MdxTable({ children, ...props }: TableProps) {
|
|
9
|
+
return <Table {...props}>{children}</Table>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type TheadProps = ComponentProps<'thead'>
|
|
13
|
+
|
|
14
|
+
export function MdxThead({ children, ...props }: TheadProps) {
|
|
15
|
+
return <Table.Header {...props}>{children}</Table.Header>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type TbodyProps = ComponentProps<'tbody'>
|
|
19
|
+
|
|
20
|
+
export function MdxTbody({ children, ...props }: TbodyProps) {
|
|
21
|
+
return <Table.Body {...props}>{children}</Table.Body>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type TrProps = ComponentProps<'tr'>
|
|
25
|
+
|
|
26
|
+
export function MdxTr({ children, ...props }: TrProps) {
|
|
27
|
+
return <Table.Row {...props}>{children}</Table.Row>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type ThProps = ComponentProps<'th'>
|
|
31
|
+
|
|
32
|
+
export function MdxTh({ children, ...props }: ThProps) {
|
|
33
|
+
return <Table.Head {...props}>{children}</Table.Head>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type TdProps = ComponentProps<'td'>
|
|
37
|
+
|
|
38
|
+
export function MdxTd({ children, ...props }: TdProps) {
|
|
39
|
+
return <Table.Cell {...props}>{children}</Table.Cell>
|
|
40
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Breadcrumb } from '@raystack/apsara'
|
|
4
|
+
import type { PageTree, PageTreeItem } from '@/types'
|
|
5
|
+
|
|
6
|
+
interface BreadcrumbsProps {
|
|
7
|
+
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
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function Breadcrumbs({ slug, tree }: BreadcrumbsProps) {
|
|
39
|
+
const items: { label: string; href: string }[] = []
|
|
40
|
+
|
|
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
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Breadcrumb size="small">
|
|
54
|
+
{items.flatMap((item, index) => {
|
|
55
|
+
const breadcrumbItem = (
|
|
56
|
+
<Breadcrumb.Item
|
|
57
|
+
key={`item-${index}`}
|
|
58
|
+
href={item.href}
|
|
59
|
+
current={index === items.length - 1}
|
|
60
|
+
>
|
|
61
|
+
{item.label}
|
|
62
|
+
</Breadcrumb.Item>
|
|
63
|
+
)
|
|
64
|
+
if (index === 0) return [breadcrumbItem]
|
|
65
|
+
return [
|
|
66
|
+
<Breadcrumb.Separator key={`sep-${index}`} style={{ display: 'flex' }} />,
|
|
67
|
+
breadcrumbItem,
|
|
68
|
+
]
|
|
69
|
+
})}
|
|
70
|
+
</Breadcrumb>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { ThemeSwitcher } from '@raystack/apsara'
|
|
4
|
+
import { useState, useEffect } from 'react'
|
|
5
|
+
|
|
6
|
+
interface ClientThemeSwitcherProps {
|
|
7
|
+
size?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function ClientThemeSwitcher({ size }: ClientThemeSwitcherProps) {
|
|
11
|
+
const [isClient, setIsClient] = useState(false)
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
setIsClient(true)
|
|
15
|
+
}, [])
|
|
16
|
+
|
|
17
|
+
return isClient ? <ThemeSwitcher size={size} /> : null
|
|
18
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
.footer {
|
|
2
|
+
border-top: 1px solid var(--rs-color-border-base-primary);
|
|
3
|
+
padding: var(--rs-space-5) var(--rs-space-7);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.container {
|
|
7
|
+
max-width: 1200px;
|
|
8
|
+
margin: 0 auto;
|
|
9
|
+
width: 100%;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.copyright {
|
|
13
|
+
color: var(--rs-color-foreground-base-secondary);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.links {
|
|
17
|
+
flex-wrap: wrap;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.link {
|
|
21
|
+
color: var(--rs-color-foreground-base-secondary);
|
|
22
|
+
font-size: 14px;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.link:hover {
|
|
26
|
+
color: var(--rs-color-foreground-base-primary);
|
|
27
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Link } from "react-router-dom";
|
|
2
|
+
import { Flex, Text } from "@raystack/apsara";
|
|
3
|
+
import type { FooterConfig } from "@/types";
|
|
4
|
+
import styles from "./footer.module.css";
|
|
5
|
+
|
|
6
|
+
interface FooterProps {
|
|
7
|
+
config?: FooterConfig;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Footer({ config }: FooterProps) {
|
|
11
|
+
return (
|
|
12
|
+
<footer className={styles.footer}>
|
|
13
|
+
<Flex align="center" justify="between" className={styles.container}>
|
|
14
|
+
{config?.copyright && (
|
|
15
|
+
<Text size={2} className={styles.copyright}>
|
|
16
|
+
{config.copyright}
|
|
17
|
+
</Text>
|
|
18
|
+
)}
|
|
19
|
+
{config?.links && config.links.length > 0 && (
|
|
20
|
+
<Flex gap="medium" className={styles.links}>
|
|
21
|
+
{config.links.map((link) => (
|
|
22
|
+
<Link key={link.href} to={link.href} className={styles.link}>
|
|
23
|
+
{link.label}
|
|
24
|
+
</Link>
|
|
25
|
+
))}
|
|
26
|
+
</Flex>
|
|
27
|
+
)}
|
|
28
|
+
</Flex>
|
|
29
|
+
</footer>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
.trigger {
|
|
2
|
+
gap: 8px;
|
|
3
|
+
color: var(--rs-color-foreground-base-secondary);
|
|
4
|
+
cursor: pointer;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.kbd {
|
|
8
|
+
padding: 2px 6px;
|
|
9
|
+
border-radius: 4px;
|
|
10
|
+
border: 1px solid var(--rs-color-border-base-primary);
|
|
11
|
+
font-size: 12px;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.dialogContent {
|
|
15
|
+
max-width: 600px;
|
|
16
|
+
padding: 0;
|
|
17
|
+
min-height: 0;
|
|
18
|
+
position: fixed;
|
|
19
|
+
top: 20%;
|
|
20
|
+
left: 50%;
|
|
21
|
+
transform: translateX(-50%);
|
|
22
|
+
border-radius: var(--rs-radius-4);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.input {
|
|
26
|
+
flex: 1;
|
|
27
|
+
border: none;
|
|
28
|
+
outline: none;
|
|
29
|
+
background: transparent;
|
|
30
|
+
font-size: 16px;
|
|
31
|
+
color: var(--rs-color-foreground-base-primary);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.list {
|
|
35
|
+
max-height: 300px;
|
|
36
|
+
overflow: auto;
|
|
37
|
+
padding: 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.visuallyHidden {
|
|
41
|
+
position: absolute;
|
|
42
|
+
width: 1px;
|
|
43
|
+
height: 1px;
|
|
44
|
+
padding: 0;
|
|
45
|
+
margin: -1px;
|
|
46
|
+
overflow: hidden;
|
|
47
|
+
clip: rect(0, 0, 0, 0);
|
|
48
|
+
white-space: nowrap;
|
|
49
|
+
border: 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.item {
|
|
53
|
+
padding: 12px 16px;
|
|
54
|
+
cursor: pointer;
|
|
55
|
+
border-radius: 6px;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.item[data-selected="true"] {
|
|
59
|
+
background: var(--rs-color-background-base-secondary);
|
|
60
|
+
color: var(--rs-color-foreground-accent-primary-hover);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.itemContent {
|
|
64
|
+
display: flex;
|
|
65
|
+
align-items: center;
|
|
66
|
+
gap: 12px;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.resultText {
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
gap: 8px;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.headingText {
|
|
76
|
+
color: var(--rs-color-foreground-base-secondary);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.item[data-selected="true"] .headingText {
|
|
80
|
+
color: var(--rs-color-foreground-accent-primary-hover);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.separator {
|
|
84
|
+
color: var(--rs-color-foreground-base-secondary);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.pageText {
|
|
88
|
+
color: var(--rs-color-foreground-base-primary);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.item[data-selected="true"] .pageText {
|
|
92
|
+
color: var(--rs-color-foreground-accent-primary-hover);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.icon {
|
|
96
|
+
width: 18px;
|
|
97
|
+
height: 18px;
|
|
98
|
+
color: var(--rs-color-foreground-base-secondary);
|
|
99
|
+
flex-shrink: 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.item[data-selected="true"] .icon {
|
|
103
|
+
color: var(--rs-color-foreground-accent-primary-hover);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.pageText :global(mark),
|
|
107
|
+
.headingText :global(mark) {
|
|
108
|
+
background: transparent;
|
|
109
|
+
color: var(--rs-color-foreground-accent-primary);
|
|
110
|
+
font-weight: 600;
|
|
111
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
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 { isMacOs } from "react-device-detect";
|
|
9
|
+
import { MethodBadge } from "@/components/api/method-badge";
|
|
10
|
+
import styles from "./search.module.css";
|
|
11
|
+
|
|
12
|
+
interface SearchResult {
|
|
13
|
+
id: string;
|
|
14
|
+
url: string;
|
|
15
|
+
type: "page" | "api";
|
|
16
|
+
content: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function useSearch(query: string) {
|
|
20
|
+
const [results, setResults] = useState<SearchResult[]>([]);
|
|
21
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
22
|
+
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
clearTimeout(timerRef.current);
|
|
26
|
+
timerRef.current = setTimeout(async () => {
|
|
27
|
+
setIsLoading(true);
|
|
28
|
+
try {
|
|
29
|
+
const params = new URLSearchParams();
|
|
30
|
+
if (query) params.set("query", query);
|
|
31
|
+
const res = await fetch(`/api/search?${params}`);
|
|
32
|
+
setResults(await res.json());
|
|
33
|
+
} catch {
|
|
34
|
+
setResults([]);
|
|
35
|
+
}
|
|
36
|
+
setIsLoading(false);
|
|
37
|
+
}, 100);
|
|
38
|
+
return () => clearTimeout(timerRef.current);
|
|
39
|
+
}, [query]);
|
|
40
|
+
|
|
41
|
+
return { results, isLoading };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function SearchShortcutKey({ className }: { className?: string }) {
|
|
45
|
+
const [key, setKey] = useState("\u2318");
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
setKey(isMacOs ? "\u2318" : "Ctrl");
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<kbd className={className} suppressHydrationWarning>
|
|
53
|
+
{key} K
|
|
54
|
+
</kbd>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface SearchProps {
|
|
59
|
+
className?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function Search({ className }: SearchProps) {
|
|
63
|
+
const [open, setOpen] = useState(false);
|
|
64
|
+
const navigate = useNavigate();
|
|
65
|
+
const [search, setSearch] = useState("");
|
|
66
|
+
const { results, isLoading } = useSearch(search);
|
|
67
|
+
|
|
68
|
+
const onSelect = useCallback(
|
|
69
|
+
(url: string) => {
|
|
70
|
+
setOpen(false);
|
|
71
|
+
navigate(url);
|
|
72
|
+
},
|
|
73
|
+
[navigate],
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const down = (e: KeyboardEvent) => {
|
|
78
|
+
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
setOpen((open) => !open);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
document.addEventListener("keydown", down);
|
|
85
|
+
return () => document.removeEventListener("keydown", down);
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<>
|
|
90
|
+
<Button
|
|
91
|
+
variant="outline"
|
|
92
|
+
color="neutral"
|
|
93
|
+
size="small"
|
|
94
|
+
onClick={() => setOpen(true)}
|
|
95
|
+
className={cx(styles.trigger, className)}
|
|
96
|
+
trailingIcon={<SearchShortcutKey className={styles.kbd} />}
|
|
97
|
+
>
|
|
98
|
+
Search...
|
|
99
|
+
</Button>
|
|
100
|
+
|
|
101
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
102
|
+
<Dialog.Content className={styles.dialogContent}>
|
|
103
|
+
<Dialog.Title className={styles.visuallyHidden}>
|
|
104
|
+
Search documentation
|
|
105
|
+
</Dialog.Title>
|
|
106
|
+
<Command loop>
|
|
107
|
+
<Command.Input
|
|
108
|
+
placeholder="Search"
|
|
109
|
+
value={search}
|
|
110
|
+
onValueChange={setSearch}
|
|
111
|
+
className={styles.input}
|
|
112
|
+
/>
|
|
113
|
+
|
|
114
|
+
<Command.List className={styles.list}>
|
|
115
|
+
{isLoading && <Command.Empty>Loading...</Command.Empty>}
|
|
116
|
+
{!isLoading &&
|
|
117
|
+
search.length > 0 &&
|
|
118
|
+
results.length === 0 && (
|
|
119
|
+
<Command.Empty>No results found.</Command.Empty>
|
|
120
|
+
)}
|
|
121
|
+
{!isLoading &&
|
|
122
|
+
search.length === 0 &&
|
|
123
|
+
results.length > 0 && (
|
|
124
|
+
<Command.Group heading="Suggestions">
|
|
125
|
+
{results.slice(0, 8).map((result) => (
|
|
126
|
+
<Command.Item
|
|
127
|
+
key={result.id}
|
|
128
|
+
value={result.id}
|
|
129
|
+
onSelect={() => onSelect(result.url)}
|
|
130
|
+
className={styles.item}
|
|
131
|
+
>
|
|
132
|
+
<div className={styles.itemContent}>
|
|
133
|
+
{getResultIcon(result)}
|
|
134
|
+
<Text className={styles.pageText}>
|
|
135
|
+
{result.content}
|
|
136
|
+
</Text>
|
|
137
|
+
</div>
|
|
138
|
+
</Command.Item>
|
|
139
|
+
))}
|
|
140
|
+
</Command.Group>
|
|
141
|
+
)}
|
|
142
|
+
{search.length > 0 &&
|
|
143
|
+
results.map((result) => (
|
|
144
|
+
<Command.Item
|
|
145
|
+
key={result.id}
|
|
146
|
+
value={result.id}
|
|
147
|
+
onSelect={() => onSelect(result.url)}
|
|
148
|
+
className={styles.item}
|
|
149
|
+
>
|
|
150
|
+
<div className={styles.itemContent}>
|
|
151
|
+
{getResultIcon(result)}
|
|
152
|
+
<Text className={styles.pageText}>
|
|
153
|
+
{result.content}
|
|
154
|
+
</Text>
|
|
155
|
+
</div>
|
|
156
|
+
</Command.Item>
|
|
157
|
+
))}
|
|
158
|
+
</Command.List>
|
|
159
|
+
</Command>
|
|
160
|
+
</Dialog.Content>
|
|
161
|
+
</Dialog>
|
|
162
|
+
</>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function getResultIcon(result: SearchResult): React.ReactNode {
|
|
167
|
+
if (result.type === "api") {
|
|
168
|
+
const method = result.content.split(" ")[0];
|
|
169
|
+
return ["GET", "POST", "PUT", "DELETE", "PATCH"].includes(method)
|
|
170
|
+
? <MethodBadge method={method} size="micro" />
|
|
171
|
+
: null;
|
|
172
|
+
}
|
|
173
|
+
return <DocumentIcon className={styles.icon} />;
|
|
174
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { OpenAPIV3 } from 'openapi-types'
|
|
2
|
+
import slugify from 'slugify'
|
|
3
|
+
import type { PageTree, PageTreeItem } from '@/types/content'
|
|
4
|
+
import type { ApiSpec } from './openapi'
|
|
5
|
+
|
|
6
|
+
export function getSpecSlug(spec: ApiSpec): string {
|
|
7
|
+
return slugify(spec.name, { lower: true, strict: true })
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export function buildApiRoutes(specs: ApiSpec[]): { slug: string[] }[] {
|
|
12
|
+
const routes: { slug: string[] }[] = []
|
|
13
|
+
|
|
14
|
+
for (const spec of specs) {
|
|
15
|
+
const specSlug = getSpecSlug(spec)
|
|
16
|
+
const paths = spec.document.paths ?? {}
|
|
17
|
+
|
|
18
|
+
for (const [, pathItem] of Object.entries(paths)) {
|
|
19
|
+
if (!pathItem) continue
|
|
20
|
+
for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
|
|
21
|
+
const op = pathItem[method]
|
|
22
|
+
if (!op?.operationId) continue
|
|
23
|
+
routes.push({ slug: [specSlug, encodeURIComponent(op.operationId)] })
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return routes
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ApiRouteMatch {
|
|
32
|
+
spec: ApiSpec
|
|
33
|
+
operation: OpenAPIV3.OperationObject
|
|
34
|
+
method: string
|
|
35
|
+
path: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function findApiOperation(specs: ApiSpec[], slug: string[]): ApiRouteMatch | null {
|
|
39
|
+
if (slug.length !== 2) return null
|
|
40
|
+
const [specSlug, operationId] = slug
|
|
41
|
+
|
|
42
|
+
const spec = specs.find((s) => getSpecSlug(s) === specSlug)
|
|
43
|
+
if (!spec) return null
|
|
44
|
+
|
|
45
|
+
const paths = spec.document.paths ?? {}
|
|
46
|
+
for (const [pathStr, pathItem] of Object.entries(paths)) {
|
|
47
|
+
if (!pathItem) continue
|
|
48
|
+
for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
|
|
49
|
+
const op = pathItem[method]
|
|
50
|
+
if (op?.operationId && encodeURIComponent(op.operationId) === operationId) {
|
|
51
|
+
return { spec, operation: op, method: method.toUpperCase(), path: pathStr }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildApiPageTree(specs: ApiSpec[]): PageTree {
|
|
60
|
+
const children: PageTreeItem[] = []
|
|
61
|
+
|
|
62
|
+
for (const spec of specs) {
|
|
63
|
+
const specSlug = getSpecSlug(spec)
|
|
64
|
+
const paths = spec.document.paths ?? {}
|
|
65
|
+
const tags = spec.document.tags ?? []
|
|
66
|
+
|
|
67
|
+
// Group operations by tag (case-insensitive to avoid duplicates)
|
|
68
|
+
const opsByTag = new Map<string, PageTreeItem[]>()
|
|
69
|
+
const tagDisplayName = new Map<string, string>()
|
|
70
|
+
|
|
71
|
+
for (const [, pathItem] of Object.entries(paths)) {
|
|
72
|
+
if (!pathItem) continue
|
|
73
|
+
for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
|
|
74
|
+
const op = pathItem[method]
|
|
75
|
+
if (!op?.operationId) continue
|
|
76
|
+
|
|
77
|
+
const rawTag = op.tags?.[0] ?? 'default'
|
|
78
|
+
const tagKey = rawTag.toLowerCase()
|
|
79
|
+
if (!opsByTag.has(tagKey)) {
|
|
80
|
+
opsByTag.set(tagKey, [])
|
|
81
|
+
tagDisplayName.set(tagKey, rawTag.charAt(0).toUpperCase() + rawTag.slice(1))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
opsByTag.get(tagKey)!.push({
|
|
85
|
+
type: 'page',
|
|
86
|
+
name: op.summary ?? op.operationId,
|
|
87
|
+
url: `/apis/${specSlug}/${encodeURIComponent(op.operationId)}`,
|
|
88
|
+
icon: `method-${method}`,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Use doc.tags display names where available
|
|
94
|
+
for (const t of tags) {
|
|
95
|
+
const key = t.name.toLowerCase()
|
|
96
|
+
if (opsByTag.has(key)) {
|
|
97
|
+
tagDisplayName.set(key, t.name.charAt(0).toUpperCase() + t.name.slice(1))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const tagFolders: PageTreeItem[] = Array.from(opsByTag.entries()).map(([key, ops]) => ({
|
|
102
|
+
type: 'folder' as const,
|
|
103
|
+
name: tagDisplayName.get(key) ?? key,
|
|
104
|
+
icon: 'rectangle-stack',
|
|
105
|
+
children: ops,
|
|
106
|
+
}))
|
|
107
|
+
|
|
108
|
+
if (specs.length > 1) {
|
|
109
|
+
children.push({
|
|
110
|
+
type: 'folder',
|
|
111
|
+
name: spec.name,
|
|
112
|
+
children: tagFolders,
|
|
113
|
+
})
|
|
114
|
+
} else {
|
|
115
|
+
children.push(...tagFolders)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { name: 'API Reference', children }
|
|
120
|
+
}
|