@raystack/chronicle 0.5.3 → 0.6.0

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 (74) hide show
  1. package/dist/cli/index.js +260 -81
  2. package/package.json +8 -6
  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/mdx/mermaid.tsx +24 -21
  16. package/src/components/ui/breadcrumbs.tsx +4 -2
  17. package/src/components/ui/client-theme-switcher.tsx +21 -4
  18. package/src/components/ui/search.module.css +16 -41
  19. package/src/components/ui/search.tsx +30 -41
  20. package/src/lib/config.test.ts +493 -0
  21. package/src/lib/config.ts +123 -22
  22. package/src/lib/head.tsx +23 -5
  23. package/src/lib/llms.test.ts +94 -0
  24. package/src/lib/llms.ts +41 -0
  25. package/src/lib/navigation.test.ts +94 -0
  26. package/src/lib/navigation.ts +51 -0
  27. package/src/lib/page-context.tsx +79 -32
  28. package/src/lib/route-resolver.test.ts +173 -0
  29. package/src/lib/route-resolver.ts +73 -0
  30. package/src/lib/source.ts +94 -1
  31. package/src/lib/version-source.test.ts +163 -0
  32. package/src/lib/version-source.ts +101 -0
  33. package/src/pages/ApiPage.tsx +1 -1
  34. package/src/pages/DocsLayout.tsx +24 -3
  35. package/src/pages/DocsPage.tsx +7 -7
  36. package/src/pages/LandingPage.module.css +56 -0
  37. package/src/pages/LandingPage.tsx +39 -0
  38. package/src/pages/NotFound.module.css +3 -0
  39. package/src/pages/NotFound.tsx +9 -12
  40. package/src/server/App.tsx +21 -23
  41. package/src/server/api/{page/[...slug].ts → page.ts} +7 -3
  42. package/src/server/api/search.ts +51 -24
  43. package/src/server/api/specs.ts +17 -5
  44. package/src/server/entry-client.tsx +42 -14
  45. package/src/server/entry-server.tsx +35 -13
  46. package/src/server/plugins/telemetry.ts +47 -7
  47. package/src/server/routes/[...slug].md.ts +0 -6
  48. package/src/server/routes/[version]/llms.txt.ts +26 -0
  49. package/src/server/routes/llms.txt.ts +10 -13
  50. package/src/server/routes/og.tsx +2 -2
  51. package/src/server/routes/sitemap.xml.ts +14 -6
  52. package/src/server/vite-config.ts +5 -5
  53. package/src/themes/default/ContentDirButtons.tsx +66 -0
  54. package/src/themes/default/Layout.module.css +187 -40
  55. package/src/themes/default/Layout.tsx +166 -65
  56. package/src/themes/default/OpenInAI.tsx +112 -0
  57. package/src/themes/default/Page.module.css +30 -0
  58. package/src/themes/default/Page.tsx +1 -3
  59. package/src/themes/default/SidebarLogo.tsx +26 -0
  60. package/src/themes/default/Toc.module.css +102 -25
  61. package/src/themes/default/Toc.tsx +56 -10
  62. package/src/themes/default/VersionSwitcher.tsx +59 -0
  63. package/src/themes/paper/ContentDirDropdown.tsx +47 -0
  64. package/src/themes/paper/Layout.module.css +7 -0
  65. package/src/themes/paper/Layout.tsx +20 -13
  66. package/src/themes/paper/VersionSwitcher.tsx +60 -0
  67. package/src/types/config.ts +146 -23
  68. package/src/types/content.ts +11 -1
  69. package/src/types/theme.ts +1 -0
  70. package/src/components/ui/footer.module.css +0 -27
  71. package/src/components/ui/footer.tsx +0 -30
  72. package/src/server/api/metrics.ts +0 -23
  73. package/src/server/api/page/index.ts +0 -1
  74. package/src/server/telemetry.ts +0 -49
