@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
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { chronicleConfigSchema } from '@/types'
3
+ import { buildLlmsTxt } from './llms'
4
+ import { LATEST_CONTEXT } from './version-source'
5
+
6
+ describe('buildLlmsTxt', () => {
7
+ test('uses site.title and appends latest label when set', () => {
8
+ const config = chronicleConfigSchema.parse({
9
+ site: { title: 'My Docs' },
10
+ content: [{ dir: 'docs', label: 'Docs' }],
11
+ latest: { label: '3.0' },
12
+ versions: [
13
+ {
14
+ dir: 'v1',
15
+ label: '1.0',
16
+ content: [{ dir: 'docs', label: 'Docs' }],
17
+ },
18
+ ],
19
+ })
20
+
21
+ const out = buildLlmsTxt(
22
+ config,
23
+ [
24
+ { url: '/docs/a', title: 'A' },
25
+ { url: '/', title: 'Home' },
26
+ ],
27
+ LATEST_CONTEXT,
28
+ )
29
+
30
+ expect(out).toContain('# My Docs — 3.0')
31
+ expect(out).toContain('- [A](/docs/a.md)')
32
+ expect(out).toContain('- [Home](/index.md)')
33
+ })
34
+
35
+ test('heading has no version label when latest is absent and ctx is latest', () => {
36
+ const config = chronicleConfigSchema.parse({
37
+ site: { title: 'My Docs' },
38
+ content: [{ dir: 'docs', label: 'Docs' }],
39
+ })
40
+
41
+ const out = buildLlmsTxt(config, [], LATEST_CONTEXT)
42
+ expect(out.startsWith('# My Docs\n')).toBe(true)
43
+ })
44
+
45
+ test('falls back to page url when title is missing or empty', () => {
46
+ const config = chronicleConfigSchema.parse({
47
+ site: { title: 'Docs' },
48
+ content: [{ dir: 'docs', label: 'Docs' }],
49
+ })
50
+ const out = buildLlmsTxt(
51
+ config,
52
+ [
53
+ { url: '/docs/untitled' },
54
+ { url: '/docs/blank', title: ' ' },
55
+ ],
56
+ LATEST_CONTEXT,
57
+ )
58
+ expect(out).toContain('- [/docs/untitled](/docs/untitled.md)')
59
+ expect(out).toContain('- [/docs/blank](/docs/blank.md)')
60
+ })
61
+
62
+ test('omits the description line when description is empty', () => {
63
+ const config = chronicleConfigSchema.parse({
64
+ site: { title: 'Docs' },
65
+ content: [{ dir: 'docs', label: 'Docs' }],
66
+ })
67
+ const out = buildLlmsTxt(config, [{ url: '/a', title: 'A' }], LATEST_CONTEXT)
68
+ // heading immediately followed by a single blank line then the index
69
+ expect(out).toBe('# Docs\n\n- [A](/a.md)')
70
+ })
71
+
72
+ test('uses the version label for a versioned ctx', () => {
73
+ const config = chronicleConfigSchema.parse({
74
+ site: { title: 'My Docs' },
75
+ content: [{ dir: 'docs', label: 'Docs' }],
76
+ latest: { label: '3.0' },
77
+ versions: [
78
+ {
79
+ dir: 'v1',
80
+ label: '1.0',
81
+ content: [{ dir: 'docs', label: 'Docs' }],
82
+ },
83
+ ],
84
+ })
85
+
86
+ const out = buildLlmsTxt(
87
+ config,
88
+ [{ url: '/v1/docs/a', title: 'A' }],
89
+ { dir: 'v1', urlPrefix: '/v1' },
90
+ )
91
+ expect(out).toContain('# My Docs — 1.0')
92
+ expect(out).toContain('- [A](/v1/docs/a.md)')
93
+ })
94
+ })
@@ -0,0 +1,41 @@
1
+ import type { ChronicleConfig } from '@/types'
2
+ import type { VersionContext } from './version-source'
3
+
4
+ export interface LlmsPage {
5
+ url: string
6
+ title?: string
7
+ }
8
+
9
+ export function buildLlmsTxt(
10
+ config: ChronicleConfig,
11
+ pages: LlmsPage[],
12
+ version: VersionContext,
13
+ ): string {
14
+ const versionLabel = getVersionLabel(config, version)
15
+ const heading = versionLabel
16
+ ? `# ${config.site.title} — ${versionLabel}`
17
+ : `# ${config.site.title}`
18
+
19
+ const description = config.site.description ?? ''
20
+
21
+ const index = pages
22
+ .map((p) => {
23
+ const mdUrl = p.url === '/' ? '/index.md' : `${p.url}.md`
24
+ const title = p.title?.trim() || p.url
25
+ return `- [${title}](${mdUrl})`
26
+ })
27
+ .join('\n')
28
+
29
+ const parts = [heading]
30
+ if (description) parts.push(description)
31
+ parts.push(index)
32
+ return parts.join('\n\n')
33
+ }
34
+
35
+ function getVersionLabel(
36
+ config: ChronicleConfig,
37
+ version: VersionContext,
38
+ ): string | null {
39
+ if (version.dir === null) return config.latest?.label ?? null
40
+ return config.versions?.find((v) => v.dir === version.dir)?.label ?? null
41
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { type ChronicleConfig, chronicleConfigSchema } from '@/types'
3
+ import {
4
+ getActiveContentDir,
5
+ getVersionHomeHref,
6
+ splitContentButtons,
7
+ } from './navigation'
8
+
9
+ function versioned(): ChronicleConfig {
10
+ return chronicleConfigSchema.parse({
11
+ site: { title: 'x' },
12
+ content: [
13
+ { dir: 'docs', label: 'Docs' },
14
+ { dir: 'dev', label: 'Dev' },
15
+ ],
16
+ latest: { label: '3.0' },
17
+ versions: [
18
+ {
19
+ dir: 'v1',
20
+ label: '1.0',
21
+ content: [
22
+ { dir: 'docs', label: 'Docs' },
23
+ { dir: 'dev', label: 'Dev' },
24
+ ],
25
+ },
26
+ ],
27
+ })
28
+ }
29
+
30
+ describe('getActiveContentDir', () => {
31
+ test('returns latest content dir from URL', () => {
32
+ expect(getActiveContentDir('/docs/intro', versioned())).toBe('docs')
33
+ expect(getActiveContentDir('/dev/setup', versioned())).toBe('dev')
34
+ })
35
+
36
+ test('returns versioned content dir from URL', () => {
37
+ expect(getActiveContentDir('/v1/docs/intro', versioned())).toBe('docs')
38
+ expect(getActiveContentDir('/v1/dev/setup', versioned())).toBe('dev')
39
+ })
40
+
41
+ test('returns null for root and api routes', () => {
42
+ expect(getActiveContentDir('/', versioned())).toBeNull()
43
+ expect(getActiveContentDir('/v1', versioned())).toBeNull()
44
+ expect(getActiveContentDir('/apis/x', versioned())).toBeNull()
45
+ expect(getActiveContentDir('/v1/apis/x', versioned())).toBeNull()
46
+ })
47
+
48
+ test('returns null for unknown content dir', () => {
49
+ expect(getActiveContentDir('/random', versioned())).toBeNull()
50
+ expect(getActiveContentDir('/v1/random', versioned())).toBeNull()
51
+ })
52
+ })
53
+
54
+ describe('getVersionHomeHref', () => {
55
+ test('single content dir returns /<dir>', () => {
56
+ const cfg = chronicleConfigSchema.parse({
57
+ site: { title: 'x' },
58
+ content: [{ dir: 'docs', label: 'Docs' }],
59
+ })
60
+ expect(getVersionHomeHref(cfg, null)).toBe('/docs')
61
+ })
62
+
63
+ test('multi content dir returns / (version root for landing)', () => {
64
+ expect(getVersionHomeHref(versioned(), null)).toBe('/')
65
+ })
66
+
67
+ test('versioned multi content returns /<v>', () => {
68
+ expect(getVersionHomeHref(versioned(), 'v1')).toBe('/v1')
69
+ })
70
+
71
+ test('unknown version returns /<v> fallback', () => {
72
+ expect(getVersionHomeHref(versioned(), 'v9')).toBe('/v9')
73
+ })
74
+ })
75
+
76
+ describe('splitContentButtons', () => {
77
+ test('all visible when length <= max', () => {
78
+ expect(splitContentButtons([1, 2, 3], 3)).toEqual({
79
+ visible: [1, 2, 3],
80
+ overflow: [],
81
+ })
82
+ })
83
+
84
+ test('first max visible, rest overflow', () => {
85
+ expect(splitContentButtons([1, 2, 3, 4, 5], 3)).toEqual({
86
+ visible: [1, 2, 3],
87
+ overflow: [4, 5],
88
+ })
89
+ })
90
+
91
+ test('empty input returns empty arrays', () => {
92
+ expect(splitContentButtons([], 3)).toEqual({ visible: [], overflow: [] })
93
+ })
94
+ })
@@ -0,0 +1,51 @@
1
+ import type { ChronicleConfig } from '@/types'
2
+ import {
3
+ getLandingEntries,
4
+ getLatestContentRoots,
5
+ getVersionContentRoots,
6
+ } from './config'
7
+ import { resolveVersionFromUrl } from './version-source'
8
+
9
+ export function getActiveContentDir(
10
+ url: string,
11
+ config: ChronicleConfig,
12
+ ): string | null {
13
+ const version = resolveVersionFromUrl(url, config)
14
+ const parts = url.split('/').filter(Boolean)
15
+ const remainder =
16
+ version.dir !== null && parts[0] === version.dir ? parts.slice(1) : parts
17
+
18
+ if (remainder.length === 0) return null
19
+ if (remainder[0] === 'apis') return null
20
+
21
+ const dirs =
22
+ version.dir === null
23
+ ? getLatestContentRoots(config).map((root) => root.contentDir)
24
+ : getVersionContentRoots(config, version.dir).map(
25
+ (root) => root.contentDir,
26
+ )
27
+
28
+ return dirs.includes(remainder[0]) ? remainder[0] : null
29
+ }
30
+
31
+ export function getVersionHomeHref(
32
+ config: ChronicleConfig,
33
+ versionDir: string | null,
34
+ ): string {
35
+ const entries = getLandingEntries(config, versionDir)
36
+ if (entries.length === 1) return entries[0].href
37
+ return versionDir ? `/${versionDir}` : '/'
38
+ }
39
+
40
+ export interface ContentButtonSplit<T> {
41
+ visible: T[]
42
+ overflow: T[]
43
+ }
44
+
45
+ export function splitContentButtons<T>(
46
+ items: T[],
47
+ max: number,
48
+ ): ContentButtonSplit<T> {
49
+ if (items.length <= max) return { visible: items, overflow: [] }
50
+ return { visible: items.slice(0, max), overflow: items.slice(max) }
51
+ }
@@ -7,23 +7,20 @@ import {
7
7
  } from 'react';
8
8
  import { useLocation } from 'react-router';
9
9
  import type { ApiSpec } from '@/lib/openapi';
10
- import type { ChronicleConfig, Frontmatter, Root, TableOfContents } from '@/types';
10
+ import { resolveRoute, RouteType } from '@/lib/route-resolver';
11
+ import type { VersionContext } from '@/lib/version-source';
12
+ import { LATEST_CONTEXT } from '@/lib/version-source';
13
+ import type { ChronicleConfig, Frontmatter, Page, Root, TableOfContents } from '@/types';
11
14
 
12
15
  export type MdxLoader = (relativePath: string) => Promise<{ content: ReactNode; toc: TableOfContents }>;
13
16
 
14
- interface PageData {
15
- slug: string[];
16
- frontmatter: Frontmatter;
17
- content: ReactNode;
18
- toc: TableOfContents;
19
- }
20
-
21
17
  interface PageContextValue {
22
18
  config: ChronicleConfig;
23
19
  tree: Root;
24
- page: PageData | null;
20
+ page: Page | null;
25
21
  errorStatus: number | null;
26
22
  apiSpecs: ApiSpec[];
23
+ version: VersionContext;
27
24
  }
28
25
 
29
26
  const PageContext = createContext<PageContextValue | null>(null);
@@ -33,11 +30,15 @@ export function usePageContext(): PageContextValue {
33
30
  if (!ctx) {
34
31
  console.error('usePageContext: no context found!');
35
32
  return {
36
- config: { title: 'Documentation' },
33
+ config: {
34
+ site: { title: 'Documentation' },
35
+ content: [{ dir: 'docs', label: 'Docs' }],
36
+ },
37
37
  tree: { name: 'root', children: [] } as Root,
38
38
  page: null,
39
39
  errorStatus: null,
40
- apiSpecs: []
40
+ apiSpecs: [],
41
+ version: LATEST_CONTEXT,
41
42
  };
42
43
  }
43
44
  return ctx;
@@ -46,8 +47,9 @@ export function usePageContext(): PageContextValue {
46
47
  interface PageProviderProps {
47
48
  initialConfig: ChronicleConfig;
48
49
  initialTree: Root;
49
- initialPage: PageData | null;
50
+ initialPage: Page | null;
50
51
  initialApiSpecs: ApiSpec[];
52
+ initialVersion: VersionContext;
51
53
  loadMdx: MdxLoader;
52
54
  children: ReactNode;
53
55
  }
@@ -56,9 +58,12 @@ function isApisRoute(pathname: string): boolean {
56
58
  return pathname === '/apis' || pathname.startsWith('/apis/');
57
59
  }
58
60
 
59
- function getInitialErrorStatus(page: PageData | null, pathname: string): number | null {
61
+ function getInitialErrorStatus(page: Page | null, config: ChronicleConfig, pathname: string): number | null {
60
62
  if (page) return null;
61
- if (pathname === '/' || isApisRoute(pathname)) return null;
63
+ const route = resolveRoute(pathname, config);
64
+ if (route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage) return null;
65
+ if (route.type === RouteType.Redirect) return null;
66
+ if (route.type === RouteType.DocsIndex) return null;
62
67
  return 404;
63
68
  }
64
69
 
@@ -67,39 +72,53 @@ export function PageProvider({
67
72
  initialTree,
68
73
  initialPage,
69
74
  initialApiSpecs,
75
+ initialVersion,
70
76
  loadMdx,
71
77
  children
72
78
  }: PageProviderProps) {
73
79
  const { pathname } = useLocation();
74
80
  const [tree] = useState<Root>(initialTree);
75
- const [page, setPage] = useState<PageData | null>(initialPage);
76
- const [errorStatus, setErrorStatus] = useState<number | null>(getInitialErrorStatus(initialPage, pathname));
81
+ const [page, setPage] = useState<Page | null>(initialPage);
82
+ const [errorStatus, setErrorStatus] = useState<number | null>(getInitialErrorStatus(initialPage, initialConfig, pathname));
77
83
  const [apiSpecs, setApiSpecs] = useState<ApiSpec[]>(initialApiSpecs);
84
+ const [version, setVersion] = useState<VersionContext>(initialVersion);
78
85
  const [currentPath, setCurrentPath] = useState(pathname);
79
86
 
80
87
  useEffect(() => {
81
88
  if (pathname === currentPath) return;
82
89
  setCurrentPath(pathname);
83
90
 
91
+ const route = resolveRoute(pathname, initialConfig);
92
+ if (route.type !== RouteType.Redirect) setVersion(route.version);
93
+
84
94
  const cancelled = { current: false };
85
95
 
86
- if (isApisRoute(pathname)) {
87
- if (apiSpecs.length === 0) {
88
- fetch('/api/specs')
89
- .then(res => res.json())
90
- .then(specs => {
91
- if (!cancelled.current) setApiSpecs(specs);
92
- })
93
- .catch(() => {});
94
- }
96
+ if (route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage) {
97
+ setPage(null);
98
+ setErrorStatus(null);
99
+ const specsUrl = route.version.dir
100
+ ? `/api/specs?version=${encodeURIComponent(route.version.dir)}`
101
+ : '/api/specs';
102
+ fetch(specsUrl)
103
+ .then(res => res.json())
104
+ .then(specs => {
105
+ if (!cancelled.current) setApiSpecs(specs);
106
+ })
107
+ .catch(() => {
108
+ // swallow — api specs are best-effort on client nav
109
+ });
95
110
  return () => { cancelled.current = true; };
96
111
  }
97
112
 
98
- const slug = pathname === '/'
99
- ? []
100
- : pathname.slice(1).split('/').filter(Boolean);
113
+ if (route.type !== RouteType.DocsPage) {
114
+ setPage(null);
115
+ setErrorStatus(null);
116
+ return () => { cancelled.current = true; };
117
+ }
101
118
 
102
- const apiPath = slug.length === 0 ? '/api/page' : `/api/page?slug=${slug.join(',')}`;
119
+ const apiPath = route.slug.length === 0
120
+ ? '/api/page'
121
+ : `/api/page?slug=${route.slug.join(',')}`;
103
122
 
104
123
  fetch(apiPath)
105
124
  .then(res => {
@@ -112,12 +131,12 @@ export function PageProvider({
112
131
  }
113
132
  return res.json();
114
133
  })
115
- .then(async (data: { frontmatter: Frontmatter; relativePath: string; originalPath?: string } | undefined) => {
134
+ .then(async (data: { frontmatter: Frontmatter; relativePath: string; originalPath?: string; prev?: Page['prev']; next?: Page['next'] } | undefined) => {
116
135
  if (cancelled.current || !data) return;
117
136
  const { content, toc } = await loadMdx(data.originalPath || data.relativePath);
118
137
  if (cancelled.current) return;
119
138
  setErrorStatus(null);
120
- setPage({ slug, frontmatter: data.frontmatter, content, toc });
139
+ setPage({ slug: route.slug, frontmatter: data.frontmatter, content, toc, prev: data.prev, next: data.next });
121
140
  })
122
141
  .catch(() => {
123
142
  if (!cancelled.current) {
@@ -131,7 +150,7 @@ export function PageProvider({
131
150
 
132
151
  return (
133
152
  <PageContext.Provider
134
- value={{ config: initialConfig, tree, page, errorStatus, apiSpecs }}
153
+ value={{ config: initialConfig, tree, page, errorStatus, apiSpecs, version }}
135
154
  >
136
155
  {children}
137
156
  </PageContext.Provider>
@@ -0,0 +1,173 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { type ChronicleConfig, chronicleConfigSchema } from '@/types'
3
+ import { resolveRoute, RouteType } from './route-resolver'
4
+ import { LATEST_CONTEXT } from './version-source'
5
+
6
+ function singleContent(): ChronicleConfig {
7
+ return chronicleConfigSchema.parse({
8
+ site: { title: 'x' },
9
+ content: [{ dir: 'docs', label: 'Docs' }],
10
+ })
11
+ }
12
+
13
+ function multiContent(): ChronicleConfig {
14
+ return chronicleConfigSchema.parse({
15
+ site: { title: 'x' },
16
+ content: [
17
+ { dir: 'docs', label: 'Docs' },
18
+ { dir: 'dev', label: 'Dev' },
19
+ ],
20
+ latest: { label: '3.0', landing: true },
21
+ })
22
+ }
23
+
24
+ function multiContentNoLanding(): ChronicleConfig {
25
+ return chronicleConfigSchema.parse({
26
+ site: { title: 'x' },
27
+ content: [
28
+ { dir: 'docs', label: 'Docs' },
29
+ { dir: 'dev', label: 'Dev' },
30
+ ],
31
+ })
32
+ }
33
+
34
+ function versioned(): ChronicleConfig {
35
+ return chronicleConfigSchema.parse({
36
+ site: { title: 'x' },
37
+ content: [{ dir: 'docs', label: 'Docs' }],
38
+ latest: { label: '3.0' },
39
+ versions: [
40
+ {
41
+ dir: 'v2',
42
+ label: '2.0',
43
+ content: [{ dir: 'docs', label: 'Docs' }],
44
+ },
45
+ {
46
+ dir: 'v1',
47
+ label: '1.0',
48
+ landing: true,
49
+ content: [
50
+ { dir: 'docs', label: 'Docs' },
51
+ { dir: 'dev', label: 'Dev' },
52
+ ],
53
+ },
54
+ ],
55
+ })
56
+ }
57
+
58
+ describe('resolveRoute — root', () => {
59
+ test('redirects single-content latest root to /<dir>', () => {
60
+ expect(resolveRoute('/', singleContent())).toEqual({
61
+ type: RouteType.Redirect,
62
+ to: '/docs',
63
+ status: 302,
64
+ })
65
+ })
66
+
67
+ test('docs-index for latest root when latest.landing = true', () => {
68
+ expect(resolveRoute('/', multiContent())).toEqual({
69
+ type: RouteType.DocsIndex,
70
+ version: LATEST_CONTEXT,
71
+ })
72
+ })
73
+
74
+ test('redirect for multi-content latest root when landing is not set', () => {
75
+ expect(resolveRoute('/', multiContentNoLanding())).toEqual({
76
+ type: RouteType.Redirect,
77
+ to: '/docs',
78
+ status: 302,
79
+ })
80
+ })
81
+
82
+ test('redirects single-content version root to /<v>/<dir>', () => {
83
+ expect(resolveRoute('/v2', versioned())).toEqual({
84
+ type: RouteType.Redirect,
85
+ to: '/v2/docs',
86
+ status: 302,
87
+ })
88
+ })
89
+
90
+ test('docs-index for version root when versions[].landing = true', () => {
91
+ expect(resolveRoute('/v1', versioned())).toEqual({
92
+ type: RouteType.DocsIndex,
93
+ version: { dir: 'v1', urlPrefix: '/v1' },
94
+ })
95
+ })
96
+ })
97
+
98
+ describe('resolveRoute — docs pages', () => {
99
+ test('latest docs page returns full slug and latest context', () => {
100
+ expect(resolveRoute('/docs/getting-started', singleContent())).toEqual({
101
+ type: RouteType.DocsPage,
102
+ version: LATEST_CONTEXT,
103
+ slug: ['docs', 'getting-started'],
104
+ })
105
+ })
106
+
107
+ test('versioned docs page returns full slug and version context', () => {
108
+ expect(resolveRoute('/v1/dev/intro', versioned())).toEqual({
109
+ type: RouteType.DocsPage,
110
+ version: { dir: 'v1', urlPrefix: '/v1' },
111
+ slug: ['v1', 'dev', 'intro'],
112
+ })
113
+ })
114
+
115
+ test('unrecognized first segment stays latest (page lookup handles 404)', () => {
116
+ expect(resolveRoute('/foo/bar', singleContent())).toEqual({
117
+ type: RouteType.DocsPage,
118
+ version: LATEST_CONTEXT,
119
+ slug: ['foo', 'bar'],
120
+ })
121
+ })
122
+ })
123
+
124
+ describe('resolveRoute — APIs', () => {
125
+ test('latest api index', () => {
126
+ expect(resolveRoute('/apis', singleContent())).toEqual({
127
+ type: RouteType.ApiIndex,
128
+ version: LATEST_CONTEXT,
129
+ })
130
+ })
131
+
132
+ test('latest api page', () => {
133
+ expect(resolveRoute('/apis/petstore/getPetById', singleContent())).toEqual({
134
+ type: RouteType.ApiPage,
135
+ version: LATEST_CONTEXT,
136
+ slug: ['petstore', 'getPetById'],
137
+ })
138
+ })
139
+
140
+ test('versioned api index', () => {
141
+ expect(resolveRoute('/v1/apis', versioned())).toEqual({
142
+ type: RouteType.ApiIndex,
143
+ version: { dir: 'v1', urlPrefix: '/v1' },
144
+ })
145
+ })
146
+
147
+ test('versioned api page', () => {
148
+ expect(
149
+ resolveRoute('/v1/apis/petstore/getPetById', versioned()),
150
+ ).toEqual({
151
+ type: RouteType.ApiPage,
152
+ version: { dir: 'v1', urlPrefix: '/v1' },
153
+ slug: ['petstore', 'getPetById'],
154
+ })
155
+ })
156
+ })
157
+
158
+ describe('resolveRoute — edge cases', () => {
159
+ test('trailing slash is normalized', () => {
160
+ expect(resolveRoute('/v1/', versioned())).toEqual({
161
+ type: RouteType.DocsIndex,
162
+ version: { dir: 'v1', urlPrefix: '/v1' },
163
+ })
164
+ })
165
+
166
+ test('version-shaped path without a matching version stays latest', () => {
167
+ expect(resolveRoute('/v9/docs', versioned())).toEqual({
168
+ type: RouteType.DocsPage,
169
+ version: LATEST_CONTEXT,
170
+ slug: ['v9', 'docs'],
171
+ })
172
+ })
173
+ })
@@ -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
+ }