@raystack/chronicle 0.1.0-canary.0efaef0

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 (115) hide show
  1. package/bin/chronicle.js +2 -0
  2. package/dist/cli/index.js +276 -0
  3. package/package.json +71 -0
  4. package/src/cli/commands/build.ts +34 -0
  5. package/src/cli/commands/dev.ts +32 -0
  6. package/src/cli/commands/init.ts +69 -0
  7. package/src/cli/commands/serve.ts +40 -0
  8. package/src/cli/commands/start.ts +28 -0
  9. package/src/cli/index.ts +21 -0
  10. package/src/cli/utils/config.ts +42 -0
  11. package/src/cli/utils/index.ts +3 -0
  12. package/src/cli/utils/resolve.ts +10 -0
  13. package/src/cli/utils/scaffold.ts +18 -0
  14. package/src/components/api/code-snippets.module.css +7 -0
  15. package/src/components/api/code-snippets.tsx +76 -0
  16. package/src/components/api/endpoint-page.module.css +58 -0
  17. package/src/components/api/endpoint-page.tsx +283 -0
  18. package/src/components/api/field-row.module.css +126 -0
  19. package/src/components/api/field-row.tsx +204 -0
  20. package/src/components/api/field-section.module.css +24 -0
  21. package/src/components/api/field-section.tsx +100 -0
  22. package/src/components/api/index.ts +8 -0
  23. package/src/components/api/json-editor.module.css +9 -0
  24. package/src/components/api/json-editor.tsx +61 -0
  25. package/src/components/api/key-value-editor.module.css +13 -0
  26. package/src/components/api/key-value-editor.tsx +62 -0
  27. package/src/components/api/method-badge.module.css +4 -0
  28. package/src/components/api/method-badge.tsx +29 -0
  29. package/src/components/api/response-panel.module.css +8 -0
  30. package/src/components/api/response-panel.tsx +44 -0
  31. package/src/components/common/breadcrumb.tsx +3 -0
  32. package/src/components/common/button.tsx +3 -0
  33. package/src/components/common/callout.module.css +7 -0
  34. package/src/components/common/callout.tsx +27 -0
  35. package/src/components/common/code-block.tsx +3 -0
  36. package/src/components/common/dialog.tsx +3 -0
  37. package/src/components/common/index.ts +10 -0
  38. package/src/components/common/input-field.tsx +3 -0
  39. package/src/components/common/sidebar.tsx +3 -0
  40. package/src/components/common/switch.tsx +3 -0
  41. package/src/components/common/table.tsx +3 -0
  42. package/src/components/common/tabs.tsx +3 -0
  43. package/src/components/mdx/code.module.css +42 -0
  44. package/src/components/mdx/code.tsx +27 -0
  45. package/src/components/mdx/details.module.css +37 -0
  46. package/src/components/mdx/details.tsx +18 -0
  47. package/src/components/mdx/image.tsx +9 -0
  48. package/src/components/mdx/index.tsx +35 -0
  49. package/src/components/mdx/link.tsx +41 -0
  50. package/src/components/mdx/mermaid.module.css +9 -0
  51. package/src/components/mdx/mermaid.tsx +37 -0
  52. package/src/components/mdx/paragraph.module.css +8 -0
  53. package/src/components/mdx/paragraph.tsx +19 -0
  54. package/src/components/mdx/table.tsx +40 -0
  55. package/src/components/ui/breadcrumbs.tsx +72 -0
  56. package/src/components/ui/client-theme-switcher.tsx +18 -0
  57. package/src/components/ui/footer.module.css +27 -0
  58. package/src/components/ui/footer.tsx +30 -0
  59. package/src/components/ui/search.module.css +111 -0
  60. package/src/components/ui/search.tsx +218 -0
  61. package/src/lib/api-routes.ts +120 -0
  62. package/src/lib/config.ts +58 -0
  63. package/src/lib/get-llm-text.ts +10 -0
  64. package/src/lib/head.tsx +49 -0
  65. package/src/lib/index.ts +2 -0
  66. package/src/lib/openapi.ts +188 -0
  67. package/src/lib/page-context.tsx +117 -0
  68. package/src/lib/remark-unused-directives.ts +30 -0
  69. package/src/lib/schema.ts +99 -0
  70. package/src/lib/snippet-generators.ts +87 -0
  71. package/src/lib/source.ts +186 -0
  72. package/src/pages/ApiLayout.module.css +22 -0
  73. package/src/pages/ApiLayout.tsx +33 -0
  74. package/src/pages/ApiPage.tsx +73 -0
  75. package/src/pages/DocsLayout.tsx +18 -0
  76. package/src/pages/DocsPage.tsx +43 -0
  77. package/src/pages/NotFound.tsx +17 -0
  78. package/src/server/App.tsx +67 -0
  79. package/src/server/api/apis-proxy.ts +69 -0
  80. package/src/server/api/health.ts +5 -0
  81. package/src/server/api/page/[...slug].ts +18 -0
  82. package/src/server/api/search.ts +170 -0
  83. package/src/server/api/specs.ts +9 -0
  84. package/src/server/build-search-index.ts +117 -0
  85. package/src/server/entry-client.tsx +73 -0
  86. package/src/server/entry-server.tsx +95 -0
  87. package/src/server/routes/llms.txt.ts +61 -0
  88. package/src/server/routes/og.tsx +75 -0
  89. package/src/server/routes/robots.txt.ts +11 -0
  90. package/src/server/routes/sitemap.xml.ts +39 -0
  91. package/src/server/utils/safe-path.ts +17 -0
  92. package/src/server/vite-config.ts +71 -0
  93. package/src/themes/default/Layout.module.css +81 -0
  94. package/src/themes/default/Layout.tsx +160 -0
  95. package/src/themes/default/Page.module.css +46 -0
  96. package/src/themes/default/Page.tsx +19 -0
  97. package/src/themes/default/Toc.module.css +48 -0
  98. package/src/themes/default/Toc.tsx +68 -0
  99. package/src/themes/default/index.ts +11 -0
  100. package/src/themes/paper/ChapterNav.module.css +71 -0
  101. package/src/themes/paper/ChapterNav.tsx +115 -0
  102. package/src/themes/paper/Layout.module.css +33 -0
  103. package/src/themes/paper/Layout.tsx +37 -0
  104. package/src/themes/paper/Page.module.css +181 -0
  105. package/src/themes/paper/Page.tsx +126 -0
  106. package/src/themes/paper/ReadingProgress.module.css +132 -0
  107. package/src/themes/paper/ReadingProgress.tsx +315 -0
  108. package/src/themes/paper/index.ts +8 -0
  109. package/src/themes/registry.ts +14 -0
  110. package/src/types/config.ts +80 -0
  111. package/src/types/content.ts +36 -0
  112. package/src/types/globals.d.ts +4 -0
  113. package/src/types/index.ts +3 -0
  114. package/src/types/theme.ts +22 -0
  115. package/tsconfig.json +29 -0
