@raystack/chronicle 0.1.0-canary.323385a → 0.1.0-canary.4a4a3f8

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 (80) hide show
  1. package/dist/cli/index.js +268 -9914
  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/code.tsx +10 -1
  14. package/src/components/mdx/details.module.css +1 -26
  15. package/src/components/mdx/details.tsx +2 -3
  16. package/src/components/mdx/image.tsx +5 -34
  17. package/src/components/mdx/link.tsx +18 -15
  18. package/src/components/ui/breadcrumbs.tsx +8 -42
  19. package/src/components/ui/search.tsx +63 -51
  20. package/src/lib/api-routes.ts +6 -8
  21. package/src/lib/config.ts +31 -28
  22. package/src/lib/head.tsx +49 -0
  23. package/src/lib/mdx-loader.ts +21 -0
  24. package/src/lib/openapi.ts +8 -8
  25. package/src/lib/page-context.tsx +108 -0
  26. package/src/lib/source.ts +145 -56
  27. package/src/pages/ApiLayout.tsx +33 -0
  28. package/src/pages/ApiPage.tsx +73 -0
  29. package/src/pages/DocsLayout.tsx +18 -0
  30. package/src/pages/DocsPage.tsx +43 -0
  31. package/src/pages/NotFound.tsx +17 -0
  32. package/src/server/App.tsx +67 -0
  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 +117 -0
  39. package/src/server/entry-client.tsx +65 -0
  40. package/src/server/entry-server.tsx +95 -0
  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 +111 -0
  47. package/src/themes/default/Layout.tsx +78 -48
  48. package/src/themes/default/Page.module.css +44 -0
  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 +64 -45
  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 -63
  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/config.ts +11 -0
  61. package/src/types/content.ts +6 -21
  62. package/src/types/globals.d.ts +3 -0
  63. package/src/types/theme.ts +4 -3
  64. package/tsconfig.json +2 -3
  65. package/next.config.mjs +0 -10
  66. package/source.config.ts +0 -50
  67. package/src/app/[[...slug]]/layout.tsx +0 -15
  68. package/src/app/[[...slug]]/page.tsx +0 -57
  69. package/src/app/api/apis-proxy/route.ts +0 -59
  70. package/src/app/api/health/route.ts +0 -3
  71. package/src/app/api/search/route.ts +0 -90
  72. package/src/app/apis/[[...slug]]/layout.tsx +0 -26
  73. package/src/app/apis/[[...slug]]/page.tsx +0 -57
  74. package/src/app/layout.tsx +0 -26
  75. package/src/app/llms-full.txt/route.ts +0 -18
  76. package/src/app/llms.txt/route.ts +0 -15
  77. package/src/app/providers.tsx +0 -8
  78. package/src/cli/utils/process.ts +0 -7
  79. package/src/themes/default/font.ts +0 -6
  80. /package/src/{app/apis/[[...slug]]/layout.module.css → pages/ApiLayout.module.css} +0 -0
@@ -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
  }
package/src/lib/config.ts CHANGED
@@ -1,55 +1,58 @@
1
- import fs from 'fs'
2
- import path from 'path'
3
- import { parse } from 'yaml'
4
- import type { ChronicleConfig } from '@/types'
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parse } from 'yaml';
4
+ import type { ChronicleConfig } from '@/types';
5
5
 
6
- const CONFIG_FILE = 'chronicle.yaml'
6
+ const CONFIG_FILE = 'chronicle.yaml';
7
7
 
8
8
  const defaultConfig: ChronicleConfig = {
9
9
  title: 'Documentation',
10
10
  theme: { name: 'default' },
11
- search: { enabled: true, placeholder: 'Search...' },
12
- }
11
+ search: { enabled: true, placeholder: 'Search...' }
12
+ };
13
13
 