@@ -0,0 +1,73 @@
1
+ import type { ChronicleConfig } from '@/types'
2
+ import { getLatestContentRoots, getVersionContentRoots } from './config'
3
+ import { type VersionContext, resolveVersionFromUrl } from './version-source'
4
+
5
+ export const RouteType = {
6
+ Redirect: 'redirect',
7
+ DocsIndex: 'docs-index',
8
+ DocsPage: 'docs-page',
9
+ ApiIndex: 'api-index',
10
+ ApiPage: 'api-page',
11
+ } as const
12
+
13
+ export type RouteType = (typeof RouteType)[keyof typeof RouteType]
14
+
15
+ export type Route =
16
+ | { type: typeof RouteType.Redirect; to: string; status: 302 }
17
+ | { type: typeof RouteType.DocsIndex; version: VersionContext }
18
+ | { type: typeof RouteType.DocsPage; version: VersionContext; slug: string[] }
19
+ | { type: typeof RouteType.ApiIndex; version: VersionContext }
20
+ | { type: typeof RouteType.ApiPage; version: VersionContext; slug: string[] }
21
+
22
+ function contentDirsFor(
23
+ config: ChronicleConfig,
24
+ version: VersionContext,
25
+ ): string[] {
26
+ if (version.dir === null) {
27
+ return getLatestContentRoots(config).map((root) => root.contentDir)
28
+ }
29
+ return getVersionContentRoots(config, version.dir).map(
30
+ (root) => root.contentDir,
31
+ )
32
+ }
33
+
34
+ function isLandingEnabled(
35
+ config: ChronicleConfig,
36
+ version: VersionContext,
37
+ ): boolean {
38
+ if (version.dir === null) return config.latest?.landing === true
39
+ return (
40
+ config.versions?.find((v) => v.dir === version.dir)?.landing === true
41
+ )
42
+ }
43
+
44
+ export function resolveRoute(
45
+ pathname: string,
46
+ config: ChronicleConfig,
47
+ ): Route {
48
+ const parts = pathname.split('/').filter(Boolean)
49
+ const version = resolveVersionFromUrl(pathname, config)
50
+ const remainder =
51
+ version.dir !== null && parts[0] === version.dir ? parts.slice(1) : parts
52
+
53
+ if (remainder[0] === 'apis') {
54
+ const slug = remainder.slice(1)
55
+ if (slug.length === 0) return { type: RouteType.ApiIndex, version }
56
+ return { type: RouteType.ApiPage, version, slug }
57
+ }
58
+
59
+ if (remainder.length === 0) {
60
+ if (isLandingEnabled(config, version)) {
61
+ return { type: RouteType.DocsIndex, version }
62
+ }
63
+ const dirs = contentDirsFor(config, version)
64
+ if (dirs.length === 0) return { type: RouteType.DocsIndex, version }
65
+ return {
66
+ type: RouteType.Redirect,
67
+ to: `${version.urlPrefix}/${dirs[0]}`,
68
+ status: 302,
69
+ }
70
+ }
71
+
72
+ return { type: RouteType.DocsPage, version, slug: parts }
73
+ }
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
  );
@@ -1,5 +1,6 @@
1
1
  import { Head } from '@/lib/head';
2
2
  import { usePageContext } from '@/lib/page-context';
3
+ import { NotFound } from '@/pages/NotFound';
3
4
  import { getTheme } from '@/themes/registry';
4
5
 
5
6
  interface DocsPageProps {
@@ -7,12 +8,15 @@ interface DocsPageProps {
7
8
  }
8
9
 
9
10
  export function DocsPage({ slug }: DocsPageProps) {
10
- const { config, tree, page } = usePageContext();
11
+ const { config, tree, page, errorStatus } = usePageContext();
11
12
 
13
+ if (errorStatus === 404) return <NotFound />;
14
+ if (errorStatus) return <NotFound />;
12
15
  if (!page) return null;
13
16
 
14
17
  const { Page } = getTheme(config.theme?.name);
15
18
  const pageUrl = config.url ? `${config.url}/${slug.join('/')}` : undefined;
19
+ const markdownHref = `/${slug.join('/')}.md`;
16
20
 
17
21
  return (
18
22
  <>
@@ -20,6 +24,7 @@ export function DocsPage({ slug }: DocsPageProps) {
20
24
  title={page.frontmatter.title}
21
25
  description={page.frontmatter.description}
22
26
  config={config}
27
+ markdownHref={markdownHref}
23
28
  jsonLd={{
24
29
  '@context': 'https://schema.org',
25
30
  '@type': 'Article',
@@ -29,12 +34,7 @@ export function DocsPage({ slug }: DocsPageProps) {
29
34
  }}
30
35
  />
31
36
  <Page
32
- page={{
33
- slug: page.slug,
34
- frontmatter: page.frontmatter,
35
- content: page.content,
36
- toc: page.toc
37
- }}
37
+ page={page}
38
38
  config={config}
39
39
  tree={tree}
40
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
+ }
@@ -0,0 +1,3 @@
1
+ .emptyState {
2
+ justify-content: center;
3
+ }
@@ -1,17 +1,14 @@
1
- import { Flex, Headline, Text } from '@raystack/apsara';
1
+ import { DocumentTextIcon } from '@heroicons/react/24/outline';
2
+ import { EmptyState } from '@raystack/apsara';
3
+ import styles from './NotFound.module.css';
2
4
 
3
5
  export function NotFound() {
4
6
  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>
7
+ <EmptyState
8
+ icon={<DocumentTextIcon width={32} height={32} />}
9
+ heading="404"
10
+ subHeading="Page not found"
11
+ classNames={{ container: styles.emptyState }}
12
+ />
16
13
  );
17
14
  }