@@ -0,0 +1,61 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import matter from 'gray-matter';
4
+ import { defineHandler, HTTPError } from 'nitro';
5
+ import { loadConfig } from '@/lib/config';
6
+
7
+ function getContentDir(): string {
8
+ return __CHRONICLE_CONTENT_DIR__ || path.join(process.cwd(), 'content');
9
+ }
10
+
11
+ async function scanPages(): Promise<{ title: string; url: string }[]> {
12
+ const contentDir = getContentDir();
13
+ const pages: { title: string; url: string }[] = [];
14
+
15
+ async function scan(dir: string, prefix: string[] = []) {
16
+ try {
17
+ const entries = await fs.readdir(dir, { withFileTypes: true });
18
+ for (const entry of entries) {
19
+ if (entry.name.startsWith('.') || entry.name === 'node_modules')
20
+ continue;
21
+ const fullPath = path.join(dir, entry.name);
22
+
23
+ if (entry.isDirectory()) {
24
+ await scan(fullPath, [...prefix, entry.name]);
25
+ continue;
26
+ }
27
+
28
+ if (!entry.name.endsWith('.mdx') && !entry.name.endsWith('.md'))
29
+ continue;
30
+
31
+ const raw = await fs.readFile(fullPath, 'utf-8');
32
+ const { data: fm } = matter(raw);
33
+ const baseName = entry.name.replace(/\.(mdx|md)$/, '');
34
+ const slugs = baseName === 'index' ? prefix : [...prefix, baseName];
35
+ const url = slugs.length === 0 ? '/' : `/${slugs.join('/')}`;
36
+
37
+ pages.push({ title: fm.title ?? baseName, url });
38
+ }
39
+ } catch {
40
+ /* directory not readable */
41
+ }
42
+ }
43
+
44
+ await scan(contentDir);
45
+ return pages;
46
+ }
47
+
48
+ export default defineHandler(async event => {
49
+ const config = loadConfig();
50
+
51
+ if (!config.llms?.enabled) {
52
+ throw new HTTPError({ status: 404, message: 'Not Found' });
53
+ }
54
+
55
+ const pages = await scanPages();
56
+ const index = pages.map(p => `- [${p.title}](${p.url})`).join('\n');
57
+ const body = `# ${config.title}\n\n${config.description ?? ''}\n\n${index}`;
58
+
59
+ event.res.headers.set('Content-Type', 'text/plain');
60
+ return body;
61
+ });
@@ -0,0 +1,75 @@
1
+ import { defineHandler } from 'nitro';
2
+ import React from 'react';
3
+ import satori from 'satori';
4
+ import { loadConfig } from '@/lib/config';
5
+
6
+ let fontData: ArrayBuffer | null = null;
7
+
8
+ async function loadFont(): Promise<ArrayBuffer> {
9
+ if (fontData) return fontData;
10
+
11
+ try {
12
+ const response = await fetch(
13
+ 'https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hiA.woff2'
14
+ );
15
+ fontData = await response.arrayBuffer();
16
+ } catch {
17
+ fontData = new ArrayBuffer(0);
18
+ }
19
+
20
+ return fontData;
21
+ }
22
+
23
+ export default defineHandler(async event => {
24
+ const config = loadConfig();
25
+ const title = event.url.searchParams.get('title') ?? config.title;
26
+ const description = event.url.searchParams.get('description') ?? '';
27
+ const siteName = config.title;
28
+
29
+ const font = await loadFont();
30
+
31
+ const svg = await satori(
32
+ <div
33
+ style={{
34
+ height: '100%',
35
+ width: '100%',
36
+ display: 'flex',
37
+ flexDirection: 'column',
38
+ justifyContent: 'center',
39
+ padding: '60px 80px',
40
+ backgroundColor: '#0a0a0a',
41
+ color: '#fafafa',
42
+ }}
43
+ >
44
+ <div style={{ fontSize: 24, color: '#888', marginBottom: 16 }}>
45
+ {siteName}
46
+ </div>
47
+ <div
48
+ style={{
49
+ fontSize: 56,
50
+ fontWeight: 700,
51
+ lineHeight: 1.2,
52
+ marginBottom: 24,
53
+ }}
54
+ >
55
+ {title}
56
+ </div>
57
+ {description && (
58
+ <div style={{ fontSize: 24, color: '#999', lineHeight: 1.4 }}>
59
+ {description}
60
+ </div>
61
+ )}
62
+ </div>,
63
+ {
64
+ width: 1200,
65
+ height: 630,
66
+ fonts: [
67
+ { name: 'Inter', data: font, weight: 400, style: 'normal' as const },
68
+ ],
69
+ },
70
+ );
71
+
72
+ event.res.headers.set('Content-Type', 'image/svg+xml');
73
+ event.res.headers.set('Cache-Control', 'public, max-age=86400');
74
+ return svg;
75
+ });
@@ -0,0 +1,11 @@
1
+ import { defineHandler } from 'nitro';
2
+ import { loadConfig } from '@/lib/config';
3
+
4
+ export default defineHandler(event => {
5
+ const config = loadConfig();
6
+ const sitemap = config.url ? `\nSitemap: ${config.url}/sitemap.xml` : '';
7
+ const body = `User-agent: *\nAllow: /${sitemap}`;
8
+
9
+ event.res.headers.set('Content-Type', 'text/plain');
10
+ return body;
11
+ });
@@ -0,0 +1,39 @@
1
+ import { defineHandler } from 'nitro';
2
+ import { buildApiRoutes } from '@/lib/api-routes';
3
+ import { loadConfig } from '@/lib/config';
4
+ import { loadApiSpecs } from '@/lib/openapi';
5
+ import { getPages } from '@/lib/source';
6
+
7
+ export default defineHandler(async event => {
8
+ const config = loadConfig();
9
+
10
+ if (!config.url) {
11
+ event.res.headers.set('Content-Type', 'application/xml');
12
+ return '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"/>';
13
+ }
14
+
15
+ const baseUrl = config.url.replace(/\/$/, '');
16
+
17
+ const pages = await getPages();
18
+ const docPages = pages.map(page => {
19
+ const lastmod = page.frontmatter.lastModified
20
+ ? `<lastmod>${new Date(page.frontmatter.lastModified).toISOString()}</lastmod>`
21
+ : '';
22
+ return `<url><loc>${baseUrl}/${page.slugs.join('/')}</loc>${lastmod}</url>`;
23
+ });
24
+
25
+ const apiPages = config.api?.length
26
+ ? buildApiRoutes(await loadApiSpecs(config.api)).map(
27
+ route => `<url><loc>${baseUrl}/apis/${route.slug.join('/')}</loc></url>`
28
+ )
29
+ : [];
30
+
31
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
32
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
33
+ <url><loc>${baseUrl}</loc></url>
34
+ ${[...docPages, ...apiPages].join('\n')}
35
+ </urlset>`;
36
+
37
+ event.res.headers.set('Content-Type', 'application/xml');
38
+ return xml;
39
+ });
@@ -0,0 +1,17 @@
1
+ import path from 'node:path';
2
+
3
+ /**
4
+ * Resolve a URL path within a base directory, preventing path traversal.
5
+ * Returns null if the resolved path escapes the base directory.
6
+ */
7
+ export function safePath(baseDir: string, urlPath: string): string | null {
8
+ const decoded = decodeURIComponent(urlPath.split('?')[0]);
9
+ const resolved = path.resolve(baseDir, '.' + decoded);
10
+ if (
11
+ !resolved.startsWith(path.resolve(baseDir) + path.sep) &&
12
+ resolved !== path.resolve(baseDir)
13
+ ) {
14
+ return null;
15
+ }
16
+ return resolved;
17
+ }
@@ -0,0 +1,71 @@
1
+ import react from '@vitejs/plugin-react';
2
+ import mdx from 'fumadocs-mdx/vite';
3
+ import { nitro } from 'nitro/vite';
4
+ import path from 'node:path';
5
+ import { type InlineConfig } from 'vite';
6
+
7
+ export interface ViteConfigOptions {
8
+ packageRoot: string;
9
+ projectRoot: string;
10
+ contentDir: string;
11
+ preset?: string;
12
+ }
13
+
14
+ export async function createViteConfig(
15
+ options: ViteConfigOptions
16
+ ): Promise<InlineConfig> {
17
+ const { packageRoot, projectRoot, contentDir, preset } = options;
18
+
19
+ return {
20
+ root: packageRoot,
21
+ configFile: false,
22
+ plugins: [
23
+ nitro({
24
+ serverDir: path.resolve(packageRoot, 'src/server'),
25
+ ...(preset && { preset }),
26
+ }),
27
+ mdx({}, { index: false }),
28
+ react()
29
+ ],
30
+ resolve: {
31
+ alias: {
32
+ '@': path.resolve(packageRoot, 'src'),
33
+ },
34
+ conditions: ['module-sync', 'import', 'node'],
35
+ dedupe: [
36
+ 'react',
37
+ 'react-dom',
38
+ 'react/jsx-runtime',
39
+ 'react/jsx-dev-runtime',
40
+ 'react-router',
41
+ ]
42
+ },
43
+ server: {
44
+ fs: {
45
+ allow: [packageRoot, projectRoot, contentDir]
46
+ }
47
+ },
48
+ define: {
49
+ __CHRONICLE_CONTENT_DIR__: JSON.stringify(contentDir),
50
+ __CHRONICLE_PROJECT_ROOT__: JSON.stringify(projectRoot),
51
+ __CHRONICLE_PACKAGE_ROOT__: JSON.stringify(packageRoot)
52
+ },
53
+ css: {
54
+ modules: {
55
+ localsConvention: 'camelCase'
56
+ }
57
+ },
58
+ ssr: {
59
+ noExternal: ['@raystack/apsara', 'dayjs', 'fumadocs-core']
60
+ },
61
+ environments: {
62
+ client: {
63
+ build: {
64
+ rollupOptions: {
65
+ input: path.resolve(packageRoot, 'src/server/entry-client.tsx')
66
+ }
67
+ }
68
+ }
69
+ }
70
+ };
71
+ }
@@ -0,0 +1,81 @@
1
+ .layout {
2
+ min-height: 100vh;
3
+ }
4
+
5
+ .header {
6
+ border-bottom: 1px solid var(--rs-color-border-base-primary);
7
+ }
8
+
9
+ .search {
10
+ margin-left: var(--rs-space-5);
11
+ }
12
+
13
+ .body {
14
+ flex: 1;
15
+ }
16
+
17
+ .sidebar {
18
+ width: 260px;
19
+ position: sticky;
20
+ top: 0;
21
+ height: 100vh;
22
+ }
23
+
24
+ .content {
25
+ flex: 1;
26
+ padding: var(--rs-space-9);
27
+ }
28
+
29
+ .sidebarList {
30
+ list-style: none;
31
+ padding: 0;
32
+ margin: 0;
33
+ }
34
+
35
+ .separator {
36
+ height: 1px;
37
+ background: var(--rs-color-border-base-primary);
38
+ margin: var(--rs-space-3) 0;
39
+ }
40
+
41
+ .folder {
42
+ margin-bottom: var(--rs-space-3);
43
+ }
44
+
45
+ .folderLabel {
46
+ font-weight: 500;
47
+ font-size: 0.875rem;
48
+ color: var(--rs-color-text-base-secondary);
49
+ text-transform: uppercase;
50
+ letter-spacing: 0.05em;
51
+ }
52
+
53
+ .folder > .sidebarList {
54
+ margin-top: var(--rs-space-2);
55
+ padding-left: var(--rs-space-4);
56
+ }
57
+
58
+ .navButton {
59
+ display: flex;
60
+ align-items: center;
61
+ height: 32px;
62
+ padding: 0 var(--rs-space-4);
63
+ border: 1px solid var(--rs-color-border-base-primary);
64
+ border-radius: var(--rs-radius-2);
65
+ font-size: var(--rs-font-size-small);
66
+ font-weight: var(--rs-font-weight-medium);
67
+ color: var(--rs-color-foreground-base-primary);
68
+ text-decoration: none;
69
+ }
70
+
71
+ .navButton:hover {
72
+ background: var(--rs-color-background-base-primary-hover);
73
+ }
74
+
75
+ .groupItems {
76
+ padding-left: var(--rs-space-4);
77
+ }
78
+
79
+ .page {
80
+ padding: var(--rs-space-2) 0;
81
+ }
@@ -0,0 +1,160 @@
1
+ import { RectangleStackIcon } from '@heroicons/react/24/outline';
2
+ import {
3
+ Button,
4
+ Flex,
5
+ Headline,
6
+ Link,
7
+ Navbar,
8
+ Sidebar
9
+ } from '@raystack/apsara';
10
+ import { cx } from 'class-variance-authority';
11
+ import { useEffect, useMemo, useRef } from 'react';
12
+ import { Link as RouterLink, useLocation } from 'react-router';
13
+ import { MethodBadge } from '@/components/api/method-badge';
14
+ import { ClientThemeSwitcher } from '@/components/ui/client-theme-switcher';
15
+ import { Footer } from '@/components/ui/footer';
16
+ import { Search } from '@/components/ui/search';
17
+ import type { PageTreeItem, ThemeLayoutProps } from '@/types';
18
+ import styles from './Layout.module.css';
19
+
20
+ const iconMap: Record<string, React.ReactNode> = {
21
+ 'rectangle-stack': <RectangleStackIcon width={16} height={16} />,
22
+ 'method-get': <MethodBadge method='GET' size='micro' />,
23
+ 'method-post': <MethodBadge method='POST' size='micro' />,
24
+ 'method-put': <MethodBadge method='PUT' size='micro' />,
25
+ 'method-delete': <MethodBadge method='DELETE' size='micro' />,
26
+ 'method-patch': <MethodBadge method='PATCH' size='micro' />
27
+ };
28
+
29
+ let savedScrollTop = 0;
30
+
31
+ export function Layout({
32
+ children,
33
+ config,
34
+ tree,
35
+ classNames
36
+ }: ThemeLayoutProps) {
37
+ const { pathname } = useLocation();
38
+ const scrollRef = useRef<HTMLDivElement>(null);
39
+
40
+ useEffect(() => {
41
+ const el = scrollRef.current;
42
+ if (!el) return;
43
+ const onScroll = () => {
44
+ savedScrollTop = el.scrollTop;
45
+ };
46
+ el.addEventListener('scroll', onScroll);
47
+ return () => el.removeEventListener('scroll', onScroll);
48
+ }, []);
49
+
50
+ useEffect(() => {
51
+ const el = scrollRef.current;
52
+ if (el)
53
+ requestAnimationFrame(() => {
54
+ el.scrollTop = savedScrollTop;
55
+ });
56
+ }, [pathname]);
57
+
58
+ return (
59
+ <Flex direction='column' className={cx(styles.layout, classNames?.layout)}>
60
+ <Navbar className={styles.header}>
61
+ <Navbar.Start>
62
+ <RouterLink
63
+ to='/'
64
+ style={{ textDecoration: 'none', color: 'inherit' }}
65
+ >
66
+ <Headline size='small' weight='medium' as='h1'>
67
+ {config.title}
68
+ </Headline>
69
+ </RouterLink>
70
+ </Navbar.Start>
71
+ <Navbar.End>
72
+ <Flex gap='medium' align='center' className={styles.navActions}>
73
+ {config.api?.map(api => (
74
+ <RouterLink
75
+ key={api.basePath}
76
+ to={api.basePath}
77
+ className={styles.navButton}
78
+ >
79
+ {api.name} API
80
+ </RouterLink>
81
+ ))}
82
+ {config.navigation?.links?.map(link => (
83
+ <Link key={link.href} href={link.href}>
84
+ {link.label}
85
+ </Link>
86
+ ))}
87
+ {config.search?.enabled && <Search />}
88
+ </Flex>
89
+ <ClientThemeSwitcher size={16} />
90
+ </Navbar.End>
91
+ </Navbar>
92
+ <Flex className={cx(styles.body, classNames?.body)}>
93
+ <Sidebar
94
+ defaultOpen
95
+ collapsible={false}
96
+ className={cx(styles.sidebar, classNames?.sidebar)}
97
+ >
98
+ <Sidebar.Main ref={scrollRef}>
99
+ {tree.children.map(item => (
100
+ <SidebarNode
101
+ key={item.url ?? item.name}
102
+ item={item}
103
+ pathname={pathname}
104
+ />
105
+ ))}
106
+ </Sidebar.Main>
107
+ </Sidebar>
108
+ <main className={cx(styles.content, classNames?.content)}>
109
+ {children}
110
+ </main>
111
+ </Flex>
112
+ <Footer config={config.footer} />
113
+ </Flex>
114
+ );
115
+ }
116
+
117
+ function SidebarNode({
118
+ item,
119
+ pathname
120
+ }: {
121
+ item: PageTreeItem;
122
+ pathname: string;
123
+ }) {
124
+ if (item.type === 'separator') {
125
+ return null;
126
+ }
127
+
128
+ if (item.type === 'folder' && item.children) {
129
+ return (
130
+ <Sidebar.Group
131
+ label={item.name}
132
+ leadingIcon={item.icon ? iconMap[item.icon] : undefined}
133
+ classNames={{ items: styles.groupItems }}
134
+ >
135
+ {item.children.map(child => (
136
+ <SidebarNode
137
+ key={child.url ?? child.name}
138
+ item={child}
139
+ pathname={pathname}
140
+ />
141
+ ))}
142
+ </Sidebar.Group>
143
+ );
144
+ }
145
+
146
+ const isActive = pathname === item.url;
147
+ const href = item.url ?? '#';
148
+ const link = useMemo(() => <RouterLink to={href} />, [href]);
149
+
150
+ return (
151
+ <Sidebar.Item
152
+ href={href}
153
+ active={isActive}
154
+ leadingIcon={item.icon ? iconMap[item.icon] : undefined}
155
+ as={link}
156
+ >
157
+ {item.name}
158
+ </Sidebar.Item>
159
+ );
160
+ }
@@ -0,0 +1,46 @@
1
+ .page {
2
+ gap: var(--rs-space-9);
3
+ }
4
+
5
+ .article {
6
+ flex: 1;
7
+ min-width: 0;
8
+ max-width: 768px;
9
+ }
10
+
11
+ .content {
12
+ line-height: 1.7;
13
+ }
14
+
15
+ .content h1,
16
+ .content h2,
17
+ .content h3,
18
+ .content h4,
19
+ .content h5,
20
+ .content h6 {
21
+ margin-top: var(--rs-space-8);
22
+ margin-bottom: var(--rs-space-5);
23
+ line-height: 1.4;
24
+ }
25
+
26
+ .content ul,
27
+ .content ol {
28
+ padding-left: var(--rs-space-5);
29
+ margin-bottom: var(--rs-space-5);
30
+ }
31
+
32
+ .content li {
33
+ font-size: var(--rs-font-size-regular);
34
+ margin: var(--rs-space-2) 0;
35
+ }
36
+
37
+ .content [role="tablist"] {
38
+ margin-bottom: var(--rs-space-3);
39
+ }
40
+
41
+ .content table {
42
+ display: block;
43
+ max-width: 100%;
44
+ overflow-x: auto;
45
+ margin-bottom: var(--rs-space-5);
46
+ }
@@ -0,0 +1,19 @@
1
+ 'use client';
2
+
3
+ import { Flex } from '@raystack/apsara';
4
+ import { Breadcrumbs } from '@/components/ui/breadcrumbs';
5
+ import type { ThemePageProps } from '@/types';
6
+ import styles from './Page.module.css';
7
+ import { Toc } from './Toc';
8
+
9
+ export function Page({ page, tree }: ThemePageProps) {
10
+ return (
11
+ <Flex className={styles.page}>
12
+ <article className={styles.article} data-article-content>
13
+ <Breadcrumbs slug={page.slug} tree={tree} />
14
+ <div className={styles.content}>{page.content}</div>
15
+ </article>
16
+ <Toc items={page.toc} />
17
+ </Flex>
18
+ );
19
+ }
@@ -0,0 +1,48 @@
1
+ .toc {
2
+ width: 200px;
3
+ flex-shrink: 0;
4
+ position: sticky;
5
+ top: var(--rs-space-9);
6
+ max-height: calc(100vh - var(--rs-space-17));
7
+ overflow-y: auto;
8
+ }
9
+
10
+ .title {
11
+ display: block;
12
+ color: var(--rs-color-foreground-base-secondary);
13
+ text-transform: uppercase;
14
+ letter-spacing: 0.05em;
15
+ margin-bottom: var(--rs-space-3);
16
+ font-size: var(--rs-font-size-mini);
17
+ }
18
+
19
+ .nav {
20
+ display: flex;
21
+ flex-direction: column;
22
+ gap: 0;
23
+ border-left: 1px solid var(--rs-color-border-base-primary);
24
+ padding-left: var(--rs-space-3);
25
+ margin-bottom: var(--rs-space-6);
26
+ }
27
+
28
+ .link {
29
+ color: var(--rs-color-foreground-base-tertiary);
30
+ text-decoration: none;
31
+ font-size: var(--rs-font-size-small);
32
+ line-height: 1.4;
33
+ padding: var(--rs-space-1) 0;
34
+ transition: color 0.15s ease;
35
+ }
36
+
37
+ .link:hover {
38
+ color: var(--rs-color-foreground-base-primary);
39
+ }
40
+
41
+ .active {
42
+ color: var(--rs-color-foreground-base-primary);
43
+ font-weight: 500;
44
+ }
45
+
46
+ .nested {
47
+ padding-left: var(--rs-space-3);
48
+ }
@@ -0,0 +1,68 @@
1
+ 'use client';
2
+
3
+ import { Text } from '@raystack/apsara';
4
+ import { useEffect, useState } from 'react';
5
+ import type { TocItem } from '@/types';
6
+ import styles from './Toc.module.css';
7
+
8
+ interface TocProps {
9
+ items: TocItem[];
10
+ }
11
+
12
+ export function Toc({ items }: TocProps) {
13
+ const [activeId, setActiveId] = useState<string>('');
14
+
15
+ // Filter to only show h2 and h3 headings
16
+ const filteredItems = items.filter(
17
+ item => item.depth >= 2 && item.depth <= 3
18
+ );
19
+
20
+ useEffect(() => {
21
+ const headingIds = filteredItems.map(item => item.url.replace('#', ''));
22
+
23
+ const observer = new IntersectionObserver(
24
+ entries => {
25
+ entries.forEach(entry => {
26
+ if (entry.isIntersecting) {
27
+ setActiveId(entry.target.id);
28
+ }
29
+ });
30
+ },
31
+ // -80px top: offset for fixed header, -80% bottom: trigger when heading is in top 20% of viewport
32
+ { rootMargin: '-80px 0px -80% 0px' }
33
+ );
34
+
35
+ headingIds.forEach(id => {
36
+ const element = document.getElementById(id);
37
+ if (element) observer.observe(element);
38
+ });
39
+
40
+ return () => observer.disconnect();
41
+ }, [filteredItems]);
42
+
43
+ if (filteredItems.length === 0) return null;
44
+
45
+ return (
46
+ <aside className={styles.toc}>
47
+ <Text size={1} weight='medium' className={styles.title}>
48
+ On this page
49
+ </Text>
50
+ <nav className={styles.nav}>
51
+ {filteredItems.map(item => {
52
+ const id = item.url.replace('#', '');
53
+ const isActive = activeId === id;
54
+ const isNested = item.depth > 2;
55
+ return (
56
+ <a
57
+ key={item.url}
58
+ href={item.url}
59
+ className={`${styles.link} ${isActive ? styles.active : ''} ${isNested ? styles.nested : ''}`}
60
+ >
61
+ {item.title}
62
+ </a>
63
+ );
64
+ })}
65
+ </nav>
66
+ </aside>
67
+ );
68
+ }