14
14
  function resolveConfigPath(): string | null {
15
- // Check project root via env var
16
- const projectRoot = process.env.CHRONICLE_PROJECT_ROOT
17
- if (projectRoot) {
18
- const rootPath = path.join(projectRoot, CONFIG_FILE)
19
- if (fs.existsSync(rootPath)) return rootPath
20
- }
21
- // Check cwd
22
- const cwdPath = path.join(process.cwd(), CONFIG_FILE)
23
- if (fs.existsSync(cwdPath)) return cwdPath
24
- // Check content dir
25
- const contentDir = process.env.CHRONICLE_CONTENT_DIR
15
+ const projectRoot =
16
+ typeof __CHRONICLE_PROJECT_ROOT__ !== 'undefined'
17
+ ? __CHRONICLE_PROJECT_ROOT__
18
+ : process.cwd();
19
+
20
+ const rootPath = path.join(projectRoot, CONFIG_FILE);
21
+ if (fs.existsSync(rootPath)) return rootPath;
22
+
23
+ const contentDir =
24
+ typeof __CHRONICLE_CONTENT_DIR__ !== 'undefined'
25
+ ? __CHRONICLE_CONTENT_DIR__
26
+ : undefined;
26
27
  if (contentDir) {
27
- const contentPath = path.join(contentDir, CONFIG_FILE)
28
- if (fs.existsSync(contentPath)) return contentPath
28
+ const contentPath = path.join(contentDir, CONFIG_FILE);
29
+ if (fs.existsSync(contentPath)) return contentPath;
29
30
  }
30
- return null
31
+
32
+ return null;
31
33
  }
32
34
 
33
35
  export function loadConfig(): ChronicleConfig {
34
- const configPath = resolveConfigPath()
36
+ const configPath = resolveConfigPath();
35
37
 
36
38
  if (!configPath) {
37
- return defaultConfig
39
+ return defaultConfig;
38
40
  }
39
41
 
40
- const raw = fs.readFileSync(configPath, 'utf-8')
41
- const userConfig = parse(raw) as Partial<ChronicleConfig>
42
+ const raw = fs.readFileSync(configPath, 'utf-8');
43
+ const userConfig = parse(raw) as Partial<ChronicleConfig>;
42
44
 
43
45
  return {
44
46
  ...defaultConfig,
45
47
  ...userConfig,
46
48
  theme: {
47
49
  name: userConfig.theme?.name ?? defaultConfig.theme!.name,
48
- colors: { ...defaultConfig.theme?.colors, ...userConfig.theme?.colors },
50
+ colors: { ...defaultConfig.theme?.colors, ...userConfig.theme?.colors }
49
51
  },
50
52
  search: { ...defaultConfig.search, ...userConfig.search },
51
53
  footer: userConfig.footer,
52
54
  api: userConfig.api,
53
55
  llms: { enabled: false, ...userConfig.llms },
54
- }
55
- }
56
+ analytics: { enabled: false, ...userConfig.analytics }
57
+ };
58
+ }
@@ -0,0 +1,49 @@
1
+ import type { ChronicleConfig } from '@/types';
2
+
3
+ export interface HeadProps {
4
+ title: string;
5
+ description?: string;
6
+ config: ChronicleConfig;
7
+ jsonLd?: Record<string, unknown>;
8
+ }
9
+
10
+ export function Head({ title, description, config, jsonLd }: HeadProps) {
11
+ const fullTitle = `${title} | ${config.title}`;
12
+ const ogParams = new URLSearchParams({ title });
13
+ if (description) ogParams.set('description', description);
14
+
15
+ return (
16
+ <>
17
+ <title>{fullTitle}</title>
18
+ {description && <meta name='description' content={description} />}
19
+
20
+ {config.url && (
21
+ <>
22
+ <meta property='og:title' content={title} />
23
+ {description && (
24
+ <meta property='og:description' content={description} />
25
+ )}
26
+ <meta property='og:site_name' content={config.title} />
27
+ <meta property='og:type' content='website' />
28
+ <meta property='og:image' content={`/og?${ogParams.toString()}`} />
29
+ <meta property='og:image:width' content='1200' />
30
+ <meta property='og:image:height' content='630' />
31
+
32
+ <meta name='twitter:card' content='summary_large_image' />
33
+ <meta name='twitter:title' content={title} />
34
+ {description && (
35
+ <meta name='twitter:description' content={description} />
36
+ )}
37
+ <meta name='twitter:image' content={`/og?${ogParams.toString()}`} />
38
+ </>
39
+ )}
40
+
41
+ {jsonLd && (
42
+ <script
43
+ type='application/ld+json'
44
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd, null, 2) }}
45
+ />
46
+ )}
47
+ </>
48
+ );
49
+ }
@@ -0,0 +1,21 @@
1
+ import React, { type ReactNode } from 'react';
2
+ import type { TableOfContents } from 'fumadocs-core/toc';
3
+ import { mdxComponents } from '@/components/mdx';
4
+
5
+ const contentModules = import.meta.glob<{ default?: React.ComponentType<any>; toc?: TableOfContents }>(
6
+ '../../.content/**/*.{mdx,md}'
7
+ );
8
+
9
+ export async function loadMdxModule(relativePath: string): Promise<{ content: ReactNode; toc: TableOfContents }> {
10
+ const withoutExt = relativePath.replace(/\.(mdx|md)$/, '');
11
+ const key = relativePath.endsWith('.md')
12
+ ? `../../.content/${withoutExt}.md`
13
+ : `../../.content/${withoutExt}.mdx`;
14
+ const loader = contentModules[key];
15
+ if (!loader) return { content: null, toc: [] };
16
+ const mod = await loader();
17
+ const content = mod.default
18
+ ? React.createElement(mod.default, { components: mdxComponents })
19
+ : null;
20
+ return { content, toc: mod.toc ?? [] };
21
+ }
@@ -1,5 +1,5 @@
1
- import fs from 'fs'
2
- import path from 'path'
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
3
  import { parse as parseYaml } from 'yaml'
