@raystack/chronicle 0.9.0 → 0.10.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 (35) hide show
  1. package/dist/cli/index.js +43 -4
  2. package/package.json +3 -2
  3. package/src/cli/commands/dev.ts +12 -0
  4. package/src/cli/commands/start.ts +12 -0
  5. package/src/components/api/playground-dialog.tsx +14 -14
  6. package/src/components/mdx/link.tsx +5 -31
  7. package/src/lib/folder-utils.ts +26 -0
  8. package/src/lib/route-resolver.test.ts +3 -3
  9. package/src/lib/route-resolver.ts +12 -2
  10. package/src/lib/source-utils.test.ts +85 -0
  11. package/src/lib/source.ts +9 -20
  12. package/src/lib/tree-utils.test.ts +113 -0
  13. package/src/lib/tree-utils.ts +57 -0
  14. package/src/pages/DocsPage.tsx +5 -36
  15. package/src/server/entry-server.tsx +30 -1
  16. package/src/server/routes/[...slug].md.ts +5 -1
  17. package/src/server/{routes/apis/[...slug].md.ts → utils/api-markdown.ts} +3 -6
  18. package/src/server/vite-config.ts +14 -4
  19. package/src/themes/default/ContentDirButtons.tsx +1 -1
  20. package/src/themes/default/Layout.tsx +18 -6
  21. package/src/themes/default/Page.module.css +9 -0
  22. package/src/themes/default/Skeleton.tsx +5 -15
  23. package/src/themes/default/VersionSwitcher.tsx +2 -2
  24. package/src/themes/paper/VersionSwitcher.tsx +2 -2
  25. package/src/types/config.ts +8 -0
  26. package/src/components/common/breadcrumb.tsx +0 -3
  27. package/src/components/common/button.tsx +0 -3
  28. package/src/components/common/code-block.tsx +0 -3
  29. package/src/components/common/dialog.tsx +0 -3
  30. package/src/components/common/index.ts +0 -10
  31. package/src/components/common/input-field.tsx +0 -3
  32. package/src/components/common/sidebar.tsx +0 -3
  33. package/src/components/common/switch.tsx +0 -3
  34. package/src/components/common/table.tsx +0 -3
  35. package/src/components/common/tabs.tsx +0 -3
package/dist/cli/index.js CHANGED
@@ -312,6 +312,18 @@ import { nitro } from "nitro/vite";
312
312
  import fs3 from "node:fs/promises";
313
313
  import path6 from "node:path";
314
314
  import remarkDirective from "remark-directive";
315
+ function getDatabaseConnector(preset) {
316
+ switch (preset) {
317
+ case "bun":
318
+ return { connector: "bun-sqlite", options: { name: "chronicle-search" } };
319
+ case "cloudflare":
320
+ case "cloudflare-pages":
321
+ case "cloudflare-module":
322
+ return { connector: "cloudflare-d1", options: { bindingName: "CHRONICLE_DB" } };
323
+ default:
324
+ return { connector: "sqlite", options: { name: "chronicle-search" } };
325
+ }
326
+ }
315
327
  function resolveOutputDir(projectRoot, preset) {
316
328
  if (preset === "vercel" || preset === "vercel-static")
317
329
  return path6.resolve(projectRoot, ".vercel/output");
@@ -429,10 +441,7 @@ async function createViteConfig(options) {
429
441
  database: true
430
442
  },
431
443
  database: {
432
- default: {
433
- connector: "sqlite",
434
- options: { name: "chronicle-search" }
435
- }
444
+ default: getDatabaseConnector(preset)
436
445
  }
437
446
  }
438
447
  };
@@ -561,6 +570,11 @@ var RESERVED_ROUTE_SEGMENTS = [
561
570
  "robots.txt",
562
571
  "sitemap.xml"
563
572
  ];
573
+ var redirectSchema = z.object({
574
+ from: z.string(),
575
+ to: z.string(),
576
+ permanent: z.boolean().optional()
577
+ });
564
578
  var chronicleConfigSchema = z.object({
565
579
  site: siteSchema,
566
580
  url: z.string().optional(),
@@ -573,6 +587,7 @@ var chronicleConfigSchema = z.object({
573
587
  navigation: navigationSchema.optional(),
574
588
  search: searchSchema.optional(),
575
589
  api: z.array(apiSchema).optional(),
590
+ redirects: z.array(redirectSchema).optional(),
576
591
  analytics: analyticsSchema.optional(),
577
592
  telemetry: telemetrySchema.optional()
578
593
  }).strict().refine((cfg) => allUnique(cfg.content, (c) => c.dir), {
@@ -807,6 +822,18 @@ var devCommand = new Command2("dev").description("Start development server").opt
807
822
  });
808
823
  await server.listen();
809
824
  server.printUrls();
825
+ let shuttingDown = false;
826
+ const shutdown = async () => {
827
+ if (shuttingDown)
828
+ return;
829
+ shuttingDown = true;
830
+ try {
831
+ await server.close();
832
+ } catch {}
833
+ process.exit(0);
834
+ };
835
+ process.once("SIGINT", shutdown);
836
+ process.once("SIGTERM", shutdown);
810
837
  });
811
838
 
812
839
  // src/cli/commands/init.ts
@@ -938,6 +965,18 @@ var startCommand = new Command5("start").description("Start production server").
938
965
  preview: { port, host: options.host }
939
966
  });
940
967
  server.printUrls();
968
+ let shuttingDown = false;
969
+ const shutdown = async () => {
970
+ if (shuttingDown)
971
+ return;
972
+ shuttingDown = true;
973
+ try {
974
+ await server.close();
975
+ } catch {}
976
+ process.exit(0);
977
+ };
978
+ process.once("SIGINT", shutdown);
979
+ process.once("SIGTERM", shutdown);
941
980
  });
942
981
 
943
982
  // src/cli/index.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raystack/chronicle",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Config-driven documentation framework",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -47,7 +47,7 @@
47
47
  "@opentelemetry/sdk-metrics": "^2.6.1",
48
48
  "@opentelemetry/semantic-conventions": "^1.40.0",
49
49
  "@radix-ui/react-icons": "^1.3.2",
50
- "@raystack/apsara": "1.0.0-rc.4",
50
+ "@raystack/apsara": "1.0.0-rc.7",
51
51
  "@shikijs/rehype": "^4.0.2",
52
52
  "@vitejs/plugin-react": "^6.0.1",
53
53
  "chalk": "^5.6.2",
@@ -59,6 +59,7 @@
59
59
  "glob": "^11.0.0",
60
60
  "gray-matter": "^4.0.3",
61
61
  "h3": "^2.0.1-rc.16",
