@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.
- package/dist/cli/index.js +258 -80
- package/package.json +7 -5
- package/src/cli/commands/build.ts +5 -8
- package/src/cli/commands/dev.ts +5 -6
- package/src/cli/commands/init.test.ts +77 -0
- package/src/cli/commands/init.ts +73 -40
- package/src/cli/commands/serve.ts +6 -9
- package/src/cli/commands/start.ts +5 -5
- package/src/cli/utils/config.ts +6 -12
- package/src/cli/utils/scaffold.test.ts +179 -0
- package/src/cli/utils/scaffold.ts +70 -9
- package/src/components/api/field-row.tsx +1 -1
- package/src/components/api/field-section.tsx +2 -2
- package/src/components/mdx/index.tsx +1 -1
- package/src/components/ui/breadcrumbs.tsx +4 -2
- package/src/components/ui/client-theme-switcher.tsx +21 -4
- package/src/components/ui/search.module.css +16 -41
- package/src/components/ui/search.tsx +30 -41
- package/src/lib/config.test.ts +493 -0
- package/src/lib/config.ts +123 -22
- package/src/lib/head.tsx +23 -5
- package/src/lib/llms.test.ts +94 -0
- package/src/lib/llms.ts +41 -0
- package/src/lib/navigation.test.ts +94 -0
- package/src/lib/navigation.ts +51 -0
- package/src/lib/page-context.tsx +51 -32
- package/src/lib/route-resolver.test.ts +173 -0
- package/src/lib/route-resolver.ts +73 -0
- package/src/lib/source.ts +94 -1
- package/src/lib/version-source.test.ts +163 -0
- package/src/lib/version-source.ts +101 -0
- package/src/pages/ApiPage.tsx +1 -1
- package/src/pages/DocsLayout.tsx +24 -3
- package/src/pages/DocsPage.tsx +3 -6
- package/src/pages/LandingPage.module.css +56 -0
- package/src/pages/LandingPage.tsx +39 -0
- package/src/pages/NotFound.tsx +2 -0
- package/src/server/App.tsx +21 -23
- package/src/server/api/page.ts +5 -1
- package/src/server/api/search.ts +51 -24
- package/src/server/api/specs.ts +17 -5
- package/src/server/entry-client.tsx +42 -14
- package/src/server/entry-server.tsx +33 -11
- package/src/server/routes/[...slug].md.ts +0 -6
- package/src/server/routes/[version]/llms.txt.ts +26 -0
- package/src/server/routes/llms.txt.ts +10 -13
- package/src/server/routes/og.tsx +2 -2
- package/src/server/routes/sitemap.xml.ts +14 -6
- package/src/server/vite-config.ts +5 -5
- package/src/themes/default/ContentDirButtons.tsx +66 -0
- package/src/themes/default/Layout.module.css +187 -40
- package/src/themes/default/Layout.tsx +166 -65
- package/src/themes/default/OpenInAI.tsx +112 -0
- package/src/themes/default/Page.module.css +30 -0
- package/src/themes/default/Page.tsx +1 -3
- package/src/themes/default/SidebarLogo.tsx +26 -0
- package/src/themes/default/Toc.module.css +102 -25
- package/src/themes/default/Toc.tsx +56 -10
- package/src/themes/default/VersionSwitcher.tsx +59 -0
- package/src/themes/paper/ContentDirDropdown.tsx +47 -0
- package/src/themes/paper/Layout.module.css +7 -0
- package/src/themes/paper/Layout.tsx +20 -13
- package/src/themes/paper/VersionSwitcher.tsx +60 -0
- package/src/types/config.ts +145 -23
- package/src/types/content.ts +11 -1
- package/src/types/theme.ts +1 -0
- package/src/components/ui/footer.module.css +0 -27
- 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
|
+
})
|
package/src/lib/llms.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/page-context.tsx
CHANGED
|
@@ -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
|
|
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:
|
|
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: {
|
|
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:
|
|
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:
|
|
61
|
+
function getInitialErrorStatus(page: Page | null, config: ChronicleConfig, pathname: string): number | null {
|
|
60
62
|
if (page) return null;
|
|
61
|
-
|
|
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<
|
|
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 (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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
|
+
}
|