4
4
  import type { OpenAPIV2, OpenAPIV3 } from 'openapi-types'
5
5
  import type { ApiConfig, ApiServerConfig, ApiAuthConfig } from '@/types/config'
@@ -17,14 +17,14 @@ export interface ApiSpec {
17
17
  export type { SchemaField } from './schema'
18
18
  export { flattenSchema } from './schema'
19
19
 
20
- export function loadApiSpecs(apiConfigs: ApiConfig[]): ApiSpec[] {
21
- const contentDir = process.env.CHRONICLE_CONTENT_DIR ?? process.cwd()
22
- return apiConfigs.map((config) => loadApiSpec(config, contentDir))
20
+ export async function loadApiSpecs(apiConfigs: ApiConfig[]): Promise<ApiSpec[]> {
21
+ const projectRoot = typeof __CHRONICLE_PROJECT_ROOT__ !== 'undefined' ? __CHRONICLE_PROJECT_ROOT__ : process.cwd()
22
+ return Promise.all(apiConfigs.map((config) => loadApiSpec(config, projectRoot)))
23
23
  }
24
24
 
25
- export function loadApiSpec(config: ApiConfig, contentDir: string): ApiSpec {
26
- const specPath = path.resolve(contentDir, config.spec)
27
- const raw = fs.readFileSync(specPath, 'utf-8')
25
+ export async function loadApiSpec(config: ApiConfig, projectRoot: string): Promise<ApiSpec> {
26
+ const specPath = path.resolve(projectRoot, config.spec)
27
+ const raw = await fs.readFile(specPath, 'utf-8')
28
28
  const isYaml = specPath.endsWith('.yaml') || specPath.endsWith('.yml')
29
29
  const doc = (isYaml ? parseYaml(raw) : JSON.parse(raw)) as OpenAPIV2.Document | OpenAPIV3.Document
30
30
 
@@ -0,0 +1,108 @@
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ useContext,
5
+ useEffect,
6
+ useState
7
+ } from 'react';
8
+ import { useLocation } from 'react-router';
9
+ import { loadMdxModule } from '@/lib/mdx-loader';
10
+ import type { ApiSpec } from '@/lib/openapi';
11
+ import type { ChronicleConfig, Frontmatter, Root, TableOfContents } from '@/types';
12
+
13
+ interface PageData {
14
+ slug: string[];
15
+ frontmatter: Frontmatter;
16
+ content: ReactNode;
17
+ toc: TableOfContents;
18
+ }
19
+
20
+ interface PageContextValue {
21
+ config: ChronicleConfig;
22
+ tree: Root;
23
+ page: PageData | null;
24
+ apiSpecs: ApiSpec[];
25
+ }
26
+
27
+ const PageContext = createContext<PageContextValue | null>(null);
28
+
29
+ export function usePageContext(): PageContextValue {
30
+ const ctx = useContext(PageContext);
31
+ if (!ctx) {
32
+ console.error('usePageContext: no context found!');
33
+ return {
34
+ config: { title: 'Documentation' },
35
+ tree: { name: 'root', children: [] } as Root,
36
+ page: null,
37
+ apiSpecs: []
38
+ };
39
+ }
40
+ return ctx;
41
+ }
42
+
43
+ interface PageProviderProps {
44
+ initialConfig: ChronicleConfig;
45
+ initialTree: Root;
46
+ initialPage: PageData | null;
47
+ initialApiSpecs: ApiSpec[];
48
+ children: ReactNode;
49
+ }
50
+
51
+ export function PageProvider({
52
+ initialConfig,
53
+ initialTree,
54
+ initialPage,
55
+ initialApiSpecs,
56
+ children
57
+ }: PageProviderProps) {
58
+ const { pathname } = useLocation();
59
+ const [tree] = useState<Root>(initialTree);
60
+ const [page, setPage] = useState<PageData | null>(initialPage);
61
+ const [apiSpecs, setApiSpecs] = useState<ApiSpec[]>(initialApiSpecs);
62
+ const [currentPath, setCurrentPath] = useState(pathname);
63
+
64
+ useEffect(() => {
65
+ if (pathname === currentPath) return;
66
+ setCurrentPath(pathname);
67
+
68
+ const cancelled = { current: false };
69
+
70
+ if (pathname.startsWith('/apis')) {
71
+ if (apiSpecs.length === 0) {
72
+ fetch('/api/specs')
73
+ .then(res => res.json())
74
+ .then(specs => {
75
+ if (!cancelled.current) setApiSpecs(specs);
76
+ })
77
+ .catch(() => {});
78
+ }
79
+ return () => { cancelled.current = true; };
80
+ }
81
+
82
+ const slug = pathname === '/'
83
+ ? []
84
+ : pathname.slice(1).split('/').filter(Boolean);
85
+
86
+ const apiPath = slug.length === 0 ? '/api/page/' : `/api/page/${slug.join('/')}`;
87
+
88
+ fetch(apiPath)
89
+ .then(res => res.json())
90
+ .then(async (data: { frontmatter: Frontmatter; relativePath: string }) => {
91
+ if (cancelled.current) return;
92
+ const { content, toc } = await loadMdxModule(data.relativePath);
93
+ if (cancelled.current) return;
94
+ setPage({ slug, frontmatter: data.frontmatter, content, toc });
95
+ })
96
+ .catch(() => {});
97
+
98
+ return () => { cancelled.current = true; };
99
+ }, [pathname]);
100
+
101
+ return (
102
+ <PageContext.Provider
103
+ value={{ config: initialConfig, tree, page, apiSpecs }}
104
+ >
105
+ {children}
106
+ </PageContext.Provider>
107
+ );
108
+ }
package/src/lib/source.ts CHANGED
@@ -1,67 +1,156 @@
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 type { Root, Node, Folder } from 'fumadocs-core/page-tree';
5
+ import matter from 'gray-matter';
6
+ import type { MDXContent } from 'mdx/types';
7
+ import type { TableOfContents } from 'fumadocs-core/toc';
8
+ import type { Frontmatter } from '@/types';
9
+
10
+ function getContentDir(): string {
11
+ return __CHRONICLE_CONTENT_DIR__ || path.join(process.cwd(), 'content');
18
12
  }
19
13
 
20
- export function buildPageTree(): PageTree {
21
- const pages = source.getPages()
22
- const folders = new Map<string, PageTreeItem[]>()
23
- const rootPages: PageTreeItem[] = []
24
-
25
- pages.forEach((page) => {
26
- const data = page.data as { title?: string; order?: number }
27
- const isIndex = page.url === '/'
28
- const item: PageTreeItem = {
29
- type: 'page',
30
- name: data.title ?? page.slugs.join('/') ?? 'Untitled',
31
- url: page.url,
32
- order: data.order ?? (isIndex ? 0 : undefined),
33
- }
14
+ async function scanFiles(contentDir: string) {
15
+ const files: {
16
+ type: 'page' | 'meta';
17
+ path: string;
18
+ data: Record<string, unknown>;
19
+ }[] = [];
20
+
21
+ async function scan(dir: string, prefix: string[] = []) {
22
+ try {
23
+ const entries = await fs.readdir(dir, { withFileTypes: true });
24
+ for (const entry of entries) {
25
+ if (entry.name.startsWith('.') || entry.name === 'node_modules')
26
+ continue;
27
+ const fullPath = path.join(dir, entry.name);
28
+ const relativePath = [...prefix, entry.name].join('/');
34
29
 
35
- if (page.slugs.length > 1) {
36
- const folder = page.slugs[0]
37
- if (!folders.has(folder)) {
38
- folders.set(folder, [])
30
+ if (entry.isDirectory()) {
31
+ await scan(fullPath, [...prefix, entry.name]);
32
+ continue;
33
+ }
34
+
35
+ if (entry.name.endsWith('.mdx') || entry.name.endsWith('.md')) {
36
+ const raw = await fs.readFile(fullPath, 'utf-8');
37
+ const { data } = matter(raw);
38
+ files.push({
39
+ type: 'page',
40
+ path: relativePath,
41
+ data: { ...data, _relativePath: relativePath }
42
+ });
43
+ } else if (entry.name === 'meta.json' || entry.name === 'meta.yaml') {
44
+ const raw = await fs.readFile(fullPath, 'utf-8');
45
+ const data = entry.name.endsWith('.json')
46
+ ? JSON.parse(raw)
47
+ : matter(raw).data;
48
+ files.push({ type: 'meta', path: relativePath, data });
49
+ }
39
50
  }
40
- folders.get(folder)?.push(item)
41
- } else {
42
- rootPages.push(item)
51
+ } catch {
52
+ /* directory not readable */
43
53
  }
44
- })
54
+ }
55
+
56
+ await scan(contentDir);
57
+ return files;
58
+ }
59
+
60
+ let cachedSource: ReturnType<typeof loader> | null = null;
61
+
62
+ async function getSource() {
63
+ if (cachedSource) return cachedSource;
64
+ const contentDir = getContentDir();
65
+ const files = await scanFiles(contentDir);
66
+ cachedSource = loader({
67
+ source: { files },
68
+ baseUrl: '/'
69
+ });
70
+ return cachedSource;
71
+ }
72
+
73
+ export { getSource as source };
74
+
75
+ export function invalidate() {
76
+ cachedSource = null;
77
+ }
45
78
 
46
- const sortByOrder = (items: PageTreeItem[]) =>
47
- items.sort((a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER))
79
+ function getOrder(node: Node, orderMap: Map<string, number>): number | undefined {
80
+ if (node.type === 'page') return orderMap.get(node.url);
81
+ if (node.type === 'folder' && node.index) return orderMap.get(node.index.url);
82
+ return undefined;
83
+ }
84
+
85
+ function sortNodes(nodes: Node[], orderMap: Map<string, number>): Node[] {
86
+ return [...nodes]
87
+ .map(n =>
88
+ n.type === 'folder'
89
+ ? ({ ...n, children: sortNodes(n.children, orderMap) } as Folder)
90
+ : n
91
+ )
92
+ .sort(
93
+ (a, b) =>
94
+ (getOrder(a, orderMap) ?? Number.MAX_SAFE_INTEGER) -
95
+ (getOrder(b, orderMap) ?? Number.MAX_SAFE_INTEGER)
96
+ );
97
+ }
98
+
99
+ function sortTreeByOrder(tree: Root, pages: { url: string; data: unknown }[]): Root {
100
+ const orderMap = new Map<string, number>();
101
+ for (const page of pages) {
102
+ const d = page.data as Record<string, unknown>;
103
+ const order = d.order as number | undefined;
104
+ if (order !== undefined) orderMap.set(page.url, order);
105
+ if (page.url === '/') orderMap.set('/', order ?? 0);
106
+ }
107
+ return { ...tree, children: sortNodes(tree.children, orderMap) };
108
+ }
109
+
110
+ export async function getPageTree(): Promise<Root> {
111
+ const s = await getSource();
112
+ return sortTreeByOrder(s.pageTree as Root, s.getPages());
113
+ }
114
+
115
+ export async function getPages() {
116
+ const s = await getSource();
117
+ return s.getPages();
118
+ }
48
119
 
49
- const children: PageTreeItem[] = sortByOrder(rootPages)
120
+ export async function getPage(slugs?: string[]) {
121
+ const s = await getSource();
122
+ return s.getPage(slugs);
123
+ }
50
124
 
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
56
- folderItems.push({
57
- type: 'folder',
58
- name: folder.charAt(0).toUpperCase() + folder.slice(1),
59
- order: folderOrder,
60
- children: sorted,
61
- })
62
- })
125
+ export function extractFrontmatter(page: { data: unknown }, fallbackTitle?: string): Frontmatter {
126
+ const d = page.data as Record<string, unknown>;
127
+ return {
128
+ title: (d.title as string) ?? fallbackTitle ?? 'Untitled',
129
+ description: d.description as string | undefined,
130
+ order: d.order as number | undefined,
131
+ icon: d.icon as string | undefined,
132
+ lastModified: d.lastModified as string | undefined,
133
+ };
134
+ }
63
135
 
64
- children.push(...sortByOrder(folderItems))
136
+ export function getRelativePath(page: { data: unknown }): string {
137
+ return ((page.data as Record<string, unknown>)._relativePath as string) ?? '';
138
+ }
65
139
 
66
- return { name: 'root', children }
140
+ export async function loadPageModule(
141
+ relativePath: string
142
+ ): Promise<{ default: MDXContent | null; toc: TableOfContents }> {
143
+ if (!relativePath) return { default: null, toc: [] };
144
+ const contentDir = getContentDir();
145
+ const fullPath = path.join(contentDir, relativePath);
146
+ try {
147
+ await fs.access(fullPath);
148
+ } catch {
149
+ return { default: null, toc: [] };
150
+ }
151
+ const withoutExt = relativePath.replace(/\.(mdx|md)$/, '');
152
+ const mod = relativePath.endsWith('.md')
153
+ ? await import(`../../.content/${withoutExt}.md`)
154
+ : await import(`../../.content/${withoutExt}.mdx`);
155
+ return { default: mod.default ?? null, toc: mod.toc ?? [] };
67
156
  }
@@ -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
+ }