@raystack/chronicle 0.5.4 → 0.6.1

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 (68) hide show
  1. package/dist/cli/index.js +258 -80
  2. package/package.json +7 -5
  3. package/src/cli/commands/build.ts +5 -8
  4. package/src/cli/commands/dev.ts +5 -6
  5. package/src/cli/commands/init.test.ts +77 -0
  6. package/src/cli/commands/init.ts +73 -40
  7. package/src/cli/commands/serve.ts +6 -9
  8. package/src/cli/commands/start.ts +5 -5
  9. package/src/cli/utils/config.ts +6 -12
  10. package/src/cli/utils/scaffold.test.ts +179 -0
  11. package/src/cli/utils/scaffold.ts +70 -9
  12. package/src/components/api/field-row.tsx +1 -1
  13. package/src/components/api/field-section.tsx +2 -2
  14. package/src/components/mdx/index.tsx +1 -1
  15. package/src/components/ui/breadcrumbs.tsx +4 -2
  16. package/src/components/ui/client-theme-switcher.tsx +21 -4
  17. package/src/components/ui/search.module.css +16 -41
  18. package/src/components/ui/search.tsx +30 -41
  19. package/src/lib/config.test.ts +493 -0
  20. package/src/lib/config.ts +123 -22
  21. package/src/lib/head.tsx +23 -5
  22. package/src/lib/llms.test.ts +94 -0
  23. package/src/lib/llms.ts +41 -0
  24. package/src/lib/navigation.test.ts +94 -0
  25. package/src/lib/navigation.ts +51 -0
  26. package/src/lib/page-context.tsx +51 -32
  27. package/src/lib/route-resolver.test.ts +173 -0
  28. package/src/lib/route-resolver.ts +73 -0
  29. package/src/lib/source.ts +94 -1
  30. package/src/lib/version-source.test.ts +163 -0
  31. package/src/lib/version-source.ts +101 -0
  32. package/src/pages/ApiPage.tsx +1 -1
  33. package/src/pages/DocsLayout.tsx +24 -3
  34. package/src/pages/DocsPage.tsx +3 -6
  35. package/src/pages/LandingPage.module.css +56 -0
  36. package/src/pages/LandingPage.tsx +39 -0
  37. package/src/pages/NotFound.tsx +2 -0
  38. package/src/server/App.tsx +21 -23
  39. package/src/server/api/page.ts +5 -1
  40. package/src/server/api/search.ts +51 -24
  41. package/src/server/api/specs.ts +17 -5
  42. package/src/server/entry-client.tsx +42 -14
  43. package/src/server/entry-server.tsx +33 -11
  44. package/src/server/routes/[...slug].md.ts +0 -6
  45. package/src/server/routes/[version]/llms.txt.ts +26 -0
  46. package/src/server/routes/llms.txt.ts +10 -13
  47. package/src/server/routes/og.tsx +2 -2
  48. package/src/server/routes/sitemap.xml.ts +14 -6
  49. package/src/server/vite-config.ts +5 -5
  50. package/src/themes/default/ContentDirButtons.tsx +66 -0
  51. package/src/themes/default/Layout.module.css +187 -40
  52. package/src/themes/default/Layout.tsx +166 -65
  53. package/src/themes/default/OpenInAI.tsx +112 -0
  54. package/src/themes/default/Page.module.css +30 -0
  55. package/src/themes/default/Page.tsx +1 -3
  56. package/src/themes/default/SidebarLogo.tsx +26 -0
  57. package/src/themes/default/Toc.module.css +102 -25
  58. package/src/themes/default/Toc.tsx +56 -10
  59. package/src/themes/default/VersionSwitcher.tsx +59 -0
  60. package/src/themes/paper/ContentDirDropdown.tsx +47 -0
  61. package/src/themes/paper/Layout.module.css +7 -0
  62. package/src/themes/paper/Layout.tsx +20 -13
  63. package/src/themes/paper/VersionSwitcher.tsx +60 -0
  64. package/src/types/config.ts +145 -23
  65. package/src/types/content.ts +11 -1
  66. package/src/types/theme.ts +1 -0
  67. package/src/components/ui/footer.module.css +0 -27
  68. package/src/components/ui/footer.tsx +0 -30
