@raystack/chronicle 0.1.0-canary.30bf0df → 0.1.0-canary.49fe67c

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 (73) hide show
  1. package/dist/cli/index.js +220 -9919
  2. package/package.json +20 -12
  3. package/src/cli/commands/build.ts +27 -25
  4. package/src/cli/commands/dev.ts +24 -25
  5. package/src/cli/commands/init.ts +38 -132
  6. package/src/cli/commands/serve.ts +36 -49
  7. package/src/cli/commands/start.ts +20 -25
  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 -3
  11. package/src/cli/utils/resolve.ts +9 -3
  12. package/src/cli/utils/scaffold.ts +11 -124
  13. package/src/components/mdx/image.tsx +5 -34
  14. package/src/components/mdx/link.tsx +18 -15
  15. package/src/components/ui/search.module.css +7 -0
  16. package/src/components/ui/search.tsx +65 -49
  17. package/src/lib/config.ts +31 -28
  18. package/src/lib/head.tsx +49 -0
  19. package/src/lib/openapi.ts +8 -8
  20. package/src/lib/page-context.tsx +114 -0
  21. package/src/lib/source.ts +164 -45
  22. package/src/pages/ApiLayout.tsx +33 -0
  23. package/src/pages/ApiPage.tsx +73 -0
  24. package/src/pages/DocsLayout.tsx +18 -0
  25. package/src/pages/DocsPage.tsx +43 -0
  26. package/src/pages/NotFound.tsx +17 -0
  27. package/src/server/App.tsx +67 -0
  28. package/src/server/api/apis-proxy.ts +69 -0
  29. package/src/server/api/health.ts +5 -0
  30. package/src/server/api/page/[...slug].ts +18 -0
  31. package/src/server/api/search.ts +170 -0
  32. package/src/server/api/specs.ts +9 -0
  33. package/src/server/build-search-index.ts +117 -0
  34. package/src/server/entry-client.tsx +70 -0
  35. package/src/server/entry-server.tsx +95 -0
  36. package/src/server/routes/llms.txt.ts +61 -0
  37. package/src/server/routes/og.tsx +75 -0
  38. package/src/server/routes/robots.txt.ts +11 -0
  39. package/src/server/routes/sitemap.xml.ts +39 -0
  40. package/src/server/utils/safe-path.ts +17 -0
  41. package/src/server/vite-config.ts +84 -0
  42. package/src/themes/default/Layout.tsx +69 -42
  43. package/src/themes/default/Page.tsx +9 -11
  44. package/src/themes/default/Toc.tsx +30 -28
  45. package/src/themes/default/index.ts +7 -9
  46. package/src/themes/paper/ChapterNav.tsx +60 -41
  47. package/src/themes/paper/Layout.module.css +1 -1
  48. package/src/themes/paper/Layout.tsx +24 -12
  49. package/src/themes/paper/Page.module.css +11 -4
  50. package/src/themes/paper/Page.tsx +67 -48
  51. package/src/themes/paper/ReadingProgress.tsx +160 -139
  52. package/src/themes/paper/index.ts +5 -5
  53. package/src/themes/registry.ts +7 -7
  54. package/src/types/config.ts +11 -0
  55. package/src/types/content.ts +1 -0
  56. package/src/types/globals.d.ts +4 -0
  57. package/tsconfig.json +2 -3
  58. package/next.config.mjs +0 -10
  59. package/source.config.ts +0 -50
  60. package/src/app/[[...slug]]/layout.tsx +0 -15
  61. package/src/app/[[...slug]]/page.tsx +0 -57
  62. package/src/app/api/apis-proxy/route.ts +0 -59
  63. package/src/app/api/health/route.ts +0 -3
  64. package/src/app/api/search/route.ts +0 -90
  65. package/src/app/apis/[[...slug]]/layout.tsx +0 -26
  66. package/src/app/apis/[[...slug]]/page.tsx +0 -57
  67. package/src/app/layout.tsx +0 -26
  68. package/src/app/llms-full.txt/route.ts +0 -18
  69. package/src/app/llms.txt/route.ts +0 -15
  70. package/src/app/providers.tsx +0 -8
  71. package/src/cli/utils/process.ts +0 -7
  72. package/src/themes/default/font.ts +0 -6
  73. /package/src/{app/apis/[[...slug]]/layout.module.css → pages/ApiLayout.module.css} +0 -0