62
+ "http-status-codes": "^2.3.0",
62
63
  "lodash-es": "^4.17.23",
63
64
  "mermaid": "^11.13.0",
64
65
  "nitro": "3.0.260311-beta",
@@ -28,4 +28,16 @@ export const devCommand = new Command('dev')
28
28
 
29
29
  await server.listen();
30
30
  server.printUrls();
31
+
32
+ let shuttingDown = false;
33
+ const shutdown = async () => {
34
+ if (shuttingDown) return;
35
+ shuttingDown = true;
36
+ try {
37
+ await server.close();
38
+ } catch { /* ignore close errors */ }
39
+ process.exit(0);
40
+ };
41
+ process.once('SIGINT', shutdown);
42
+ process.once('SIGTERM', shutdown);
31
43
  });
@@ -26,4 +26,16 @@ export const startCommand = new Command('start')
26
26
  });
27
27
 
28
28
  server.printUrls();
29
+
30
+ let shuttingDown = false;
31
+ const shutdown = async () => {
32
+ if (shuttingDown) return;
33
+ shuttingDown = true;
34
+ try {
35
+ await server.close();
36
+ } catch { /* ignore close errors */ }
37
+ process.exit(0);
38
+ };
39
+ process.once('SIGINT', shutdown);
40
+ process.once('SIGTERM', shutdown);
29
41
  });
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useState, useCallback, useMemo } from 'react'
4
4
  import type { OpenAPIV3 } from 'openapi-types'
5
- import { Dialog, Button, Badge, IconButton, InputField, CopyButton, Select, Menu } from '@raystack/apsara'
5
+ import { Dialog, Button, Badge, IconButton, Input, CopyButton, Select, Menu } from '@raystack/apsara'
6
6
  import { Cross2Icon, ChevronDownIcon, ChevronUpIcon, PlayIcon, PlusIcon } from '@radix-ui/react-icons'
7
7
  import { CounterClockwiseClockIcon, CodeIcon } from '@radix-ui/react-icons'
8
8
  import { MethodBadge } from '@/components/api/method-badge'