package/src/lib/source.ts CHANGED
@@ -1,8 +1,20 @@
1
1
  import { loader } from 'fumadocs-core/source';
2
+ import { flattenTree } from 'fumadocs-core/page-tree';
2
3
  import type { Root, Node, Folder } from 'fumadocs-core/page-tree';
3
4
  import type { MDXContent } from 'mdx/types';
4
5
  import type { TableOfContents } from 'fumadocs-core/toc';
5
- import type { Frontmatter } from '@/types';
6
+ import {
7
+ getLatestContentRoots,
8
+ getVersionContentRoots,
9
+ loadConfig,
10
+ } from './config';
11
+ import {
12
+ filterPagesByVersion,
13
+ filterPageTreeByVersion,
14
+ resolveVersionFromUrl,
15
+ type VersionContext,
16
+ } from './version-source';
17
+ import type { Frontmatter, PageNav, PageNavLink } from '@/types';
6
18
 
7
19
  const CONTENT_PREFIX = '../../.content/';
8
20
 
@@ -33,14 +45,50 @@ function buildFiles() {
33
45
  });
34
46
  }
35
47
 
48
+ const userMetaPaths = new Set<string>();
36
49
  for (const [key, data] of Object.entries(metaGlob)) {
37
50
  const relativePath = key.slice(CONTENT_PREFIX.length);
51
+ userMetaPaths.add(relativePath);
38
52
  files.push({ type: 'meta', path: relativePath, data: data ?? {} });
39
53
  }
40
54
 
55
+ for (const entry of buildSyntheticMeta()) {
56
+ if (userMetaPaths.has(entry.path)) continue;
57
+ files.push(entry);
58
+ }
59
+
41
60
  return files;
42
61
  }
43
62
 
63
+ function buildSyntheticMeta(): {
64
+ type: 'meta';
65
+ path: string;
66
+ data: Record<string, unknown>;
67
+ }[] {
68
+ const config = loadConfig();
69
+ const entries: { type: 'meta'; path: string; data: Record<string, unknown> }[] = [];
70
+
71
+ for (const root of getLatestContentRoots(config)) {
72
+ entries.push({
73
+ type: 'meta',
74
+ path: `${root.contentDir}/meta.json`,
75
+ data: { title: root.contentLabel, root: true },
76
+ });
77
+ }
78
+
79
+ for (const version of config.versions ?? []) {
80
+ for (const root of getVersionContentRoots(config, version.dir)) {
81
+ entries.push({
82
+ type: 'meta',
83
+ path: `${version.dir}/${root.contentDir}/meta.json`,
84
+ data: { title: root.contentLabel, root: true },
85
+ });
86
+ }
87
+ }
88
+
89
+ return entries;
90
+ }
91
+
44
92
  let cachedSource: ReturnType<typeof loader> | null = null;
45
93
 