@@ -0,0 +1,114 @@
1
+ import React, {
2
+ createContext,
3
+ type ReactNode,
4
+ useContext,
5
+ useEffect,
6
+ useState
7
+ } from 'react';
8
+ import { useLocation } from 'react-router';
9
+ import { mdxComponents } from '@/components/mdx';
10
+ import type { ApiSpec } from '@/lib/openapi';
11
+ import type { ChronicleConfig, Frontmatter, PageTree } from '@/types';
12
+
13
+ interface PageData {
14
+ slug: string[];
15
+ frontmatter: Frontmatter;
16
+ content: ReactNode;
17
+ }
18
+
19
+ interface PageContextValue {
20
+ config: ChronicleConfig;
21
+ tree: PageTree;
22
+ page: PageData | null;
23
+ apiSpecs: ApiSpec[];
24
+ }
25
+
26
+ const PageContext = createContext<PageContextValue | null>(null);
27
+
28
+ export function usePageContext(): PageContextValue {
29
+ const ctx = useContext(PageContext);
30
+ if (!ctx) {
31
+ console.error('usePageContext: no context found!');
32
+ return {
33
+ config: { title: 'Documentation' },
34
+ tree: { name: 'root', children: [] },
35
+ page: null,
36
+ apiSpecs: []
37
+ };
38
+ }
39
+ return ctx;
40
+ }
41
+
42
+ interface PageProviderProps {
43
+ initialConfig: ChronicleConfig;
44
+ initialTree: PageTree;
45
+ initialPage: PageData | null;
46
+ initialApiSpecs: ApiSpec[];
47
+ children: ReactNode;
48
+ }
49
+
50
+ async function loadMdxComponent(relativePath: string): Promise<ReactNode> {
51
+ const mod = await import(/* @vite-ignore */ `/.content/${relativePath}`);
52
+ return mod.default
53
+ ? React.createElement(mod.default, { components: mdxComponents })
54
+ : null;
55
+ }
56
+
57
+ export function PageProvider({
58
+ initialConfig,
59
+ initialTree,
60
+ initialPage,
61
+ initialApiSpecs,
62
+ children
63
+ }: PageProviderProps) {
64
+ const { pathname } = useLocation();
65
+ const [tree] = useState<PageTree>(initialTree);
66
+ const [page, setPage] = useState<PageData | null>(initialPage);
67
+ const [apiSpecs, setApiSpecs] = useState<ApiSpec[]>(initialApiSpecs);
68
+ const [currentPath, setCurrentPath] = useState(pathname);
69
+
70
+ useEffect(() => {
71
+ if (pathname === currentPath) return;
72
+ setCurrentPath(pathname);
73
+
74
+ const cancelled = { current: false };
75
+
76
+ if (pathname.startsWith('/apis')) {
77
+ if (apiSpecs.length === 0) {
78
+ fetch('/api/specs')
79
+ .then(res => res.json())
80
+ .then(specs => {
81
+ if (!cancelled.current) setApiSpecs(specs);
82
+ })
83
+ .catch(() => {});
84
+ }
85
+ return () => { cancelled.current = true; };
86
+ }
87
+
88
+ const slug = pathname === '/'
89
+ ? []
90
+ : pathname.slice(1).split('/').filter(Boolean);
91
+
92
+ const apiPath = slug.length === 0 ? '/api/page/' : `/api/page/${slug.join('/')}`;
93
+
94
+ fetch(apiPath)
95
+ .then(res => res.json())
96
+ .then(async (data: { frontmatter: Frontmatter; relativePath: string }) => {
97
+ if (cancelled.current) return;
98
+ const content = await loadMdxComponent(data.relativePath);
99
+ if (cancelled.current) return;
100
+ setPage({ slug, frontmatter: data.frontmatter, content });
101
+ })
102
+ .catch(() => {});
103
+
104
+ return () => { cancelled.current = true; };
105
+ }, [pathname]);
106
+
107
+ return (
108
+ <PageContext.Provider
109
+ value={{ config: initialConfig, tree, page, apiSpecs }}
110
+ >
111
+ {children}
112
+ </PageContext.Provider>
113
+ );
114
+ }
package/src/lib/source.ts CHANGED
@@ -1,67 +1,186 @@
1
- import { docs } from '@/.source/server'
2
- import { loader } from 'fumadocs-core/source'
3
- import type { PageTree, PageTreeItem, Frontmatter } from '@/types'
4
-
5
- export const source = loader({
6
- baseUrl: '/',
7
- source: docs.toFumadocsSource(),
8
- })
9
-
10
- export function sortByOrder<T extends { frontmatter?: Frontmatter }>(
11
- items: T[]
12
- ): T[] {
13
- return [...items].sort((a, b) => {
14
- const orderA = a.frontmatter?.order ?? Number.MAX_SAFE_INTEGER
15
- const orderB = b.frontmatter?.order ?? Number.MAX_SAFE_INTEGER
16
- return orderA - orderB
17
- })
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { loader } from 'fumadocs-core/source';
4
+ import matter from 'gray-matter';
5
+ import type { MDXContent } from 'mdx/types';
6
+ import type { Frontmatter, PageTree, PageTreeItem } from '@/types';
7
+
8
+ export interface SourcePage {
9
+ url: string;
10
+ slugs: string[];
11
+ filePath: string;
12
+ frontmatter: Frontmatter;
13
+ }
14
+
15
+ function getContentDir(): string {
16
+ return __CHRONICLE_CONTENT_DIR__ || path.join(process.cwd(), 'content');
17
+ }
18
+
19
+ async function scanFiles(contentDir: string) {
20
+ const files: {
21
+ type: 'page' | 'meta';
22
+ path: string;
23
+ data: Record<string, unknown>;
24
+ }[] = [];
25
+
26
+ async function scan(dir: string, prefix: string[] = []) {
27
+ try {
28
+ const entries = await fs.readdir(dir, { withFileTypes: true });
29
+ for (const entry of entries) {
30
+ if (entry.name.startsWith('.') || entry.name === 'node_modules')
31
+ continue;
32
+ const fullPath = path.join(dir, entry.name);
33
+ const relativePath = [...prefix, entry.name].join('/');
34
+
35
+ if (entry.isDirectory()) {
36
+ await scan(fullPath, [...prefix, entry.name]);
37
+ continue;
38
+ }
39
+
40
+ if (entry.name.endsWith('.mdx') || entry.name.endsWith('.md')) {
41
+ const raw = await fs.readFile(fullPath, 'utf-8');
42
+ const { data } = matter(raw);
43
+ files.push({
44
+ type: 'page',
45
+ path: relativePath,
46
+ data: { ...data, _absolutePath: fullPath }
47
+ });
48
+ } else if (entry.name === 'meta.json' || entry.name === 'meta.yaml') {
49
+ const raw = await fs.readFile(fullPath, 'utf-8');
50
+ const data = entry.name.endsWith('.json')
51
+ ? JSON.parse(raw)
52
+ : matter(raw).data;
53
+ files.push({ type: 'meta', path: relativePath, data });
54
+ }
55
+ }
56
+ } catch {
57
+ /* directory not readable */
58
+ }
59
+ }
60
+
61
+ await scan(contentDir);
62
+ return files;
18
63
  }
19
64
 
20
- export function buildPageTree(): PageTree {
21
- const pages = source.getPages()
22
- const folders = new Map<string, PageTreeItem[]>()
23
- const rootPages: PageTreeItem[] = []
65
+ let cachedSource: ReturnType<typeof loader> | null = null;
66
+ let cachedPages: SourcePage[] | null = null;
24
67
 
25
- pages.forEach((page) => {
26
- const data = page.data as { title?: string; order?: number }
27
- const isIndex = page.url === '/'
68
+ async function getSource() {
69
+ if (cachedSource) return cachedSource;
70
+ const contentDir = getContentDir();
71
+ const files = await scanFiles(contentDir);
72
+ cachedSource = loader({
73
+ source: { files },
74
+ baseUrl: '/'
75
+ });
76
+ return cachedSource;
77
+ }
78
+
79
+ export function invalidate() {
80
+ cachedSource = null;
81
+ cachedPages = null;
82
+ }
83
+
84
+ export async function getPages(): Promise<SourcePage[]> {
85
+ if (cachedPages) return cachedPages;
86
+
87
+ const s = await getSource();
88
+ cachedPages = s.getPages().map(page => {
89
+ const data = page.data as Record<string, unknown>;
90
+ return {
91
+ url: page.url,
92
+ slugs: page.slugs,
93
+ filePath: (data._absolutePath as string) ?? '',
94
+ frontmatter: {
95
+ title:
96
+ (data.title as string) ??
97
+ page.slugs[page.slugs.length - 1] ??
98
+ 'Untitled',
99
+ description: data.description as string | undefined,
100
+ order: data.order as number | undefined,
101
+ icon: data.icon as string | undefined,
102
+ lastModified: data.lastModified as string | undefined
103
+ }
104
+ };
105
+ });
106
+
107
+ return cachedPages;
108
+ }
109
+
110
+ export async function getPage(slug?: string[]): Promise<SourcePage | null> {
111
+ const pages = await getPages();
112
+ const targetUrl = !slug || slug.length === 0 ? '/' : `/${slug.join('/')}`;
113
+ return pages.find(p => p.url === targetUrl) ?? null;
114
+ }
115
+
116
+ export async function loadPageComponent(
117
+ page: SourcePage
118
+ ): Promise<MDXContent | null> {
119
+ if (!page.filePath) return null;
120
+ try {
121
+ await fs.access(page.filePath);
122
+ } catch {
123
+ return null;
124
+ }
125
+ const contentDir = getContentDir();
126
+ const relativePath = path.relative(contentDir, page.filePath);
127
+ const withoutExt = relativePath.replace(/\.(mdx|md)$/, '');
128
+ const mod = relativePath.endsWith('.md')
129
+ ? await import(`@content/${withoutExt}.md`)
130
+ : await import(`@content/${withoutExt}.mdx`);
131
+ return mod.default;
132
+ }
133
+
134
+ export async function buildPageTree(): Promise<PageTree> {
135
+ const s = await getSource();
136
+ const pages = s.getPages();
137
+ const folders = new Map<string, PageTreeItem[]>();
138
+ const rootPages: PageTreeItem[] = [];
139
+
140
+ for (const page of pages) {
141
+ const data = page.data as Record<string, unknown>;
142
+ const isIndex = page.url === '/';
28
143
  const item: PageTreeItem = {
29
144
  type: 'page',
30
- name: data.title ?? page.slugs.join('/') ?? 'Untitled',
145
+ name: (data.title as string) ?? page.slugs.join('/') ?? 'Untitled',
31
146
  url: page.url,
32
- order: data.order ?? (isIndex ? 0 : undefined),
33
- }
147
+ order: (data.order as number | undefined) ?? (isIndex ? 0 : undefined)
148
+ };
34
149
 
35
150
  if (page.slugs.length > 1) {
36
- const folder = page.slugs[0]
151
+ const folder = page.slugs[0];
37
152
  if (!folders.has(folder)) {
38
- folders.set(folder, [])
153
+ folders.set(folder, []);
39
154
  }
40
- folders.get(folder)?.push(item)
155
+ folders.get(folder)?.push(item);
41
156
  } else {
42
- rootPages.push(item)
157
+ rootPages.push(item);
43
158
  }
44
- })
159
+ }
45
160
 
46
161
  const sortByOrder = (items: PageTreeItem[]) =>
47
- items.sort((a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER))
162
+ items.sort(
163
+ (a, b) =>
164
+ (a.order ?? Number.MAX_SAFE_INTEGER) -
165
+ (b.order ?? Number.MAX_SAFE_INTEGER)
166
+ );
48
167
 
49
- const children: PageTreeItem[] = sortByOrder(rootPages)
168
+ const children: PageTreeItem[] = sortByOrder(rootPages);
50
169
 
51
- const folderItems: PageTreeItem[] = []
52
- folders.forEach((items, folder) => {
53
- const sorted = sortByOrder(items)
54
- const indexPage = items.find(item => item.url === `/${folder}`)
55
- const folderOrder = indexPage?.order ?? sorted[0]?.order
170
+ const folderItems: PageTreeItem[] = [];
171
+ for (const [folder, items] of folders) {
172
+ const sorted = sortByOrder(items);
173
+ const indexPage = items.find(item => item.url === `/${folder}`);
174
+ const folderOrder = indexPage?.order ?? sorted[0]?.order;
56
175
  folderItems.push({
57
176
  type: 'folder',
58
- name: folder.charAt(0).toUpperCase() + folder.slice(1),
177
+ name: `${folder.charAt(0).toUpperCase()}${folder.slice(1)}`,
59
178
  order: folderOrder,
60
- children: sorted,
61
- })
62
- })
179
+ children: sorted
180
+ });
181
+ }
63
182
 
