@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,138 @@
|
|
|
1
|
+
import type { MDXContent } from 'mdx/types'
|
|
2
|
+
import type { Frontmatter, PageTree, PageTreeItem } from '@/types'
|
|
3
|
+
|
|
4
|
+
const meta: Record<string, Frontmatter> = import.meta.glob(
|
|
5
|
+
'@content/**/*.{mdx,md}',
|
|
6
|
+
{ eager: true, import: 'frontmatter' }
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
const loaders: Record<string, () => Promise<{ default: MDXContent }>> = import.meta.glob(
|
|
10
|
+
'@content/**/*.{mdx,md}'
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
export interface SourcePage {
|
|
14
|
+
url: string
|
|
15
|
+
slugs: string[]
|
|
16
|
+
filePath: string
|
|
17
|
+
frontmatter: Frontmatter
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Compute common directory prefix of all glob keys once
|
|
21
|
+
function computePrefix(keys: string[]): string {
|
|
22
|
+
if (keys.length === 0) return ''
|
|
23
|
+
const dirs = keys.map((k) => k.split('/').slice(0, -1)) // drop filename
|
|
24
|
+
const first = dirs[0]
|
|
25
|
+
let depth = 0
|
|
26
|
+
for (let i = 0; i < first.length; i++) {
|
|
27
|
+
if (dirs.every((d) => d[i] === first[i])) {
|
|
28
|
+
depth = i + 1
|
|
29
|
+
} else {
|
|
30
|
+
break
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return first.slice(0, depth).join('/') + '/'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const prefix = computePrefix(Object.keys(meta))
|
|
37
|
+
|
|
38
|
+
function filePathToSlugs(filePath: string): string[] {
|
|
39
|
+
const relative = filePath.slice(prefix.length)
|
|
40
|
+
const withoutExt = relative.replace(/\.(mdx|md)$/, '')
|
|
41
|
+
const parts = withoutExt.split('/').filter(Boolean)
|
|
42
|
+
if (parts[parts.length - 1] === 'index') parts.pop()
|
|
43
|
+
return parts
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function slugsToUrl(slugs: string[]): string {
|
|
47
|
+
return slugs.length === 0 ? '/' : '/' + slugs.join('/')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let cachedPages: SourcePage[] | null = null
|
|
51
|
+
|
|
52
|
+
export async function getPages(): Promise<SourcePage[]> {
|
|
53
|
+
if (cachedPages) return cachedPages
|
|
54
|
+
|
|
55
|
+
cachedPages = Object.entries(meta).map(([filePath, fm]) => {
|
|
56
|
+
const slugs = filePathToSlugs(filePath)
|
|
57
|
+
const baseName = slugs[slugs.length - 1] ?? 'index'
|
|
58
|
+
return {
|
|
59
|
+
url: slugsToUrl(slugs),
|
|
60
|
+
slugs,
|
|
61
|
+
filePath,
|
|
62
|
+
frontmatter: {
|
|
63
|
+
title: fm?.title ?? baseName,
|
|
64
|
+
description: fm?.description,
|
|
65
|
+
order: fm?.order,
|
|
66
|
+
icon: fm?.icon,
|
|
67
|
+
lastModified: fm?.lastModified,
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
return cachedPages
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function getPage(slug?: string[]): Promise<SourcePage | null> {
|
|
76
|
+
const pages = await getPages()
|
|
77
|
+
const targetUrl = !slug || slug.length === 0 ? '/' : '/' + slug.join('/')
|
|
78
|
+
return pages.find((p) => p.url === targetUrl) ?? null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function loadPageComponent(page: SourcePage): Promise<MDXContent | null> {
|
|
82
|
+
const loader = loaders[page.filePath]
|
|
83
|
+
if (!loader) return null
|
|
84
|
+
const mod = await loader()
|
|
85
|
+
return mod.default
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function invalidate() {
|
|
89
|
+
cachedPages = null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function buildPageTree(): Promise<PageTree> {
|
|
93
|
+
const pages = await getPages()
|
|
94
|
+
const folders = new Map<string, PageTreeItem[]>()
|
|
95
|
+
const rootPages: PageTreeItem[] = []
|
|
96
|
+
|
|
97
|
+
pages.forEach((page) => {
|
|
98
|
+
const isIndex = page.url === '/'
|
|
99
|
+
const item: PageTreeItem = {
|
|
100
|
+
type: 'page',
|
|
101
|
+
name: page.frontmatter.title,
|
|
102
|
+
url: page.url,
|
|
103
|
+
order: page.frontmatter.order ?? (isIndex ? 0 : undefined),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (page.slugs.length > 1) {
|
|
107
|
+
const folder = page.slugs[0]
|
|
108
|
+
if (!folders.has(folder)) {
|
|
109
|
+
folders.set(folder, [])
|
|
110
|
+
}
|
|
111
|
+
folders.get(folder)?.push(item)
|
|
112
|
+
} else {
|
|
113
|
+
rootPages.push(item)
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const sortByOrder = (items: PageTreeItem[]) =>
|
|
118
|
+
items.sort((a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER))
|
|
119
|
+
|
|
120
|
+
const children: PageTreeItem[] = sortByOrder(rootPages)
|
|
121
|
+
|
|
122
|
+
const folderItems: PageTreeItem[] = []
|
|
123
|
+
folders.forEach((items, folder) => {
|
|
124
|
+
const sorted = sortByOrder(items)
|
|
125
|
+
const indexPage = items.find(item => item.url === `/${folder}`)
|
|
126
|
+
const folderOrder = indexPage?.order ?? sorted[0]?.order
|
|
127
|
+
folderItems.push({
|
|
128
|
+
type: 'folder',
|
|
129
|
+
name: folder.charAt(0).toUpperCase() + folder.slice(1),
|
|
130
|
+
order: folderOrder,
|
|
131
|
+
children: sorted,
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
children.push(...sortByOrder(folderItems))
|
|
136
|
+
|
|
137
|
+
return { name: 'root', children }
|
|
138
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
.layout {
|
|
2
|
+
height: 100vh;
|
|
3
|
+
overflow: hidden;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.body {
|
|
7
|
+
overflow: hidden;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.sidebar {
|
|
11
|
+
height: 100%;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.content {
|
|
15
|
+
height: 100%;
|
|
16
|
+
overflow-y: auto;
|
|
17
|
+
padding-right: 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.hiddenSearch {
|
|
21
|
+
display: none;
|
|
22
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { cx } from 'class-variance-authority'
|
|
3
|
+
import { usePageContext } from '@/lib/page-context'
|
|
4
|
+
import { buildApiPageTree } from '@/lib/api-routes'
|
|
5
|
+
import { getTheme } from '@/themes/registry'
|
|
6
|
+
import { Search } from '@/components/ui/search'
|
|
7
|
+
import styles from './ApiLayout.module.css'
|
|
8
|
+
|
|
9
|
+
interface ApiLayoutProps {
|
|
10
|
+
children: ReactNode
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ApiLayout({ children }: ApiLayoutProps) {
|
|
14
|
+
const { config, apiSpecs } = usePageContext()
|
|
15
|
+
const { Layout, className } = getTheme(config.theme?.name)
|
|
16
|
+
const tree = buildApiPageTree(apiSpecs)
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Layout config={config} tree={tree} classNames={{
|
|
20
|
+
layout: cx(styles.layout, className),
|
|
21
|
+
body: styles.body,
|
|
22
|
+
sidebar: styles.sidebar,
|
|
23
|
+
content: styles.content,
|
|
24
|
+
}}>
|
|
25
|
+
<Search className={styles.hiddenSearch} />
|
|
26
|
+
{children}
|
|
27
|
+
</Layout>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { OpenAPIV3 } from 'openapi-types'
|
|
2
|
+
import { Flex, Headline, Text } from '@raystack/apsara'
|
|
3
|
+
import { usePageContext } from '@/lib/page-context'
|
|
4
|
+
import { findApiOperation } from '@/lib/api-routes'
|
|
5
|
+
import { EndpointPage } from '@/components/api'
|
|
6
|
+
import { Head } from '@/lib/head'
|
|
7
|
+
import type { ApiSpec } from '@/lib/openapi'
|
|
8
|
+
|
|
9
|
+
interface ApiPageProps {
|
|
10
|
+
slug: string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ApiPage({ slug }: ApiPageProps) {
|
|
14
|
+
const { config, apiSpecs } = usePageContext()
|
|
15
|
+
|
|
16
|
+
if (slug.length === 0) {
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<Head
|
|
20
|
+
title="API Reference"
|
|
21
|
+
description={`API documentation for ${config.title}`}
|
|
22
|
+
config={config}
|
|
23
|
+
/>
|
|
24
|
+
<ApiLanding specs={apiSpecs} />
|
|
25
|
+
</>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const match = findApiOperation(apiSpecs, slug)
|
|
30
|
+
if (!match) return null
|
|
31
|
+
|
|
32
|
+
const operation = match.operation as OpenAPIV3.OperationObject
|
|
33
|
+
const title = operation.summary ?? `${match.method.toUpperCase()} ${match.path}`
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
<Head
|
|
38
|
+
title={title}
|
|
39
|
+
description={operation.description}
|
|
40
|
+
config={config}
|
|
41
|
+
/>
|
|
42
|
+
<EndpointPage
|
|
43
|
+
method={match.method}
|
|
44
|
+
path={match.path}
|
|
45
|
+
operation={match.operation}
|
|
46
|
+
serverUrl={match.spec.server.url}
|
|
47
|
+
specName={match.spec.name}
|
|
48
|
+
auth={match.spec.auth}
|
|
49
|
+
/>
|
|
50
|
+
</>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ApiLanding({ specs }: { specs: ApiSpec[] }) {
|
|
55
|
+
return (
|
|
56
|
+
<Flex direction="column" gap="large" style={{ padding: 'var(--rs-space-7)' }}>
|
|
57
|
+
<Headline size="medium" as="h1">API Reference</Headline>
|
|
58
|
+
{specs.map((spec) => (
|
|
59
|
+
<Flex key={spec.name} direction="column" gap="small">
|
|
60
|
+
<Headline size="small" as="h2">{spec.name}</Headline>
|
|
61
|
+
{spec.document.info.description && (
|
|
62
|
+
<Text size={3}>{spec.document.info.description}</Text>
|
|
63
|
+
)}
|
|
64
|
+
</Flex>
|
|
65
|
+
))}
|
|
66
|
+
</Flex>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { usePageContext } from '@/lib/page-context'
|
|
3
|
+
import { getTheme } from '@/themes/registry'
|
|
4
|
+
|
|
5
|
+
interface DocsLayoutProps {
|
|
6
|
+
children: ReactNode
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function DocsLayout({ children }: DocsLayoutProps) {
|
|
10
|
+
const { config, tree } = usePageContext()
|
|
11
|
+
const { Layout, className } = getTheme(config.theme?.name)
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<Layout config={config} tree={tree} classNames={{ layout: className }}>
|
|
15
|
+
{children}
|
|
16
|
+
</Layout>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { usePageContext } from '@/lib/page-context'
|
|
2
|
+
import { getTheme } from '@/themes/registry'
|
|
3
|
+
import { Head } from '@/lib/head'
|
|
4
|
+
|
|
5
|
+
interface DocsPageProps {
|
|
6
|
+
slug: string[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function DocsPage({ slug }: DocsPageProps) {
|
|
10
|
+
const { config, tree, page } = usePageContext()
|
|
11
|
+
|
|
12
|
+
if (!page) return null
|
|
13
|
+
|
|
14
|
+
const { Page } = getTheme(config.theme?.name)
|
|
15
|
+
const pageUrl = config.url ? `${config.url}/${slug.join('/')}` : undefined
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<Head
|
|
20
|
+
title={page.frontmatter.title}
|
|
21
|
+
description={page.frontmatter.description}
|
|
22
|
+
config={config}
|
|
23
|
+
jsonLd={{
|
|
24
|
+
'@context': 'https://schema.org',
|
|
25
|
+
'@type': 'Article',
|
|
26
|
+
headline: page.frontmatter.title,
|
|
27
|
+
description: page.frontmatter.description,
|
|
28
|
+
...(pageUrl && { url: pageUrl }),
|
|
29
|
+
}}
|
|
30
|
+
/>
|
|
31
|
+
<Page
|
|
32
|
+
page={{
|
|
33
|
+
slug,
|
|
34
|
+
frontmatter: page.frontmatter,
|
|
35
|
+
content: page.content,
|
|
36
|
+
toc: [],
|
|
37
|
+
}}
|
|
38
|
+
config={config}
|
|
39
|
+
tree={tree}
|
|
40
|
+
/>
|
|
41
|
+
</>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Flex, Headline, Text } from '@raystack/apsara'
|
|
2
|
+
|
|
3
|
+
export function NotFound() {
|
|
4
|
+
return (
|
|
5
|
+
<Flex direction="column" align="center" justify="center" style={{ minHeight: '60vh' }}>
|
|
6
|
+
<Headline size="large" as="h1">404</Headline>
|
|
7
|
+
<Text size={3}>Page not found</Text>
|
|
8
|
+
</Flex>
|
|
9
|
+
)
|
|
10
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { Head } from '@/lib/head'
|
|
4
|
+
|
|
5
|
+
describe('Head component', () => {
|
|
6
|
+
const baseConfig = {
|
|
7
|
+
title: 'Test Docs',
|
|
8
|
+
theme: { name: 'default' as const },
|
|
9
|
+
search: { enabled: true, placeholder: 'Search...' },
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
it('renders title tag', () => {
|
|
13
|
+
const html = renderToString(
|
|
14
|
+
<Head title="Page Title" config={baseConfig} />
|
|
15
|
+
)
|
|
16
|
+
expect(html).toContain('Page Title | Test Docs')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('renders description meta tag', () => {
|
|
20
|
+
const html = renderToString(
|
|
21
|
+
<Head title="Page" description="A description" config={baseConfig} />
|
|
22
|
+
)
|
|
23
|
+
expect(html).toContain('A description')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('renders OG tags when config.url is set', () => {
|
|
27
|
+
const html = renderToString(
|
|
28
|
+
<Head
|
|
29
|
+
title="Page"
|
|
30
|
+
description="Desc"
|
|
31
|
+
config={{ ...baseConfig, url: 'https://docs.example.com' }}
|
|
32
|
+
/>
|
|
33
|
+
)
|
|
34
|
+
expect(html).toContain('og:title')
|
|
35
|
+
expect(html).toContain('og:description')
|
|
36
|
+
expect(html).toContain('twitter:card')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('skips OG tags when no url in config', () => {
|
|
40
|
+
const html = renderToString(
|
|
41
|
+
<Head title="Page" config={baseConfig} />
|
|
42
|
+
)
|
|
43
|
+
expect(html).not.toContain('og:title')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('renders JSON-LD script', () => {
|
|
47
|
+
const html = renderToString(
|
|
48
|
+
<Head
|
|
49
|
+
title="Page"
|
|
50
|
+
config={baseConfig}
|
|
51
|
+
jsonLd={{ '@context': 'https://schema.org', '@type': 'Article' }}
|
|
52
|
+
/>
|
|
53
|
+
)
|
|
54
|
+
expect(html).toContain('application/ld+json')
|
|
55
|
+
expect(html).toContain('schema.org')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import '@raystack/apsara/normalize.css'
|
|
2
|
+
import '@raystack/apsara/style.css'
|
|
3
|
+
import { ThemeProvider } from '@raystack/apsara'
|
|
4
|
+
import { useLocation } from 'react-router-dom'
|
|
5
|
+
import { usePageContext } from '@/lib/page-context'
|
|
6
|
+
import { Head } from '@/lib/head'
|
|
7
|
+
import { DocsLayout } from '@/pages/DocsLayout'
|
|
8
|
+
import { DocsPage } from '@/pages/DocsPage'
|
|
9
|
+
import { ApiLayout } from '@/pages/ApiLayout'
|
|
10
|
+
import { ApiPage } from '@/pages/ApiPage'
|
|
11
|
+
import type { ChronicleConfig } from '@/types'
|
|
12
|
+
|
|
13
|
+
function resolveRoute(pathname: string) {
|
|
14
|
+
if (pathname.startsWith('/apis')) {
|
|
15
|
+
const slug = pathname.replace(/^\/apis\/?/, '').split('/').filter(Boolean)
|
|
16
|
+
return { type: 'api' as const, slug }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const slug = pathname === '/' ? [] : pathname.slice(1).split('/').filter(Boolean)
|
|
20
|
+
return { type: 'docs' as const, slug }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function App() {
|
|
24
|
+
const { pathname } = useLocation()
|
|
25
|
+
const { config } = usePageContext()
|
|
26
|
+
const route = resolveRoute(pathname)
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<ThemeProvider enableSystem>
|
|
30
|
+
<RootHead config={config} />
|
|
31
|
+
{route.type === 'api' ? (
|
|
32
|
+
<ApiLayout>
|
|
33
|
+
<ApiPage slug={route.slug} />
|
|
34
|
+
</ApiLayout>
|
|
35
|
+
) : (
|
|
36
|
+
<DocsLayout>
|
|
37
|
+
<DocsPage slug={route.slug} />
|
|
38
|
+
</DocsLayout>
|
|
39
|
+
)}
|
|
40
|
+
</ThemeProvider>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function RootHead({ config }: { config: ChronicleConfig }) {
|
|
45
|
+
return (
|
|
46
|
+
<Head
|
|
47
|
+
title={config.title}
|
|
48
|
+
description={config.description}
|
|
49
|
+
config={config}
|
|
50
|
+
jsonLd={config.url ? {
|
|
51
|
+
'@context': 'https://schema.org',
|
|
52
|
+
'@type': 'WebSite',
|
|
53
|
+
name: config.title,
|
|
54
|
+
description: config.description,
|
|
55
|
+
url: config.url,
|
|
56
|
+
} : undefined}
|
|
57
|
+
/>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, type SSRData } from '../entry-server'
|
|
3
|
+
|
|
4
|
+
const mockData: SSRData = {
|
|
5
|
+
config: { title: 'Test Site' },
|
|
6
|
+
tree: { name: 'root', children: [] },
|
|
7
|
+
page: {
|
|
8
|
+
slug: [],
|
|
9
|
+
frontmatter: { title: 'Test' },
|
|
10
|
+
content: null,
|
|
11
|
+
},
|
|
12
|
+
apiSpecs: [],
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('entry-server', () => {
|
|
16
|
+
it('exports a render function', () => {
|
|
17
|
+
expect(typeof render).toBe('function')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns an HTML string', () => {
|
|
21
|
+
const html = render('http://localhost:3000/', mockData)
|
|
22
|
+
expect(typeof html).toBe('string')
|
|
23
|
+
expect(html.length).toBeGreaterThan(0)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('renders docs route for root URL', () => {
|
|
27
|
+
const html = render('http://localhost:3000/', mockData)
|
|
28
|
+
expect(html).toBeTruthy()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('renders api route for /apis URL', () => {
|
|
32
|
+
const html = render('http://localhost:3000/apis', mockData)
|
|
33
|
+
expect(html).toBeTruthy()
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { handleHealth } from '../handlers/health'
|
|
3
|
+
import { handleRobots } from '../handlers/robots'
|
|
4
|
+
import { handleSitemap } from '../handlers/sitemap'
|
|
5
|
+
import { handleApisProxy } from '../handlers/apis-proxy'
|
|
6
|
+
import { handleLlms } from '../handlers/llms'
|
|
7
|
+
|
|
8
|
+
describe('handleHealth', () => {
|
|
9
|
+
it('returns 200 with status ok', async () => {
|
|
10
|
+
const response = handleHealth()
|
|
11
|
+
expect(response.status).toBe(200)
|
|
12
|
+
const body = await response.json()
|
|
13
|
+
expect(body).toEqual({ status: 'ok' })
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('handleRobots', () => {
|
|
18
|
+
it('returns text/plain content type', async () => {
|
|
19
|
+
const response = handleRobots()
|
|
20
|
+
expect(response.headers.get('content-type')).toBe('text/plain')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('includes User-agent directive', async () => {
|
|
24
|
+
const response = handleRobots()
|
|
25
|
+
const text = await response.text()
|
|
26
|
+
expect(text).toContain('User-agent: *')
|
|
27
|
+
expect(text).toContain('Allow: /')
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('handleSitemap', () => {
|
|
32
|
+
it('returns application/xml content type', async () => {
|
|
33
|
+
const response = await handleSitemap()
|
|
34
|
+
expect(response.headers.get('content-type')).toBe('application/xml')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('returns valid XML structure', async () => {
|
|
38
|
+
const response = await handleSitemap()
|
|
39
|
+
const xml = await response.text()
|
|
40
|
+
expect(xml).toContain('<urlset')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('handleApisProxy', () => {
|
|
45
|
+
it('returns 405 for GET requests', async () => {
|
|
46
|
+
const req = new Request('http://localhost:3000/api/apis-proxy', { method: 'GET' })
|
|
47
|
+
const response = await handleApisProxy(req)
|
|
48
|
+
expect(response.status).toBe(405)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns 400 for POST without required fields', async () => {
|
|
52
|
+
const req = new Request('http://localhost:3000/api/apis-proxy', {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
body: JSON.stringify({}),
|
|
55
|
+
headers: { 'Content-Type': 'application/json' },
|
|
56
|
+
})
|
|
57
|
+
const response = await handleApisProxy(req)
|
|
58
|
+
expect(response.status).toBe(400)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('returns 404 for unknown spec', async () => {
|
|
62
|
+
const req = new Request('http://localhost:3000/api/apis-proxy', {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
body: JSON.stringify({ specName: 'nonexistent', method: 'GET', path: '/test' }),
|
|
65
|
+
headers: { 'Content-Type': 'application/json' },
|
|
66
|
+
})
|
|
67
|
+
const response = await handleApisProxy(req)
|
|
68
|
+
expect(response.status).toBe(404)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('handleLlms', () => {
|
|
73
|
+
it('returns 404 when llms not enabled', async () => {
|
|
74
|
+
const response = await handleLlms()
|
|
75
|
+
expect(response.status).toBe(404)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { handleOg } from '../handlers/og'
|
|
3
|
+
|
|
4
|
+
// OG handler requires network to fetch fonts, skip in CI-like environments
|
|
5
|
+
describe.skipIf(!process.env.TEST_OG)('handleOg', () => {
|
|
6
|
+
it('returns SVG content type', async () => {
|
|
7
|
+
const req = new Request('http://localhost:3000/og?title=Test')
|
|
8
|
+
const response = await handleOg(req)
|
|
9
|
+
expect(response.headers.get('content-type')).toBe('image/svg+xml')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('returns cache-control header', async () => {
|
|
13
|
+
const req = new Request('http://localhost:3000/og?title=Test')
|
|
14
|
+
const response = await handleOg(req)
|
|
15
|
+
expect(response.headers.get('cache-control')).toContain('max-age')
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('handleOg export', () => {
|
|
20
|
+
it('exports a function', () => {
|
|
21
|
+
expect(typeof handleOg).toBe('function')
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { matchRoute } from '../router'
|
|
3
|
+
|
|
4
|
+
describe('router', () => {
|
|
5
|
+
it('matches /api/health route', () => {
|
|
6
|
+
const handler = matchRoute('http://localhost:3000/api/health')
|
|
7
|
+
expect(handler).not.toBeNull()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('returns ok for /api/health', async () => {
|
|
11
|
+
const handler = matchRoute('http://localhost:3000/api/health')
|
|
12
|
+
const response = await handler!(new Request('http://localhost:3000/api/health'))
|
|
13
|
+
expect(response.status).toBe(200)
|
|
14
|
+
const body = await response.json()
|
|
15
|
+
expect(body).toEqual({ status: 'ok' })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('matches /api/search route', () => {
|
|
19
|
+
const handler = matchRoute('http://localhost:3000/api/search')
|
|
20
|
+
expect(handler).not.toBeNull()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('matches /robots.txt route', () => {
|
|
24
|
+
const handler = matchRoute('http://localhost:3000/robots.txt')
|
|
25
|
+
expect(handler).not.toBeNull()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('returns robots.txt with correct content', async () => {
|
|
29
|
+
const handler = matchRoute('http://localhost:3000/robots.txt')
|
|
30
|
+
const response = await handler!(new Request('http://localhost:3000/robots.txt'))
|
|
31
|
+
expect(response.headers.get('content-type')).toBe('text/plain')
|
|
32
|
+
const text = await response.text()
|
|
33
|
+
expect(text).toContain('User-agent')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('matches /sitemap.xml route', () => {
|
|
37
|
+
const handler = matchRoute('http://localhost:3000/sitemap.xml')
|
|
38
|
+
expect(handler).not.toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('returns sitemap.xml with xml content type', async () => {
|
|
42
|
+
const handler = matchRoute('http://localhost:3000/sitemap.xml')
|
|
43
|
+
const response = await handler!(new Request('http://localhost:3000/sitemap.xml'))
|
|
44
|
+
expect(response.headers.get('content-type')).toBe('application/xml')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('returns null for unknown routes', () => {
|
|
48
|
+
const handler = matchRoute('http://localhost:3000/some/random/path')
|
|
49
|
+
expect(handler).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('matches /llms.txt route', () => {
|
|
53
|
+
const handler = matchRoute('http://localhost:3000/llms.txt')
|
|
54
|
+
expect(handler).not.toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('matches /og route', () => {
|
|
58
|
+
const handler = matchRoute('http://localhost:3000/og')
|
|
59
|
+
expect(handler).not.toBeNull()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('matches /api/apis-proxy route', () => {
|
|
63
|
+
const handler = matchRoute('http://localhost:3000/api/apis-proxy')
|
|
64
|
+
expect(handler).not.toBeNull()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('returns 405 for non-POST to apis-proxy', async () => {
|
|
68
|
+
const handler = matchRoute('http://localhost:3000/api/apis-proxy')
|
|
69
|
+
const response = await handler!(new Request('http://localhost:3000/api/apis-proxy'))
|
|
70
|
+
expect(response.status).toBe(405)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { createViteConfig } from '../vite-config'
|
|
3
|
+
|
|
4
|
+
describe('createViteConfig', () => {
|
|
5
|
+
it('returns a valid vite config object', async () => {
|
|
6
|
+
const config = await createViteConfig({
|
|
7
|
+
root: '/tmp/test',
|
|
8
|
+
contentDir: '/tmp/test/content',
|
|
9
|
+
isDev: true,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
expect(config.root).toBe('/tmp/test')
|
|
13
|
+
expect(config.configFile).toBe(false)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('accepts isDev option', async () => {
|
|
17
|
+
const config = await createViteConfig({
|
|
18
|
+
root: '/tmp/test',
|
|
19
|
+
contentDir: '/tmp/test/content',
|
|
20
|
+
isDev: false,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
expect(config.root).toBe('/tmp/test')
|
|
24
|
+
})
|
|
25
|
+
})
|