46
94
  async function getSource() {
@@ -105,6 +153,51 @@ export async function getPage(slugs?: string[]) {
105
153
  return s.getPage(slugs);
106
154
  }
107
155
 
156
+ export async function getPageTreeForVersion(ctx: VersionContext): Promise<Root> {
157
+ const tree = await getPageTree();
158
+ return filterPageTreeByVersion(tree, ctx, loadConfig());
159
+ }
160
+
161
+ export async function getPagesForVersion(ctx: VersionContext) {
162
+ const pages = await getPages();
163
+ return filterPagesByVersion(pages, ctx, loadConfig());
164
+ }
165
+
166
+ export function getVersionContextForUrl(url: string): VersionContext {
167
+ return resolveVersionFromUrl(url, loadConfig());
168
+ }
169
+
170
+ export type { VersionContext } from './version-source';
171
+
172
+ function titleFromUrl(url: string): string {
173
+ if (url === '/') return 'Home';
174
+ const last = url.split('/').filter(Boolean).pop();
175
+ if (!last) return 'Home';
176
+ return last
177
+ .split('-')
178
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
179
+ .join(' ');
180
+ }
181
+
182
+ export async function getPageNav(slug: string[], tree?: Root): Promise<PageNav> {
183
+ const resolvedTree = tree ?? (await getPageTree());
184
+ const pages = flattenTree(resolvedTree.children);
185
+ const url = slug.length === 0 ? '/' : `/${slug.join('/')}`;
186
+ const i = pages.findIndex(p => p.url === url);
187
+ if (i < 0) return { prev: null, next: null };
188
+ const toLink = (p: (typeof pages)[number]): PageNavLink => ({
189
+ url: p.url,
190
+ title:
191
+ typeof p.name === 'string' && p.name.length > 0
192
+ ? p.name
193
+ : titleFromUrl(p.url)
194
+ });
195
+ return {
196
+ prev: i > 0 ? toLink(pages[i - 1]) : null,
197
+ next: i < pages.length - 1 ? toLink(pages[i + 1]) : null
198
+ };
199
+ }
200
+
108
201
  export function extractFrontmatter(page: { data: unknown }, fallbackTitle?: string): Frontmatter {
109
202
  const d = page.data as Record<string, unknown>;
110
203
  return {
@@ -0,0 +1,163 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { Folder, Item, Root } from 'fumadocs-core/page-tree'
3
+ import { type ChronicleConfig, chronicleConfigSchema } from '@/types'
4
+ import {
5
+ filterPagesByVersion,
6
+ filterPageTreeByContentDir,
7
+ filterPageTreeByVersion,
8
+ LATEST_CONTEXT,
9
+ resolveVersionFromUrl,
10
+ } from './version-source'
11
+
12
+ function makeConfig(): ChronicleConfig {
13
+ return chronicleConfigSchema.parse({
14
+ site: { title: 'x' },
15
+ content: [{ dir: 'docs', label: 'Docs' }],
16
+ latest: { label: '3.0' },
17
+ versions: [
18
+ {
19
+ dir: 'v2',
20
+ label: '2.0',
21
+ content: [{ dir: 'docs', label: 'Docs' }],
22
+ },
23
+ {
24
+ dir: 'v1',
25
+ label: '1.0',
26
+ content: [
27
+ { dir: 'docs', label: 'Docs' },
28
+ { dir: 'dev', label: 'Dev' },
29
+ ],
30
+ },
31
+ ],
32
+ })
33
+ }
34
+
35
+ function page(url: string): Item {
36
+ return { type: 'page', name: url, url }
37
+ }
38
+
39
+ function folder(name: string, children: (Item | Folder)[]): Folder {
40
+ return { type: 'folder', name, children }
41
+ }
42
+
43
+ describe('resolveVersionFromUrl', () => {
44
+ const config = makeConfig()
45
+
46
+ test('returns latest context for unprefixed URLs', () => {
47
+ expect(resolveVersionFromUrl('/docs/getting-started', config)).toEqual(
48
+ LATEST_CONTEXT,
49
+ )
50
+ expect(resolveVersionFromUrl('/', config)).toEqual(LATEST_CONTEXT)
51
+ })
52
+
53
+ test('returns version context when URL matches a version prefix', () => {
54
+ expect(resolveVersionFromUrl('/v1/docs/intro', config)).toEqual({
55
+ dir: 'v1',
56
+ urlPrefix: '/v1',
57
+ })
58
+ expect(resolveVersionFromUrl('/v2', config)).toEqual({
59
+ dir: 'v2',
60
+ urlPrefix: '/v2',
61
+ })
62
+ })
63
+
64
+ test('does not match a version when prefix is only a substring', () => {
65
+ expect(resolveVersionFromUrl('/v1beta/docs', config)).toEqual(LATEST_CONTEXT)
66
+ })
67
+ })
68
+
69
+ describe('filterPagesByVersion', () => {
70
+ const config = makeConfig()
71
+ const pages = [
72
+ { url: '/docs/a' },
73
+ { url: '/docs/b' },
74
+ { url: '/v1/docs/a' },
75
+ { url: '/v1/dev/b' },
76
+ { url: '/v2/docs/a' },
77
+ ]
78
+
79
+ test('latest excludes all versioned pages', () => {
80
+ expect(filterPagesByVersion(pages, LATEST_CONTEXT, config)).toEqual([
81
+ { url: '/docs/a' },
82
+ { url: '/docs/b' },
83
+ ])
84
+ })
85
+
86
+ test('version returns only pages under its prefix', () => {
87
+ expect(
88
+ filterPagesByVersion(pages, { dir: 'v1', urlPrefix: '/v1' }, config),
89
+ ).toEqual([{ url: '/v1/docs/a' }, { url: '/v1/dev/b' }])
90
+ })
91
+ })
92
+
93
+ describe('filterPageTreeByVersion', () => {
94
+ const config = makeConfig()
95
+ const latestDocs = folder('docs', [page('/docs/a'), page('/docs/b')])
96
+ const v1Folder = folder('v1', [
97
+ folder('docs', [page('/v1/docs/a')]),
98
+ folder('dev', [page('/v1/dev/a')]),
99
+ ])
100
+ const v2Folder = folder('v2', [folder('docs', [page('/v2/docs/a')])])
101
+
102
+ const tree: Root = {
103
+ name: 'root',
104
+ children: [latestDocs, v1Folder, v2Folder],
105
+ }
106
+
107
+ test('latest drops version folders', () => {
108
+ const filtered = filterPageTreeByVersion(tree, LATEST_CONTEXT, config)
109
+ expect(filtered.children).toEqual([latestDocs])
110
+ })
111
+
112
+ test('version returns the inner children of its folder', () => {
113
+ const filtered = filterPageTreeByVersion(
114
+ tree,
115
+ { dir: 'v1', urlPrefix: '/v1' },
116
+ config,
117
+ )
118
+ expect(filtered.children).toEqual(v1Folder.children)
119
+ })
120
+
121
+ test('version returns empty children when the version folder is absent', () => {
122
+ const filtered = filterPageTreeByVersion(
123
+ { name: 'root', children: [latestDocs] },
124
+ { dir: 'v1', urlPrefix: '/v1' },
125
+ config,
126
+ )
127
+ expect(filtered.children).toEqual([])
128
+ })
129
+ })
130
+
131
+ describe('filterPageTreeByContentDir', () => {
132
+ const latestDocs = folder('docs', [page('/docs/a'), page('/docs/b')])
133
+ const latestDev = folder('dev', [page('/dev/x')])
134
+ const latestTree: Root = {
135
+ name: 'root',
136
+ children: [latestDocs, latestDev],
137
+ }
138
+
139
+ test('null contentDir returns tree unchanged', () => {
140
+ const out = filterPageTreeByContentDir(latestTree, LATEST_CONTEXT, null)
141
+ expect(out).toBe(latestTree)
142
+ })
143
+
144
+ test('returns just the matching content folder children (latest)', () => {
145
+ const out = filterPageTreeByContentDir(latestTree, LATEST_CONTEXT, 'docs')
146
+ expect(out.children).toEqual(latestDocs.children)
147
+ })
148
+
149
+ test('returns empty children when content dir is absent', () => {
150
+ const out = filterPageTreeByContentDir(latestTree, LATEST_CONTEXT, 'missing')
151
+ expect(out.children).toEqual([])
152
+ })
153
+
154
+ test('uses version urlPrefix to disambiguate within a version', () => {
155
+ const v1Docs = folder('docs', [page('/v1/docs/a')])
156
+ const v1Dev = folder('dev', [page('/v1/dev/x')])
157
+ const ctx = { dir: 'v1', urlPrefix: '/v1' }
158
+ const tree: Root = { name: 'root', children: [v1Docs, v1Dev] }
159
+ expect(
160
+ filterPageTreeByContentDir(tree, ctx, 'dev').children,
161
+ ).toEqual(v1Dev.children)
162
+ })
163
+ })
@@ -0,0 +1,101 @@
1
+ import type { Folder, Node, Root } from 'fumadocs-core/page-tree'
2
+ import type { ChronicleConfig } from '@/types'
3
+
4
+ export interface VersionContext {
5
+ dir: string | null
6
+ urlPrefix: string
7
+ }
8
+
9
+ export const LATEST_CONTEXT: VersionContext = { dir: null, urlPrefix: '' }
10
+
11
+ export function resolveVersionFromUrl(
12
+ url: string,
13
+ config: ChronicleConfig,
14
+ ): VersionContext {
15
+ for (const v of config.versions ?? []) {
16
+ const prefix = `/${v.dir}`
17
+ if (url === prefix || url.startsWith(`${prefix}/`)) {
18
+ return { dir: v.dir, urlPrefix: prefix }
19
+ }
20
+ }
21
+ return LATEST_CONTEXT
22
+ }
23
+
24
+ function versionPrefixes(config: ChronicleConfig): string[] {
25
+ return (config.versions ?? []).map((v) => `/${v.dir}`)
26
+ }
27
+
28
+ function isUnderPrefix(url: string, prefix: string): boolean {
29
+ return url === prefix || url.startsWith(`${prefix}/`)
30
+ }
31
+
32
+ export function filterPagesByVersion<T extends { url: string }>(
33
+ pages: T[],
34
+ ctx: VersionContext,
35
+ config: ChronicleConfig,
36
+ ): T[] {
37
+ if (ctx.dir !== null) {
38
+ return pages.filter((p) => isUnderPrefix(p.url, ctx.urlPrefix))
39
+ }
40
+ const prefixes = versionPrefixes(config)
41
+ return pages.filter((p) => !prefixes.some((pre) => isUnderPrefix(p.url, pre)))
42
+ }
43
+
44
+ function nodeUrls(node: Node): string[] {
45
+ if (node.type === 'page') return [node.url]
46
+ if (node.type === 'folder') {
47
+ const urls: string[] = []
48
+ if (node.index) urls.push(node.index.url)
49
+ for (const child of node.children) urls.push(...nodeUrls(child))
50
+ return urls
51
+ }
52
+ return []
53
+ }
54
+
55
+ function nodeMatchesVersion(
56
+ node: Node,
57
+ ctx: VersionContext,
58
+ config: ChronicleConfig,
59
+ ): boolean {
60
+ const urls = nodeUrls(node)
61
+ if (urls.length === 0) return ctx.dir === null
62
+ if (ctx.dir !== null) {
63
+ return urls.every((u) => isUnderPrefix(u, ctx.urlPrefix))
64
+ }
65
+ const prefixes = versionPrefixes(config)
66
+ return urls.every((u) => !prefixes.some((pre) => isUnderPrefix(u, pre)))
67
+ }
68
+
69
+ export function filterPageTreeByVersion(
70
+ tree: Root,
71
+ ctx: VersionContext,
72
+ config: ChronicleConfig,
73
+ ): Root {
74
+ if (ctx.dir !== null) {
75
+ const versionFolder = tree.children.find(
76
+ (n): n is Folder =>
77
+ n.type === 'folder' && nodeMatchesVersion(n, ctx, config),
78
+ )
79
+ return { ...tree, children: versionFolder ? versionFolder.children : [] }
80
+ }
81
+ return {
82
+ ...tree,
83
+ children: tree.children.filter((n) => nodeMatchesVersion(n, ctx, config)),
84
+ }
85
+ }
86
+
87
+ export function filterPageTreeByContentDir(
88
+ tree: Root,
89
+ ctx: VersionContext,
90
+ contentDir: string | null,
91
+ ): Root {
92
+ if (contentDir === null) return tree
93
+ const expectedPrefix = `${ctx.urlPrefix}/${contentDir}`
94
+ const match = tree.children.find((n): n is Folder => {
95
+ if (n.type !== 'folder') return false
96
+ const urls = nodeUrls(n)
97
+ return urls.length > 0 && urls.every((u) => isUnderPrefix(u, expectedPrefix))
98
+ })
99
+ if (!match) return { ...tree, children: [] }
100
+ return { ...tree, children: match.children }
101
+ }
@@ -18,7 +18,7 @@ export function ApiPage({ slug }: ApiPageProps) {
18
18
  <>
19
19
  <Head
20
20
  title='API Reference'
21
- description={`API documentation for ${config.title}`}
21
+ description={`API documentation for ${config.site.title}`}
22
22
  config={config}
23
23
  />
24
24
  <ApiLanding specs={apiSpecs} />
@@ -1,17 +1,38 @@
1
1
  import type { ReactNode } from 'react';
2
+ import { useLocation } from 'react-router';
2
3
  import { usePageContext } from '@/lib/page-context';
4
+ import { getActiveContentDir } from '@/lib/navigation';
5
+ import {
6
+ filterPageTreeByContentDir,
7
+ filterPageTreeByVersion,
8
+ } from '@/lib/version-source';
3
9
  import { getTheme } from '@/themes/registry';
4
10
 
5
11
  interface DocsLayoutProps {
6
12
  children: ReactNode;
13
+ hideSidebar?: boolean;
7
14
  }
8
15
 
9
- export function DocsLayout({ children }: DocsLayoutProps) {
10
- const { config, tree } = usePageContext();
16
+ export function DocsLayout({ children, hideSidebar }: DocsLayoutProps) {
17
+ const { config, tree, version } = usePageContext();
18
+ const { pathname } = useLocation();
11
19
  const { Layout, className } = getTheme(config.theme?.name);
12
20
 
21
+ const activeContentDir = getActiveContentDir(pathname, config);
22
+ const versionScoped = filterPageTreeByVersion(tree, version, config);
23
+ const scopedTree = filterPageTreeByContentDir(
24
+ versionScoped,
25
+ version,
26
+ activeContentDir,
27
+ );
28
+
13
29
  return (
14
- <Layout config={config} tree={tree} classNames={{ layout: className }}>
30
+ <Layout
31
+ config={config}
32
+ tree={scopedTree}
33
+ hideSidebar={hideSidebar}
34
+ classNames={{ layout: className }}
35
+ >
15
36
  {children}
16
37
  </Layout>
17
38
  );
@@ -16,6 +16,7 @@ export function DocsPage({ slug }: DocsPageProps) {
16
16
 
17
17
  const { Page } = getTheme(config.theme?.name);
18
18
  const pageUrl = config.url ? `${config.url}/${slug.join('/')}` : undefined;
19
+ const markdownHref = `/${slug.join('/')}.md`;
19
20
 
20
21
  return (
21
22
  <>
@@ -23,6 +24,7 @@ export function DocsPage({ slug }: DocsPageProps) {
23
24
  title={page.frontmatter.title}
24
25
  description={page.frontmatter.description}
25
26
  config={config}
27
+ markdownHref={markdownHref}
26
28
  jsonLd={{
27
29
  '@context': 'https://schema.org',
28
30
  '@type': 'Article',
@@ -32,12 +34,7 @@ export function DocsPage({ slug }: DocsPageProps) {
32
34
  }}
33
35
  />
34
36
  <Page
35
- page={{
36
- slug: page.slug,
37
- frontmatter: page.frontmatter,
38
- content: page.content,
39
- toc: page.toc
40
- }}
37
+ page={page}
41
38
  config={config}
42
39
  tree={tree}
43
40
  />
@@ -0,0 +1,56 @@
1
+ .root {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--rs-space-8);
5
+ padding: var(--rs-space-9) var(--rs-space-7);
6
+ max-width: 960px;
7
+ margin: 0 auto;
8
+ }
9
+
10
+ .title {
11
+ font-size: var(--rs-font-size-h3);
12
+ font-weight: 600;
13
+ color: var(--rs-color-foreground-base-primary);
14
+ margin: 0;
15
+ }
16
+
17
+ .description {
18
+ font-size: var(--rs-font-size-regular);
19
+ color: var(--rs-color-foreground-base-secondary);
20
+ margin: 0;
21
+ }
22
+
23
+ .grid {
24
+ display: grid;
25
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
26
+ gap: var(--rs-space-6);
27
+ }
28
+
29
+ .card {
30
+ display: flex;
31
+ flex-direction: column;
32
+ gap: var(--rs-space-3);
33
+ padding: var(--rs-space-6);
34
+ border: 1px solid var(--rs-color-border-base-primary);
35
+ border-radius: var(--rs-radius-3);
36
+ text-decoration: none;
37
+ color: inherit;
38
+ background: var(--rs-color-background-base-primary);
39
+ transition: border-color 0.15s ease, background 0.15s ease;
40
+ }
41
+
42
+ .card:hover {
43
+ border-color: var(--rs-color-border-accent-primary);
44
+ background: var(--rs-color-background-neutral-secondary);
45
+ }
46
+
47
+ .cardLabel {
48
+ font-size: var(--rs-font-size-large);
49
+ font-weight: 500;
50
+ }
51
+
52
+ .cardHref {
53
+ font-size: var(--rs-font-size-small);
54
+ color: var(--rs-color-foreground-base-secondary);
55
+ font-family: var(--rs-font-family-mono);
56
+ }
@@ -0,0 +1,39 @@
1
+ import { Link as RouterLink } from 'react-router';
2
+ import { getLandingEntries } from '@/lib/config';
3
+ import { usePageContext } from '@/lib/page-context';
4
+ import styles from './LandingPage.module.css';
5
+
6
+ export function LandingPage() {
7
+ const { config, version } = usePageContext();
8
+ const entries = getLandingEntries(config, version.dir);
9
+
10
+ const heading = version.dir === null
11
+ ? config.site.title
12
+ : `${config.site.title} — ${versionLabel(config, version.dir)}`;
13
+
14
+ return (
15
+ <div className={styles.root}>
16
+ <h1 className={styles.title}>{heading}</h1>
17
+ {config.site.description ? (
18
+ <p className={styles.description}>{config.site.description}</p>
19
+ ) : null}
20
+ <div className={styles.grid}>
21
+ {entries.map((entry) => (
22
+ <RouterLink key={entry.href} to={entry.href} className={styles.card}>
23
+ <span className={styles.cardLabel}>{entry.label}</span>
24
+ <span className={styles.cardHref}>{entry.href}</span>
25
+ </RouterLink>
26
+ ))}
27
+ </div>
28
+ </div>
29
+ );
30
+ }
31
+
32
+ function versionLabel(
33
+ config: ReturnType<typeof usePageContext>['config'],
34
+ versionDir: string,
35
+ ): string {
36
+ return (
37
+ config.versions?.find((v) => v.dir === versionDir)?.label ?? versionDir
38
+ );
39
+ }
@@ -1,9 +1,11 @@
1
+ import { DocumentTextIcon } from '@heroicons/react/24/outline';
1
2
  import { EmptyState } from '@raystack/apsara';
2
3
  import styles from './NotFound.module.css';
3
4
 
4
5
  export function NotFound() {
5
6
  return (
6
7
  <EmptyState
8
+ icon={<DocumentTextIcon width={32} height={32} />}
7
9
  heading="404"
8
10
  subHeading="Page not found"
9
11
  classNames={{ container: styles.emptyState }}
@@ -1,49 +1,47 @@
1
1
  import '@raystack/apsara/normalize.css';
2
2
  import '@raystack/apsara/style.css';
3
3
  import { ThemeProvider } from '@raystack/apsara';
4
- import { useLocation } from 'react-router';
4
+ import { Navigate, useLocation } from 'react-router';
5
5
  import { Head } from '@/lib/head';
6
6
  import { usePageContext } from '@/lib/page-context';
7
+ import { resolveRoute, RouteType } from '@/lib/route-resolver';
7
8
  import { ApiLayout } from '@/pages/ApiLayout';
8
9
  import { ApiPage } from '@/pages/ApiPage';
9
10
  import { DocsLayout } from '@/pages/DocsLayout';
10
11
  import { DocsPage } from '@/pages/DocsPage';
12
+ import { LandingPage } from '@/pages/LandingPage';
11
13
  import type { ChronicleConfig } from '@/types';
12
14
  import { getThemeConfig } from '@/themes/registry';
13
15
 
14
- function resolveRoute(pathname: string) {
15
- if (pathname.startsWith('/apis')) {
16
- const slug = pathname
17
- .replace(/^\/apis\/?/, '')
18
- .split('/')
19
- .filter(Boolean);
20
- return { type: 'api' as const, slug };
21
- }
22
-
23
- const slug =
24
- pathname === '/' ? [] : pathname.slice(1).split('/').filter(Boolean);
25
- return { type: 'docs' as const, slug };
26
- }
27
-
28
16
  export function App() {
29
17
  const { pathname } = useLocation();
30
18
  const { config } = usePageContext();
31
- const route = resolveRoute(pathname);
19
+ const route = resolveRoute(pathname, config);
32
20
  const themeConfig = getThemeConfig(config.theme?.name);
33
21
 
22
+ if (route.type === RouteType.Redirect) {
23
+ return <Navigate to={route.to} replace />;
24
+ }
25
+
26
+ const isApi =
27
+ route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage;
28
+ const apiSlug = route.type === RouteType.ApiPage ? route.slug : [];
29
+ const docsSlug = route.type === RouteType.DocsPage ? route.slug : [];
30
+ const isLanding = route.type === RouteType.DocsIndex;
31
+
34
32
  return (
35
33
  <ThemeProvider
36
34
  enableSystem={themeConfig.enableSystem}
37
35
  forcedTheme={themeConfig.forcedTheme}
38
36
  >
39
37
  <RootHead config={config} />
40
- {route.type === 'api' ? (
38
+ {isApi ? (
41
39
  <ApiLayout>
42
- <ApiPage slug={route.slug} />
40
+ <ApiPage slug={apiSlug} />
43
41
  </ApiLayout>
44
42
  ) : (
45
43
  <DocsLayout>
46
- <DocsPage slug={route.slug} />
44
+ {isLanding ? <LandingPage /> : <DocsPage slug={docsSlug} />}
47
45
  </DocsLayout>
48
46
  )}
49
47
  </ThemeProvider>
@@ -53,16 +51,16 @@ export function App() {
53
51
  function RootHead({ config }: { config: ChronicleConfig }) {
54
52
  return (
55
53
  <Head
56
- title={config.title}
57
- description={config.description}
54
+ title={config.site.title}
55
+ description={config.site.description}
58
56
  config={config}
59
57
  jsonLd={
60
58
  config.url
61
59
  ? {
62
60
  '@context': 'https://schema.org',
63
61
  '@type': 'WebSite',
64
- name: config.title,
65
- description: config.description,
62
+ name: config.site.title,
63
+ description: config.site.description,
66
64
  url: config.url
67
65
  }
68
66
  : undefined
@@ -1,5 +1,5 @@
1
1
  import { defineHandler, HTTPError } from 'nitro';
2
- import { getPage, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
2
+ import { getPage, getPageNav, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
3
3
 
4
4
  export default defineHandler(async event => {
5
5
  const slugParam = event.url.searchParams.get('slug') ?? '';
@@ -10,9 +10,13 @@ export default defineHandler(async event => {
10
10
  throw new HTTPError({ status: 404, message: 'Page not found' });
11
11
  }
12
12
 
13
+ const nav = await getPageNav(slug);
14
+
13
15
  return {
14
16
  frontmatter: extractFrontmatter(page, slug[slug.length - 1]),
15
17
  relativePath: getRelativePath(page),
16
18
  originalPath: getOriginalPath(page),
19
+ prev: nav.prev,
20
+ next: nav.next,
17
21
  };
18
22
  });