64
- children.push(...sortByOrder(folderItems))
183
+ children.push(...sortByOrder(folderItems));
65
184
 
66
- return { name: 'root', children }
185
+ return { name: 'root', children };
67
186
  }
@@ -0,0 +1,33 @@
1
+ import { cx } from 'class-variance-authority';
2
+ import type { ReactNode } from 'react';
3
+ import { Search } from '@/components/ui/search';
4
+ import { buildApiPageTree } from '@/lib/api-routes';
5
+ import { usePageContext } from '@/lib/page-context';
6
+ import { getTheme } from '@/themes/registry';
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
20
+ config={config}
21
+ tree={tree}
22
+ classNames={{
23
+ layout: cx(styles.layout, className),
24
+ body: styles.body,
25
+ sidebar: styles.sidebar,
26
+ content: styles.content
27
+ }}
28
+ >
29
+ <Search className={styles.hiddenSearch} />
30
+ {children}
31
+ </Layout>
32
+ );
33
+ }
@@ -0,0 +1,73 @@
1
+ import { Flex, Headline, Text } from '@raystack/apsara';
2
+ import type { OpenAPIV3 } from 'openapi-types';
3
+ import { EndpointPage } from '@/components/api';
4
+ import { findApiOperation } from '@/lib/api-routes';
5
+ import { Head } from '@/lib/head';
6
+ import type { ApiSpec } from '@/lib/openapi';
7
+ import { usePageContext } from '@/lib/page-context';
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 =
34
+ operation.summary ?? `${match.method.toUpperCase()} ${match.path}`;
35
+
36
+ return (
37
+ <>
38
+ <Head title={title} description={operation.description} config={config} />
39
+ <EndpointPage
40
+ method={match.method}
41
+ path={match.path}
42
+ operation={match.operation}
43
+ serverUrl={match.spec.server.url}
44
+ specName={match.spec.name}
45
+ auth={match.spec.auth}
46
+ />
47
+ </>
48
+ );
49
+ }
50
+
51
+ function ApiLanding({ specs }: { specs: ApiSpec[] }) {
52
+ return (
53
+ <Flex
54
+ direction='column'
55
+ gap='large'
56
+ style={{ padding: 'var(--rs-space-7)' }}
57
+ >
58
+ <Headline size='medium' as='h1'>
59
+ API Reference
60
+ </Headline>
61
+ {specs.map(spec => (
62
+ <Flex key={spec.name} direction='column' gap='small'>
63
+ <Headline size='small' as='h2'>
64
+ {spec.name}
65
+ </Headline>
66
+ {spec.document.info.description && (
67
+ <Text size={3}>{spec.document.info.description}</Text>
68
+ )}
69
+ </Flex>
70
+ ))}
71
+ </Flex>
72
+ );
73
+ }
@@ -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 { Head } from '@/lib/head';
2
+ import { usePageContext } from '@/lib/page-context';
3
+ import { getTheme } from '@/themes/registry';
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,17 @@
1
+ import { Flex, Headline, Text } from '@raystack/apsara';
2
+
3
+ export function NotFound() {
4
+ return (
5
+ <Flex
6
+ direction='column'
7
+ align='center'
8
+ justify='center'
9
+ style={{ minHeight: '60vh' }}
10
+ >
11
+ <Headline size='large' as='h1'>
12
+ 404
13
+ </Headline>
14
+ <Text size={3}>Page not found</Text>
15
+ </Flex>
16
+ );
17
+ }
@@ -0,0 +1,67 @@
1
+ import '@raystack/apsara/normalize.css';
2
+ import '@raystack/apsara/style.css';
3
+ import { ThemeProvider } from '@raystack/apsara';
4
+ import { useLocation } from 'react-router';
5
+ import { Head } from '@/lib/head';
6
+ import { usePageContext } from '@/lib/page-context';
7
+ import { ApiLayout } from '@/pages/ApiLayout';
8
+ import { ApiPage } from '@/pages/ApiPage';
9
+ import { DocsLayout } from '@/pages/DocsLayout';
10
+ import { DocsPage } from '@/pages/DocsPage';
11
+ import type { ChronicleConfig } from '@/types';
12
+
13
+ function resolveRoute(pathname: string) {
14
+ if (pathname.startsWith('/apis')) {
15
+ const slug = pathname
16
+ .replace(/^\/apis\/?/, '')
17
+ .split('/')
18
+ .filter(Boolean);
19
+ return { type: 'api' as const, slug };
20
+ }
21
+
22
+ const slug =
23
+ pathname === '/' ? [] : pathname.slice(1).split('/').filter(Boolean);
24
+ return { type: 'docs' as const, slug };
25
+ }
26
+
27
+ export function App() {
28
+ const { pathname } = useLocation();
29
+ const { config } = usePageContext();
30
+ const route = resolveRoute(pathname);
31
+
32
+ return (
33
+ <ThemeProvider enableSystem>
34
+ <RootHead config={config} />
35
+ {route.type === 'api' ? (
36
+ <ApiLayout>
37
+ <ApiPage slug={route.slug} />
38
+ </ApiLayout>
39
+ ) : (
40
+ <DocsLayout>
41
+ <DocsPage slug={route.slug} />
42
+ </DocsLayout>
43
+ )}
44
+ </ThemeProvider>
45
+ );
46
+ }
47
+
48
+ function RootHead({ config }: { config: ChronicleConfig }) {
49
+ return (
50
+ <Head
51
+ title={config.title}
52
+ description={config.description}
53
+ config={config}
54
+ jsonLd={
55
+ config.url
56
+ ? {
57
+ '@context': 'https://schema.org',
58
+ '@type': 'WebSite',
59
+ name: config.title,
60
+ description: config.description,
61
+ url: config.url
62
+ }
63
+ : undefined
64
+ }
65
+ />
66
+ );
67
+ }
@@ -0,0 +1,69 @@
1
+ import { defineHandler, HTTPError } from 'nitro';
2
+ import { loadConfig } from '@/lib/config';
3
+ import { loadApiSpecs } from '@/lib/openapi';
4
+
5
+ interface ProxyRequest {
6
+ specName: string;
7
+ method: string;
8
+ path: string;
9
+ headers?: Record<string, string>;
10
+ body?: unknown;
11
+ }
12
+
13
+ export default defineHandler(async event => {
14
+ if (event.req.method !== 'POST') {
15
+ throw new HTTPError({ status: 405, message: 'Method not allowed' });
16
+ }
17
+
18
+ const { specName, method, path, headers, body } =
19
+ (await event.req.json()) as ProxyRequest;
20
+
21
+ if (!specName || !method || !path) {
22
+ throw new HTTPError({
23
+ status: 400,
24
+ message: 'Missing specName, method, or path'
25
+ });
26
+ }
27
+
28
+ const config = loadConfig();
29
+ const specs = await loadApiSpecs(config.api ?? []);
30
+ const spec = specs.find(s => s.name === specName);
31
+
32
+ if (!spec) {
33
+ throw new HTTPError({ status: 404, message: `Unknown spec: ${specName}` });
34
+ }
35
+
36
+ if (/^[a-z]+:\/\//i.test(path) || path.includes('..')) {
37
+ throw new HTTPError({ status: 400, message: 'Invalid path' });
38
+ }
39
+
40
+ const url = spec.server.url + path;
41
+
42
+ try {
43
+ const response = await fetch(url, {
44
+ method,
45
+ headers,
46
+ body: body ? JSON.stringify(body) : undefined
47
+ });
48
+
49
+ const contentType = response.headers.get('content-type') ?? '';
50
+ const responseBody = contentType.includes('application/json')
51
+ ? await response.json()
52
+ : await response.text();
53
+
54
+ return {
55
+ status: response.status,
56
+ statusText: response.statusText,
57
+ body: responseBody
58
+ };
59
+ } catch (error) {
60
+ const message =
61
+ error instanceof Error
62
+ ? `${error.message}${error.cause ? `: ${(error.cause as Error).message}` : ''}`
63
+ : 'Request failed';
64
+ throw new HTTPError({
65
+ status: 502,
66
+ message: `Could not reach ${url}\n${message}`
67
+ });
68
+ }
69
+ });
@@ -0,0 +1,5 @@
1
+ import { defineHandler } from 'nitro';
2
+
3
+ export default defineHandler(() => {
4
+ return { status: 'ok' };
5
+ });