@@ -263,13 +263,13 @@ export function PlaygroundDialog({
263
263
  <div className={styles.fieldRow}>
264
264
  <span className={styles.fieldLabel}>Username</span>
265
265
  <div className={styles.fieldInput}>
266
- <InputField size="small" placeholder="Enter username" value={basicUser} onChange={(e) => setBasicUser(e.target.value)} />
266
+ <Input size="small" placeholder="Enter username" value={basicUser} onValueChange={setBasicUser} />
267
267
  </div>
268
268
  </div>
269
269
  <div className={styles.fieldRow}>
270
270
  <span className={styles.fieldLabel}>Password</span>
271
271
  <div className={styles.fieldInput}>
272
- <InputField size="small" type="password" placeholder="Enter password" value={basicPass} onChange={(e) => setBasicPass(e.target.value)} />
272
+ <Input size="small" type="password" placeholder="Enter password" value={basicPass} onValueChange={setBasicPass} />
273
273
  </div>
274
274
  </div>
275
275
  </>
@@ -277,7 +277,7 @@ export function PlaygroundDialog({
277
277
  <div className={styles.fieldRow}>
278
278
  <span className={styles.fieldLabel}>{currentScheme.headerName}</span>
279
279
  <div className={styles.fieldInput}>
280
- <InputField size="small" placeholder={currentScheme.placeholder} value={authToken} onChange={(e) => setAuthToken(e.target.value)} />
280
+ <Input size="small" placeholder={currentScheme.placeholder} value={authToken} onValueChange={setAuthToken} />
281
281
  </div>
282
282
  </div>
283
283
  ) : null}
@@ -285,7 +285,7 @@ export function PlaygroundDialog({
285
285
  <div key={f.name} className={styles.fieldRow}>
286
286
  <span className={styles.fieldLabel}>{f.name}</span>
287
287
  <div className={styles.fieldInput}>
288
- <InputField size="small" placeholder="Enter value" value={headerValues[f.name] ?? ''} onChange={(e) => setHeaderValues({ ...headerValues, [f.name]: e.target.value })} />
288
+ <Input size="small" placeholder="Enter value" value={headerValues[f.name] ?? ''} onValueChange={(v) => setHeaderValues({ ...headerValues, [f.name]: v })} />
289
289
  </div>
290
290
  </div>
291
291
  ))}
@@ -307,11 +307,11 @@ export function PlaygroundDialog({
307
307
  <div key={f.name} className={styles.fieldRow}>
308
308
  <span className={styles.fieldLabel}>{f.name}</span>
309
309
  <div className={styles.fieldInput}>
310
- <InputField
310
+ <Input
311
311
  size="small"
312
312
  placeholder="Enter value"
313
313
  value={pathValues[f.name] ?? ''}
314
- onChange={(e) => setPathValues({ ...pathValues, [f.name]: e.target.value })}
314
+ onValueChange={(v) => setPathValues({ ...pathValues, [f.name]: v })}
315
315
  />
316
316
  </div>
317
317
  </div>
@@ -332,11 +332,11 @@ export function PlaygroundDialog({
332
332
  <div key={f.name} className={styles.fieldRow}>
333
333
  <span className={styles.fieldLabel}>{f.name}</span>
334
334
  <div className={styles.fieldInput}>
335
- <InputField
335
+ <Input
336
336
  size="small"
337
337
  placeholder={f.description ?? 'Enter value'}
338
338
  value={queryValues[f.name] ?? ''}
339
- onChange={(e) => setQueryValues({ ...queryValues, [f.name]: e.target.value })}
339
+ onValueChange={(v) => setQueryValues({ ...queryValues, [f.name]: v })}
340
340
  />
341
341
  </div>
342
342
  </div>
@@ -494,13 +494,13 @@ function BodyFieldRow({ field, value, onChange }: {
494
494
  {items.map((item, i) => (
495
495
  <div key={i} className={styles.arrayItemRow}>
496
496
  <div className={styles.fieldInput}>
497
- <InputField
497
+ <Input
498
498
  size="small"
499
499
  placeholder={`${field.name}[${i}]`}
500
500
  value={String(item)}
501
- onChange={(e) => {
501
+ onValueChange={(v) => {
502
502
  const updated = [...items]
503
- updated[i] = e.target.value
503
+ updated[i] = v
504
504
  onChange(updated)
505
505
  }}
506
506
  />
@@ -539,11 +539,11 @@ function BodyFieldRow({ field, value, onChange }: {
539
539
  <div className={styles.fieldRow}>
540
540
  <span className={styles.fieldLabel}>{field.name} {field.required && <Badge variant="danger" size="micro">required</Badge>}</span>
541
541
  <div className={styles.fieldInput}>
542
- <InputField
542
+ <Input
543
543
  size="small"
544
544
  placeholder={field.description ?? 'Enter value'}
545
545
  value={String(value ?? '')}
546
- onChange={(e) => onChange(e.target.value)}
546
+ onValueChange={(v) => onChange(v)}
547
547
  />
548
548
  </div>
549
549
  </div>
@@ -1,12 +1,10 @@
1
1
  import { Link as ApsaraLink } from '@raystack/apsara';
2
- import type { ComponentProps, MouseEvent } from 'react';
3
- import { useNavigate } from 'react-router';
2
+ import type { ComponentProps } from 'react';
3
+ import { Link as RouterLink } from 'react-router';
4
4
 
5
5
  type LinkProps = ComponentProps<'a'>;
6
6
 
7
- export function Link({ href, children, onClick: onClickProp, ...props }: LinkProps) {
8
- const navigate = useNavigate();
9
-
7
+ export function Link({ href, children, ...props }: LinkProps) {
10
8
  if (!href) {
11
9
  return <span {...props}>{children}</span>;
12
10
  }
@@ -16,12 +14,7 @@ export function Link({ href, children, onClick: onClickProp, ...props }: LinkPro
16
14
 
17
15
  if (isExternal) {
18
16
  return (
19
- <ApsaraLink
20
- href={href}
21
- target='_blank'
22
- rel='noopener noreferrer'
23
- {...props}
24
- >
17
+ <ApsaraLink href={href} external {...props}>
25
18
  {children}
26
19
  </ApsaraLink>
27
20
  );
@@ -35,27 +28,8 @@ export function Link({ href, children, onClick: onClickProp, ...props }: LinkPro
35
28
  );
36
29
  }
37
30
 
38
- const onClick = (e: MouseEvent<HTMLAnchorElement>) => {
39
- if (
40
- e.defaultPrevented ||
41
- e.button !== 0 ||
42
- e.metaKey ||
43
- e.ctrlKey ||
44
- e.shiftKey ||
45
- e.altKey
46
- ) {
47
- return;
48
- }
49
-
50
- onClickProp?.(e);
51
- if (e.defaultPrevented) return;
52
-
53
- e.preventDefault();
54
- navigate(href);
55
- };
56
-
57
31
  return (
58
- <ApsaraLink href={href} {...props} onClick={onClick}>
32
+ <ApsaraLink render={<RouterLink to={href} />} {...props}>
59
33
  {children}
60
34
  </ApsaraLink>
61
35
  );
@@ -0,0 +1,26 @@
1
+ import type { Node, Folder } from 'fumadocs-core/page-tree';
2
+
3
+ const NodeType = {
4
+ Page: 'page',
5
+ Folder: 'folder',
6
+ } as const;
7
+
8
+ export function parentPath(url: string): string {
9
+ const parts = url.split('/').filter(Boolean);
10
+ parts.pop();
11
+ return '/' + parts.join('/');
12
+ }
13
+
14
+ export function getFolderPath(node: Folder): string | null {
15
+ if (node.index) return node.index.url;
16
+ for (const child of node.children) {
17
+ if (child.type === NodeType.Page) return parentPath(child.url);
18
+ }
19
+ for (const child of node.children) {
20
+ if (child.type === NodeType.Folder) {
21
+ const childPath = getFolderPath(child as Folder);
22
+ if (childPath) return parentPath(childPath);
23
+ }
24
+ }
25
+ return null;
26
+ }
@@ -60,7 +60,7 @@ describe('resolveRoute — root', () => {
60
60
  expect(resolveRoute('/', singleContent())).toEqual({
61
61
  type: RouteType.Redirect,
62
62
  to: '/docs',
63
- status: 302,
63
+ status: 307,
64
64
  })
65
65
  })
66
66
 
@@ -75,7 +75,7 @@ describe('resolveRoute — root', () => {
75
75
  expect(resolveRoute('/', multiContentNoLanding())).toEqual({
76
76
  type: RouteType.Redirect,
77
77
  to: '/docs',
78
- status: 302,
78
+ status: 307,
79
79
  })
80
80
  })
81
81
 
@@ -83,7 +83,7 @@ describe('resolveRoute — root', () => {
83
83
  expect(resolveRoute('/v2', versioned())).toEqual({
84
84
  type: RouteType.Redirect,
85
85
  to: '/v2/docs',
86
- status: 302,
86
+ status: 307,
87
87
  })
88
88
  })
89
89
 
@@ -1,3 +1,4 @@
1
+ import { StatusCodes } from 'http-status-codes'
1
2
  import type { ChronicleConfig } from '@/types'
2
3
  import { getLatestContentRoots, getVersionContentRoots } from './config'
3
4
  import { type VersionContext, resolveVersionFromUrl } from './version-source'
@@ -13,7 +14,7 @@ export const RouteType = {
13
14
  export type RouteType = (typeof RouteType)[keyof typeof RouteType]
14
15
 
15
16
  export type Route =
16
- | { type: typeof RouteType.Redirect; to: string; status: 302 }
17
+ | { type: typeof RouteType.Redirect; to: string; status: StatusCodes.TEMPORARY_REDIRECT | StatusCodes.PERMANENT_REDIRECT }
17
18
  | { type: typeof RouteType.DocsIndex; version: VersionContext }
18
19
  | { type: typeof RouteType.DocsPage; version: VersionContext; slug: string[] }
19
20
  | { type: typeof RouteType.ApiIndex; version: VersionContext }
@@ -45,6 +46,15 @@ export function resolveRoute(
45
46
  pathname: string,
46
47
  config: ChronicleConfig,
47
48
  ): Route {
49
+ const redirect = config.redirects?.find((r) => r.from === pathname)
50
+ if (redirect) {
51
+ return {
52
+ type: RouteType.Redirect,
53
+ to: redirect.to,
54
+ status: redirect.permanent ? StatusCodes.PERMANENT_REDIRECT : StatusCodes.TEMPORARY_REDIRECT,
55
+ }
56
+ }
57
+
48
58
  const parts = pathname.split('/').filter(Boolean)
49
59
  const version = resolveVersionFromUrl(pathname, config)
50
60
  const remainder =
@@ -65,7 +75,7 @@ export function resolveRoute(
65
75
  return {
66
76
  type: RouteType.Redirect,
67
77
  to: `${version.urlPrefix}/${dirs[0]}`,
68
- status: 302,
78
+ status: StatusCodes.TEMPORARY_REDIRECT,
69
79
  }
70
80
  }
71
81
 
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { Node, Folder } from 'fumadocs-core/page-tree'
3
+ import { parentPath, getFolderPath } from './folder-utils'
4
+
5
+ function page(url: string): Node {
6
+ return { type: 'page', name: 'Page', url } as Node
7
+ }
8
+
9
+ function folder(name: string, children: Node[], indexUrl?: string): Folder {
10
+ return {
11
+ type: 'folder',
12
+ name,
13
+ children,
14
+ ...(indexUrl ? { index: { url: indexUrl } } : {}),
15
+ } as Folder
16
+ }
17
+
18
+ describe('parentPath', () => {
19
+ test('returns parent of page URL', () => {
20
+ expect(parentPath('/docs/guides/install')).toBe('/docs/guides')
21
+ })
22
+
23
+ test('returns root for top-level page', () => {
24
+ expect(parentPath('/docs')).toBe('/')
25
+ })
26
+
27
+ test('handles trailing segments', () => {
28
+ expect(parentPath('/a/b/c/d')).toBe('/a/b/c')
29
+ })
30
+
31
+ test('handles root', () => {
32
+ expect(parentPath('/')).toBe('/')
33
+ })
34
+ })
35
+
36
+ describe('getFolderPath', () => {
37
+ test('returns index URL when folder has index', () => {
38
+ const f = folder('Guides', [page('/docs/guides/install')], '/docs/guides')
39
+ expect(getFolderPath(f)).toBe('/docs/guides')
40
+ })
41
+
42
+ test('derives path from direct child page', () => {
43
+ const f = folder('Guides', [page('/docs/guides/install')])
44
+ expect(getFolderPath(f)).toBe('/docs/guides')
45
+ })
46
+
47
+ test('derives path from subfolder child (not deeply nested)', () => {
48
+ const f = folder('Tasking', [
49
+ folder('Via Order Desk', [page('/docs/tasking/via_order_desk/package')])
50
+ ])
51
+ expect(getFolderPath(f)).toBe('/docs/tasking')
52
+ })
53
+
54
+ test('handles folder with & in path', () => {
55
+ const f = folder('Cart & Order', [page('/docs/cart&order/working_with_cart')])
56
+ expect(getFolderPath(f)).toBe('/docs/cart&order')
57
+ })
58
+
59
+ test('handles folder with space in path', () => {
60
+ const f = folder('My Folder', [page('/docs/my folder/intro')])
61
+ expect(getFolderPath(f)).toBe('/docs/my folder')
62
+ })
63
+
64
+ test('returns null for empty folder', () => {
65
+ const f = folder('Empty', [])
66
+ expect(getFolderPath(f)).toBeNull()
67
+ })
68
+
69
+ test('prefers direct child page over subfolder', () => {
70
+ const f = folder('Mixed', [
71
+ page('/docs/mixed/intro'),
72
+ folder('Sub', [page('/docs/mixed/sub/deep')])
73
+ ])
74
+ expect(getFolderPath(f)).toBe('/docs/mixed')
75
+ })
76
+
77
+ test('deeply nested only-subfolder chain', () => {
78
+ const f = folder('Root', [
79
+ folder('Mid', [
80
+ folder('Deep', [page('/a/b/c/d/page')])
81
+ ])
82
+ ])
83
+ expect(getFolderPath(f)).toBe('/a/b')
84
+ })
85
+ })
package/src/lib/source.ts CHANGED
@@ -3,6 +3,13 @@ import path from 'node:path';
3
3
  import { loader } from 'fumadocs-core/source';
4
4
  import { flattenTree } from 'fumadocs-core/page-tree';
5
5
  import type { Root, Node, Folder } from 'fumadocs-core/page-tree';
6
+
7
+ import { parentPath, getFolderPath } from './folder-utils';
8
+
9
+ const NodeType = {
10
+ Page: 'page',
11
+ Folder: 'folder',
12
+ } as const;
6
13
  import type { MDXContent } from 'mdx/types';
7
14
  import type { TableOfContents } from 'fumadocs-core/toc';
8
15
  import {
@@ -120,28 +127,10 @@ export function invalidate() {
120
127
  cachedNavMap = null;
121
128
  }
122
129
 
123
- function getFolderPath(node: Folder): string | null {
124
- const firstPage = findFirstPage(node);
125
- if (!firstPage) return null;
126
- const parts = firstPage.url.split('/').filter(Boolean);
127
- parts.pop();
128
- return '/' + parts.join('/');
129
- }
130
-
131
- function findFirstPage(node: Folder): { url: string } | null {
132
- for (const child of node.children) {
133
- if (child.type === 'page') return child;
134
- if (child.type === 'folder') {
135
- const found = findFirstPage(child);
136
- if (found) return found;
137
- }
138
- }
139
- return node.index ?? null;
140
- }
141
130
 
142
131
  function getOrder(node: Node, pageOrderMap: Map<string, number>, folderOrderMap: Map<string, number>): number | undefined {
143
- if (node.type === 'page') return pageOrderMap.get(node.url);
144
- if (node.type === 'folder') {
132
+ if (node.type === NodeType.Page) return pageOrderMap.get(node.url);
133
+ if (node.type === NodeType.Folder) {
145
134
  const folderPath = getFolderPath(node);
146
135
  if (folderPath) return folderOrderMap.get(folderPath);
147
136
  }
@@ -0,0 +1,113 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { Node } from 'fumadocs-core/page-tree'
3
+ import { getFirstPageUrl, findFolderFirstPage, resolveDocsRedirect } from './tree-utils'
4
+
5
+ function page(url: string, name = 'Page'): Node {
6
+ return { type: 'page', name, url } as Node
7
+ }
8
+
9
+ function folder(name: string, children: Node[], indexUrl?: string): Node {
10
+ return {
11
+ type: 'folder',
12
+ name,
13
+ children,
14
+ ...(indexUrl ? { index: { url: indexUrl } } : {}),
15
+ } as Node
16
+ }
17
+
18
+ describe('getFirstPageUrl', () => {
19
+ test('returns first page url', () => {
20
+ expect(getFirstPageUrl([page('/docs/intro')])).toBe('/docs/intro')
21
+ })
22
+
23
+ test('returns first page from nested folder', () => {
24
+ const nodes = [folder('Guides', [page('/docs/guides/install')])]
25
+ expect(getFirstPageUrl(nodes)).toBe('/docs/guides/install')
26
+ })
27
+
28
+ test('skips empty folders', () => {
29
+ const nodes = [folder('Empty', []), page('/docs/hello')]
30
+ expect(getFirstPageUrl(nodes)).toBe('/docs/hello')
31
+ })
32
+
33
+ test('returns null for empty list', () => {
34
+ expect(getFirstPageUrl([])).toBeNull()
35
+ })
36
+
37
+ test('returns null for folders with no pages', () => {
38
+ expect(getFirstPageUrl([folder('Empty', [])])).toBeNull()
39
+ })
40
+ })
41
+
42
+ describe('findFolderFirstPage', () => {
43
+ test('finds folder by index url', () => {
44
+ const nodes = [
45
+ folder('Guides', [page('/docs/guides/install'), page('/docs/guides/config')], '/docs/guides'),
46
+ ]
47
+ expect(findFolderFirstPage(nodes, '/docs/guides')).toBe('/docs/guides/install')
48
+ })
49
+
50
+ test('finds folder without index by child page path', () => {
51
+ const nodes = [
52
+ folder('Guides', [page('/docs/guides/install'), page('/docs/guides/config')]),
53
+ ]
54
+ expect(findFolderFirstPage(nodes, '/docs/guides')).toBe('/docs/guides/install')
55
+ })
56
+
57
+ test('finds nested folder', () => {
58
+ const nodes = [
59
+ folder('Docs', [
60
+ folder('Advanced', [page('/docs/advanced/perf'), page('/docs/advanced/debug')]),
61
+ ]),
62
+ ]
63
+ expect(findFolderFirstPage(nodes, '/docs/advanced')).toBe('/docs/advanced/perf')
64
+ })
65
+
66
+ test('returns null for non-matching path', () => {
67
+ const nodes = [folder('Guides', [page('/docs/guides/install')])]
68
+ expect(findFolderFirstPage(nodes, '/docs/api')).toBeNull()
69
+ })
70
+
71
+ test('returns null for empty folder', () => {
72
+ const nodes = [folder('Empty', [])]
73
+ expect(findFolderFirstPage(nodes, '/docs/empty')).toBeNull()
74
+ })
75
+ })
76
+
77
+ describe('resolveDocsRedirect', () => {
78
+ const tree = {
79
+ children: [
80
+ page('/docs/intro'),
81
+ folder('Guides', [page('/docs/guides/install')]),
82
+ ] as Node[],
83
+ }
84
+
85
+ test('redirects to index_page when set', () => {
86
+ expect(resolveDocsRedirect(['docs'], tree, { dir: 'docs', index_page: 'getting-started' }))
87
+ .toBe('/docs/getting-started')
88
+ })
89
+
90
+ test('redirects content root to first page', () => {
91
+ expect(resolveDocsRedirect(['docs'], tree, { dir: 'docs' }))
92
+ .toBe('/docs/intro')
93
+ })
94
+
95
+ test('redirects folder to first child', () => {
96
+ expect(resolveDocsRedirect(['docs', 'guides'], tree, { dir: 'docs' }))
97
+ .toBe('/docs/guides/install')
98
+ })
99
+
100
+ test('returns null for non-matching path', () => {
101
+ expect(resolveDocsRedirect(['docs', 'nonexistent'], tree, { dir: 'docs' }))
102
+ .toBeNull()
103
+ })
104
+
105
+ test('returns null without content config', () => {
106
+ expect(resolveDocsRedirect(['other'], tree)).toBeNull()
107
+ })
108
+
109
+ test('index_page takes priority over first page', () => {
110
+ expect(resolveDocsRedirect(['docs'], tree, { dir: 'docs', index_page: 'custom' }))
111
+ .toBe('/docs/custom')
112
+ })
113
+ })
@@ -0,0 +1,57 @@
1
+ import type { Node } from 'fumadocs-core/page-tree';
2
+
3
+ export const NodeType = {
4
+ Page: 'page',
5
+ Folder: 'folder',
6
+ Separator: 'separator',
7
+ } as const;
8
+
9
+ export function getFirstPageUrl(nodes: Node[]): string | null {
10
+ for (const node of nodes) {
11
+ if (node.type === NodeType.Page) return node.url;
12
+ if (node.type === NodeType.Folder) {
13
+ const url = getFirstPageUrl(node.children);
14
+ if (url) return url;
15
+ }
16
+ }
17
+ return null;
18
+ }
19
+
20
+ function getFolderPath(node: Node): string | null {
21
+ if (node.type !== NodeType.Folder) return null;
22
+ if (node.index) return node.index.url;
23
+ const firstPage = getFirstPageUrl(node.children);
24
+ if (!firstPage) return null;
25
+ const parts = firstPage.split('/').filter(Boolean);
26
+ parts.pop();
27
+ return '/' + parts.join('/');
28
+ }
29
+
30
+ export function findFolderFirstPage(nodes: Node[], pathname: string): string | null {
31
+ for (const node of nodes) {
32
+ if (node.type === NodeType.Folder) {
33
+ const folderPath = getFolderPath(node);
34
+ if (folderPath === pathname) return getFirstPageUrl(node.children);
35
+ const found = findFolderFirstPage(node.children, pathname);
36
+ if (found) return found;
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+
42
+ export function resolveDocsRedirect(
43
+ slug: string[],
44
+ tree: { children: Node[] },
45
+ contentConfig?: { dir: string; index_page?: string },
46
+ ): string | null {
47
+ const isContentRoot = slug.length === 1 && slug[0] === contentConfig?.dir;
48
+
49
+ if (isContentRoot) {
50
+ if (contentConfig?.index_page) {
51
+ return `/${contentConfig.dir}/${contentConfig.index_page}`;
52
+ }
53
+ return getFirstPageUrl(tree.children);
54
+ }
55
+
56
+ return findFolderFirstPage(tree.children, `/${slug.join('/')}`);
57
+ }
@@ -1,32 +1,10 @@
1
1
  import { Navigate } from 'react-router';
2
+ import { StatusCodes } from 'http-status-codes';
2
3
  import { Head } from '@/lib/head';
3
4
  import { usePageContext } from '@/lib/page-context';
5
+ import { resolveDocsRedirect } from '@/lib/tree-utils';
4
6
  import { NotFound } from '@/pages/NotFound';
5
7
  import { getTheme } from '@/themes/registry';
6
- import type { Node } from 'fumadocs-core/page-tree';
7
-
8
- function getFirstPageUrl(nodes: Node[]): string | null {
9
- for (const node of nodes) {
10
- if (node.type === 'page') return node.url;
11
- if (node.type === 'folder') {
12
- const url = getFirstPageUrl(node.children);
13
- if (url) return url;
14
- }
15
- }
16
- return null;
17
- }
18
-
19
- function findFolderFirstPage(nodes: Node[], pathname: string): string | null {
20
- for (const node of nodes) {
21
- if (node.type === 'folder') {
22
- const folderUrl = node.index?.url;
23
- if (folderUrl === pathname) return getFirstPageUrl(node.children);
24
- const found = findFolderFirstPage(node.children, pathname);
25
- if (found) return found;
26
- }
27
- }
28
- return null;
29
- }
30
8
 
31
9
  interface DocsPageProps {
32
10
  slug: string[];
@@ -35,19 +13,10 @@ interface DocsPageProps {
35
13
  export function DocsPage({ slug }: DocsPageProps) {
36
14
  const { config, tree, page, isLoading, errorStatus } = usePageContext();
37
15
 
38
- if (errorStatus === 404) {
39
- const pathname = `/${slug.join('/')}`;
16
+ if (errorStatus === StatusCodes.NOT_FOUND) {
40
17
  const contentConfig = config.content?.find(c => c.dir === slug[0]);
41
- const isContentRoot = slug.length === 1 && slug[0] === contentConfig?.dir;
42
- if (contentConfig?.index_page) {
43
- return <Navigate to={`/${contentConfig.dir}/${contentConfig.index_page}`} replace />;
44
- }
45
- if (isContentRoot) {
46
- const firstUrl = getFirstPageUrl(tree.children);
47
- if (firstUrl) return <Navigate to={firstUrl} replace />;
48
- }
49
- const folderFirstUrl = findFolderFirstPage(tree.children, pathname);
50
- if (folderFirstUrl) return <Navigate to={folderFirstUrl} replace />;
18
+ const redirectUrl = resolveDocsRedirect(slug, tree, contentConfig);
19
+ if (redirectUrl) return <Navigate to={redirectUrl} replace />;
51
20
  return <NotFound />;
52
21
  }
53
22
  if (errorStatus) return <NotFound />;
@@ -10,6 +10,9 @@ import { loadApiSpecs } from '@/lib/openapi';
10
10
  import { PageProvider } from '@/lib/page-context';
11
11
  import { resolveRoute, RouteType } from '@/lib/route-resolver';
12
12
  import { getPageTree, getPage, getPageNav, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
13
+ import { getFirstApiUrl } from '@/lib/api-routes';
14
+ import { StatusCodes } from 'http-status-codes';
15
+ import { resolveDocsRedirect } from '@/lib/tree-utils';
13
16
  import { useNitroApp } from 'nitro/app';
14
17
  import { App } from './App';
15
18
 
@@ -45,6 +48,30 @@ export default {
45
48
  getPageTree(),
46
49
  route.type === RouteType.DocsPage ? getPage(route.slug) : Promise.resolve(null),
47
50
  ]);
51
+ // SSR redirects for index pages
52
+ if (route.type === RouteType.ApiIndex) {
53
+ const firstUrl = getFirstApiUrl(apiSpecs);
54
+ if (firstUrl) {
55
+ return new Response(null, { status: StatusCodes.TEMPORARY_REDIRECT, headers: { Location: firstUrl } });
56
+ }
57
+ }
58
+
59
+ if (route.type === RouteType.DocsPage && !page) {
60
+ const versionPrefix = route.version.urlPrefix;
61
+ const slugWithoutVersion = versionPrefix && route.slug[0] === route.version.dir
62
+ ? route.slug.slice(1)
63
+ : route.slug;
64
+ const contentEntries = route.version.dir
65
+ ? config.versions?.find(v => v.dir === route.version.dir)?.content ?? config.content
66
+ : config.content;
67
+ const contentConfig = contentEntries?.find((c: { dir: string }) => c.dir === slugWithoutVersion[0]);
68
+ const redirectUrl = resolveDocsRedirect(slugWithoutVersion, tree, contentConfig);
69
+ if (redirectUrl) {
70
+ const fullUrl = versionPrefix ? `${versionPrefix}${redirectUrl}` : redirectUrl;
71
+ return new Response(null, { status: StatusCodes.TEMPORARY_REDIRECT, headers: { Location: fullUrl } });
72
+ }
73
+ }
74
+
48
75
  const nav = page ? await getPageNav(pageSlug) : { prev: null, next: null };
49
76
 
50
77
  const relativePath = page ? getRelativePath(page) : null;
@@ -88,6 +115,8 @@ export default {
88
115
  <head>
89
116
  <meta charSet="UTF-8" />
90
117
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
118
+ <link rel="icon" href="/favicon.ico" />
119
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
91
120
  {assets.css.map((attr: { href: string }) => (
92
121
  <link key={attr.href} rel="stylesheet" {...attr} />
93
122
  ))}
@@ -120,7 +149,7 @@ export default {
120
149
 
121
150
  const renderDuration = performance.now() - renderStart;
122
151
 
123
- const status = route.type === RouteType.DocsPage && !page ? 404 : 200;
152
+ const status = route.type === RouteType.DocsPage && !page ? StatusCodes.NOT_FOUND : StatusCodes.OK;
124
153
 
125
154
  // biome-ignore lint/correctness/useHookAtTopLevel: useNitroApp is a Nitro DI accessor, not a React hook
126
155
  useNitroApp().hooks.callHook('chronicle:ssr-rendered', pathname, status, renderDuration);
@@ -3,11 +3,15 @@ import matter from 'gray-matter';
3
3
  import { defineHandler, HTTPError } from 'nitro';
4
4
  import { getPage, getOriginalPath } from '@/lib/source';
5
5
  import { safePath } from '@/server/utils/safe-path';
6
+ import { handleApiMarkdown } from '@/server/utils/api-markdown';
6
7
 
7
8
  export default defineHandler(async event => {
8
9
  const pathname = event.path || event.req.url?.split('?')[0] || '';
9
10
  if (!pathname.endsWith('.md')) return;
10
- if (pathname.startsWith('/apis/')) return;
11
+
12
+ if (pathname.startsWith('/apis/')) {
13
+ return handleApiMarkdown(pathname);
14
+ }
11
15
 
12
16
  const stripped = pathname.replace(/\.md$/, '');
13
17
  const parts = stripped === '/index' || stripped === '/'
@@ -1,15 +1,12 @@
1
1
  import type { OpenAPIV3 } from 'openapi-types'
2
- import { defineHandler, HTTPError } from 'nitro'
2
+ import { HTTPError } from 'nitro'
3
3
  import { loadConfig } from '@/lib/config'
4
4
  import { loadApiSpecs } from '@/lib/openapi'
5
5
  import { findApiOperation } from '@/lib/api-routes'
6
6
  import { flattenSchema, generateExampleJson, type SchemaField } from '@/lib/schema'
7
7
  import { generateCurl } from '@/lib/snippet-generators'
8
8
 
9
- export default defineHandler(async event => {
10
- const pathname = event.path || event.req.url?.split('?')[0] || ''
11
- if (!pathname.endsWith('.md')) return
12
-
9
+ export async function handleApiMarkdown(pathname: string) {
13
10
  const stripped = pathname.replace(/\.md$/, '').replace(/^\/apis\//, '')
14
11
  const slug = stripped.split('/').filter(Boolean)
15
12
  if (slug.length < 2) {
@@ -26,7 +23,7 @@ export default defineHandler(async event => {
26
23
 
27
24
  const md = generateApiMarkdown(match.method, match.path, match.operation, match.spec.server.url, match.spec.auth)
28
25
  return new Response(md, { headers: { 'Content-Type': 'text/markdown; charset=utf-8' } })
29
- })
26
+ }
30
27
 
31
28
  function generateApiMarkdown(
32
29
  method: string,
@@ -12,6 +12,19 @@ import remarkResolveLinks from '../lib/remark-resolve-links';
12
12
  import remarkReadingTime from 'remark-reading-time';
13
13
  import remarkUnusedDirectives from '../lib/remark-unused-directives';
14
14
 
15
+ function getDatabaseConnector(preset?: string): { connector: string; options?: Record<string, unknown> } {
16
+ switch (preset) {
17
+ case 'bun':
18
+ return { connector: 'bun-sqlite', options: { name: 'chronicle-search' } };
19
+ case 'cloudflare':
20
+ case 'cloudflare-pages':
21
+ case 'cloudflare-module':
22
+ return { connector: 'cloudflare-d1', options: { bindingName: 'CHRONICLE_DB' } };
23
+ default:
24
+ return { connector: 'sqlite', options: { name: 'chronicle-search' } };
25
+ }
26
+ }
27
+
15
28
  function resolveOutputDir(projectRoot: string, preset?: string): string {
16
29
  if (preset === 'vercel' || preset === 'vercel-static') return path.resolve(projectRoot, '.vercel/output');
17
30
  return path.resolve(projectRoot, '.output');
@@ -140,10 +153,7 @@ export async function createViteConfig(
140
153
  database: true,
141
154
  },
142
155
  database: {
143
- default: {
144
- connector: 'sqlite',
145
- options: { name: 'chronicle-search' },
146
- },
156
+ default: getDatabaseConnector(preset),
147
157
  },
148
158
  },
149
159
  };
@@ -19,7 +19,7 @@ export function ContentDirButtons() {
19
19
  const { visible, overflow } = splitContentButtons(entries, MAX_VISIBLE);
20
20
 
21
21
  return (
22
- <Flex gap='small' align='center'>
22
+ <Flex gap={3} align='center'>
23
23
  {visible.map(entry => (
24
24
  <RouterLink
25
25
  key={entry.href}
@@ -22,6 +22,7 @@ import { getLandingEntries } from '@/lib/config';
22
22
  import { getActiveContentDir } from '@/lib/navigation';
23
23
  import { usePageContext } from '@/lib/page-context';
24
24
  import type { Node, Root } from 'fumadocs-core/page-tree';
25
+ import { NodeType } from '@/lib/tree-utils';
25
26
  import type { ThemeLayoutProps } from '@/types';
26
27
  import styles from './Layout.module.css';
27
28
  import { OpenInAI } from './OpenInAI';
@@ -117,7 +118,7 @@ export function Layout({
117
118
  >
118
119
  <Sidebar.Header className={styles.sidebarHeader}>
119
120
  <SidebarLogo config={config} />
120
- <Flex gap='small' align='center'>
121
+ <Flex gap={3} align='center'>
121
122
  {config.search?.enabled && <Search />}
122
123
  <ClientThemeSwitcher size={16} />
123
124
  </Flex>
@@ -186,8 +187,8 @@ export function Layout({
186
187
  <div className={styles.cardWrapper}>
187
188
  <div className={styles.card}>
188
189
  <nav className={styles.subNav}>
189
- <Flex align='center' gap='small' className={styles.subNavLeft}>
190
- <Flex align='center' gap='extra-small'>
190
+ <Flex align='center' gap={3} className={styles.subNavLeft}>
191
+ <Flex align='center' gap={2}>
191
192
  <IconButton
192
193
  size={2}
193
194
  disabled={!prev}
@@ -207,7 +208,7 @@ export function Layout({
207
208
  </Flex>
208
209
  <Breadcrumbs slug={slug} tree={tree} />
209
210
  </Flex>
210
- <Flex align='center' gap='small'>
211
+ <Flex align='center' gap={3}>
211
212
  {isApiRoute && <TestRequestButton />}
212
213
  {isApiRoute && <ViewDocsButton />}
213
214
  <OpenInAI />
@@ -224,6 +225,15 @@ export function Layout({
224
225
  );
225
226
  }
226
227
 
228
+ function hasActiveDescendant(node: Node, pathname: string): boolean {
229
+ if (node.type === NodeType.Page) return pathname === node.url;
230
+ if (node.type === NodeType.Folder) {
231
+ if (node.index && pathname === node.index.url) return true;
232
+ return node.children.some(child => hasActiveDescendant(child, pathname));
233
+ }
234
+ return false;
235
+ }
236
+
227
237
  function SidebarNode({
228
238
  item,
229
239
  pathname,
@@ -240,6 +250,7 @@ function SidebarNode({
240
250
  if (item.type === 'folder') {
241
251
  if (depth > 1) return null;
242
252
  const icon = typeof item.icon === 'string' ? iconMap[item.icon] : item.icon;
253
+ const hasActiveChild = hasActiveDescendant(item, pathname);
243
254
  return (
244
255
  <Sidebar.Group
245
256
  className={styles.navGroup}
@@ -247,6 +258,7 @@ function SidebarNode({
247
258
  label={item.name?.toString() ?? ''}
248
259
  leadingIcon={icon ?? undefined}
249
260
  collapsible={depth === 1}
261
+ defaultOpen={hasActiveChild}
250
262
  classNames={{
251
263
  items: styles.groupItems,
252
264
  header: styles.navGroupHeader,
@@ -305,7 +317,7 @@ function ApiSidebarNode({ item, pathname }: { item: Node; pathname: string }) {
305
317
 
306
318
  if (item.type === 'folder') {
307
319
  return (
308
- <Flex direction='column' gap='small' className={styles.apiGroup}>
320
+ <Flex direction='column' gap={3} className={styles.apiGroup}>
309
321
  <span className={styles.apiGroupLabel}>{item.name?.toString()}</span>
310
322
  <Flex direction='column'>
311
323
  {item.children.map((child, i) => (
@@ -329,7 +341,7 @@ function ApiSidebarNode({ item, pathname }: { item: Node; pathname: string }) {
329
341
  return (
330
342
  <Flex
331
343
  align='center'
332
- gap='small'
344
+ gap={3}
333
345
  className={`${styles.apiItem} ${isActive ? styles.apiItemActive : ''}`}
334
346
  render={<RouterLink to={href} />}
335
347
  >
@@ -125,3 +125,12 @@
125
125
  .content details > :not(summary) {
126
126
  padding: var(--rs-space-4) var(--rs-space-5);
127
127
  }
128
+
129
+ .loader {
130
+ flex: 1;
131
+ margin-bottom: var(--rs-space-3);
132
+ }
133
+
134
+ .headerLoader {
135
+ margin-bottom: var(--rs-space-5);
136
+ }
@@ -6,21 +6,11 @@ export function PageSkeleton() {
6
6
  return (
7
7
  <Flex className={styles.page}>
8
8
  <article className={styles.article}>
9
- <Skeleton width="40%" height="32px" />
10
- <Skeleton.Provider duration={2}>
11
- <Skeleton width="100%" height="16px" />
12
- <Skeleton width="95%" height="16px" />
13
- <Skeleton width="80%" height="16px" />
14
- <Skeleton width="100%" height="16px" />
15
- <Skeleton width="60%" height="16px" />
16
- </Skeleton.Provider>
17
- <Skeleton width="30%" height="24px" />
18
- <Skeleton.Provider duration={2}>
19
- <Skeleton width="100%" height="16px" />
20
- <Skeleton width="90%" height="16px" />
21
- <Skeleton width="100%" height="16px" />
22
- <Skeleton width="70%" height="16px" />
23
- </Skeleton.Provider>
9
+ <Skeleton width="40%" height="var(--rs-line-height-t2)" containerClassName={styles.headerLoader} />
10
+ <Skeleton width="60%" height="var(--rs-line-height-regular)" containerClassName={styles.headerLoader} />
11
+ {[...new Array(20)].map((_, i) => (
12
+ <Skeleton key={i} width="100%" height="var(--rs-line-height-regular)" containerClassName={styles.loader} />
13
+ ))}
24
14
  </article>
25
15
  </Flex>
26
16
  );
@@ -28,7 +28,7 @@ export function VersionSwitcher() {
28
28
  />
29
29
  }
30
30
  >
31
- <Flex gap='small' align='center'>
31
+ <Flex gap={3} align='center'>
32
32
  {active?.label ?? 'Version'}
33
33
  {active?.badge ? (
34
34
  <Badge variant={active.badge.variant} size='micro'>
@@ -43,7 +43,7 @@ export function VersionSwitcher() {
43
43
  key={v.dir ?? '_latest'}
44
44
  onClick={() => navigate(getVersionHomeHref(config, v.dir))}
45
45
  >
46
- <Flex gap='small' align='center'>
46
+ <Flex gap={3} align='center'>
47
47
  {v.label}
48
48
  {v.badge ? (
49
49
  <Badge variant={v.badge.variant} size='micro'>
@@ -29,7 +29,7 @@ export function VersionSwitcher() {
29
29
  />
30
30
  }
31
31
  >
32
- <Flex gap='small' align='center' justify='start'>
32
+ <Flex gap={3} align='center' justify='start'>
33
33
  {active?.label ?? 'Version'}
34
34
  {active?.badge ? (
35
35
  <Badge variant={active.badge.variant} size='micro'>
@@ -44,7 +44,7 @@ export function VersionSwitcher() {
44
44
  key={v.dir ?? '_latest'}
45
45
  onClick={() => navigate(getVersionHomeHref(config, v.dir))}
46
46
  >
47
- <Flex gap='small' align='center'>
47
+ <Flex gap={3} align='center'>
48
48
  {v.label}
49
49
  {v.badge ? (
50
50
  <Badge variant={v.badge.variant} size='micro'>
@@ -131,6 +131,12 @@ const RESERVED_ROUTE_SEGMENTS = [
131
131
  'sitemap.xml',
132
132
  ] as const
133
133
 
134
+ const redirectSchema = z.object({
135
+ from: z.string(),
136
+ to: z.string(),
137
+ permanent: z.boolean().optional(),
138
+ })
139
+
134
140
  export const chronicleConfigSchema = z
135
141
  .object({
136
142
  site: siteSchema,
@@ -144,6 +150,7 @@ export const chronicleConfigSchema = z
144
150
  navigation: navigationSchema.optional(),
145
151
  search: searchSchema.optional(),
146
152
  api: z.array(apiSchema).optional(),
153
+ redirects: z.array(redirectSchema).optional(),
147
154
  analytics: analyticsSchema.optional(),
148
155
  telemetry: telemetrySchema.optional(),
149
156
  })
@@ -225,6 +232,7 @@ export type SocialLink = z.infer<typeof socialLinkSchema>
225
232
  export type SearchConfig = z.infer<typeof searchSchema>
226
233
  export type ApiConfig = z.infer<typeof apiSchema>
227
234
  export type ApiServerConfig = z.infer<typeof apiServerSchema>
235
+ export type RedirectConfig = z.infer<typeof redirectSchema>
228
236
  export type ApiAuthConfig = z.infer<typeof apiAuthSchema>
229
237
  export type AnalyticsConfig = z.infer<typeof analyticsSchema>
230
238
  export type GoogleAnalyticsConfig = z.infer<typeof googleAnalyticsSchema>
@@ -1,3 +0,0 @@
1
- 'use client'
2
-
3
- export { Breadcrumb } from '@raystack/apsara'
@@ -1,3 +0,0 @@
1
- 'use client'
2
-
3
- export { Button } from '@raystack/apsara'
@@ -1,3 +0,0 @@
1
- 'use client'
2
-
3
- export { CodeBlock } from '@raystack/apsara'
@@ -1,3 +0,0 @@
1
- 'use client'
2
-
3
- export { Dialog } from '@raystack/apsara'
@@ -1,10 +0,0 @@
1
- export { Sidebar } from './sidebar'
2
- export { Table } from './table'
3
- export { Dialog } from './dialog'
4
- export { InputField } from './input-field'
5
- export { Tabs } from './tabs'
6
- export { Breadcrumb } from './breadcrumb'
7
- export { Button } from './button'
8
- export { CodeBlock } from './code-block'
9
- export { Callout } from './callout'
10
- export { Switch } from './switch'
@@ -1,3 +0,0 @@
1
- 'use client'
2
-
3
- export { InputField } from '@raystack/apsara'
@@ -1,3 +0,0 @@
1
- 'use client'
2
-
3
- export { Sidebar } from '@raystack/apsara'
@@ -1,3 +0,0 @@
1
- 'use client'
2
-
3
- export { Switch } from '@raystack/apsara'
@@ -1,3 +0,0 @@
1
- 'use client'
2
-
3
- export { Table } from '@raystack/apsara'
@@ -1,3 +0,0 @@
1
- 'use client'
2
-
3
- export { Tabs } from '@raystack/apsara'