@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,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,22 +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;
21
+ errorStatus: number | null;
25
22
  apiSpecs: ApiSpec[];
23
+ version: VersionContext;
26
24
  }
27
25
 
28
26
  const PageContext = createContext<PageContextValue | null>(null);
@@ -32,10 +30,15 @@ export function usePageContext(): PageContextValue {
32
30
  if (!ctx) {
33
31
  console.error('usePageContext: no context found!');
34
32
  return {
35
- config: { title: 'Documentation' },
33
+ config: {
34
+ site: { title: 'Documentation' },
35
+ content: [{ dir: 'docs', label: 'Docs' }],
36
+ },
36
37
  tree: { name: 'root', children: [] } as Root,
37
38
  page: null,
38
- apiSpecs: []
39
+ errorStatus: null,
40
+ apiSpecs: [],
41
+ version: LATEST_CONTEXT,
39
42
  };
40
43
  }
41
44
  return ctx;
@@ -44,66 +47,110 @@ export function usePageContext(): PageContextValue {
44
47
  interface PageProviderProps {
45
48
  initialConfig: ChronicleConfig;
46
49
  initialTree: Root;
47
- initialPage: PageData | null;
50
+ initialPage: Page | null;
48
51
  initialApiSpecs: ApiSpec[];
52
+ initialVersion: VersionContext;
49
53
  loadMdx: MdxLoader;
50
54
  children: ReactNode;
51
55
  }
52
56
 
57
+ function isApisRoute(pathname: string): boolean {
58
+ return pathname === '/apis' || pathname.startsWith('/apis/');
59
+ }
60
+
61
+ function getInitialErrorStatus(page: Page | null, config: ChronicleConfig, pathname: string): number | null {
62
+ if (page) 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;
67
+ return 404;
68
+ }
69
+
53
70
  export function PageProvider({
54
71
  initialConfig,
55
72
  initialTree,
56
73
  initialPage,
57
74
  initialApiSpecs,
75
+ initialVersion,
58
76
  loadMdx,
59
77
  children
60
78
  }: PageProviderProps) {
61
79
  const { pathname } = useLocation();
62
80
  const [tree] = useState<Root>(initialTree);
63
- const [page, setPage] = useState<PageData | null>(initialPage);
81
+ const [page, setPage] = useState<Page | null>(initialPage);
82
+ const [errorStatus, setErrorStatus] = useState<number | null>(getInitialErrorStatus(initialPage, initialConfig, pathname));
64
83
  const [apiSpecs, setApiSpecs] = useState<ApiSpec[]>(initialApiSpecs);
84
+ const [version, setVersion] = useState<VersionContext>(initialVersion);
65
85
  const [currentPath, setCurrentPath] = useState(pathname);
66
86
 
67
87
  useEffect(() => {
68
88
  if (pathname === currentPath) return;
69
89
  setCurrentPath(pathname);
70
90
 
91
+ const route = resolveRoute(pathname, initialConfig);
92
+ if (route.type !== RouteType.Redirect) setVersion(route.version);
93
+
71
94
  const cancelled = { current: false };
72
95
 
73
- if (pathname.startsWith('/apis')) {
74
- if (apiSpecs.length === 0) {
75
- fetch('/api/specs')
76
- .then(res => res.json())
77
- .then(specs => {
78
- if (!cancelled.current) setApiSpecs(specs);
79
- })
80
- .catch(() => {});
81
- }
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
+ });
82
110
  return () => { cancelled.current = true; };
83
111
  }
84
112
 
85
- const slug = pathname === '/'
86
- ? []
87
- : pathname.slice(1).split('/').filter(Boolean);
113
+ if (route.type !== RouteType.DocsPage) {
114
+ setPage(null);
115
+ setErrorStatus(null);
116
+ return () => { cancelled.current = true; };
117
+ }
88
118
 
89
- const apiPath = slug.length === 0 ? '/api/page/' : `/api/page/${slug.join('/')}`;
119
+ const apiPath = route.slug.length === 0
120
+ ? '/api/page'
121
+ : `/api/page?slug=${route.slug.join(',')}`;
90
122
 
91
123
  fetch(apiPath)
92
- .then(res => res.json())
93
- .then(async (data: { frontmatter: Frontmatter; relativePath: string; originalPath?: string }) => {
94
- if (cancelled.current) return;
124
+ .then(res => {
125
+ if (!res.ok) {
126
+ if (!cancelled.current) {
127
+ setPage(null);
128
+ setErrorStatus(res.status);
129
+ }
130
+ return;
131
+ }
132
+ return res.json();
133
+ })
134
+ .then(async (data: { frontmatter: Frontmatter; relativePath: string; originalPath?: string; prev?: Page['prev']; next?: Page['next'] } | undefined) => {
135
+ if (cancelled.current || !data) return;
95
136
  const { content, toc } = await loadMdx(data.originalPath || data.relativePath);
96
137
  if (cancelled.current) return;
97
- setPage({ slug, frontmatter: data.frontmatter, content, toc });
138
+ setErrorStatus(null);
139
+ setPage({ slug: route.slug, frontmatter: data.frontmatter, content, toc, prev: data.prev, next: data.next });
98
140
  })
99
- .catch(() => {});
141
+ .catch(() => {
142
+ if (!cancelled.current) {
143
+ setPage(null);
144
+ setErrorStatus(500);
145
+ }
146
+ });
100
147
 
101
148
  return () => { cancelled.current = true; };
102
149
  }, [pathname]);
103
150
 
104
151
  return (
105
152
  <PageContext.Provider
106
- value={{ config: initialConfig, tree, page, apiSpecs }}
153
+ value={{ config: initialConfig, tree, page, errorStatus, apiSpecs, version }}
107
154
  >
108
155
  {children}
109
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
+ })