@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
package/src/lib/source.ts
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import { loader } from 'fumadocs-core/source';
|
|
2
|
+
import { flattenTree } from 'fumadocs-core/page-tree';
|
|
2
3
|
import type { Root, Node, Folder } from 'fumadocs-core/page-tree';
|
|
3
4
|
import type { MDXContent } from 'mdx/types';
|
|
4
5
|
import type { TableOfContents } from 'fumadocs-core/toc';
|
|
5
|
-
import
|
|
6
|
+
import {
|
|
7
|
+
getLatestContentRoots,
|
|
8
|
+
getVersionContentRoots,
|
|
9
|
+
loadConfig,
|
|
10
|
+
} from './config';
|
|
11
|
+
import {
|
|
12
|
+
filterPagesByVersion,
|
|
13
|
+
filterPageTreeByVersion,
|
|
14
|
+
resolveVersionFromUrl,
|
|
15
|
+
type VersionContext,
|
|
16
|
+
} from './version-source';
|
|
17
|
+
import type { Frontmatter, PageNav, PageNavLink } from '@/types';
|
|
6
18
|
|
|
7
19
|
const CONTENT_PREFIX = '../../.content/';
|
|
8
20
|
|
|
@@ -33,14 +45,50 @@ function buildFiles() {
|
|
|
33
45
|
});
|
|
34
46
|
}
|
|
35
47
|
|
|
48
|
+
const userMetaPaths = new Set<string>();
|
|
36
49
|
for (const [key, data] of Object.entries(metaGlob)) {
|
|
37
50
|
const relativePath = key.slice(CONTENT_PREFIX.length);
|
|
51
|
+
userMetaPaths.add(relativePath);
|
|
38
52
|
files.push({ type: 'meta', path: relativePath, data: data ?? {} });
|
|
39
53
|
}
|
|
40
54
|
|
|
55
|
+
for (const entry of buildSyntheticMeta()) {
|
|
56
|
+
if (userMetaPaths.has(entry.path)) continue;
|
|
57
|
+
files.push(entry);
|
|
58
|
+
}
|
|
59
|
+
|
|
41
60
|
return files;
|
|
42
61
|
}
|
|
43
62
|
|
|
63
|
+
function buildSyntheticMeta(): {
|
|
64
|
+
type: 'meta';
|
|
65
|
+
path: string;
|
|
66
|
+
data: Record<string, unknown>;
|
|
67
|
+
}[] {
|
|
68
|
+
const config = loadConfig();
|
|
69
|
+
const entries: { type: 'meta'; path: string; data: Record<string, unknown> }[] = [];
|
|
70
|
+
|
|
71
|
+
for (const root of getLatestContentRoots(config)) {
|
|
72
|
+
entries.push({
|
|
73
|
+
type: 'meta',
|
|
74
|
+
path: `${root.contentDir}/meta.json`,
|
|
75
|
+
data: { title: root.contentLabel, root: true },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const version of config.versions ?? []) {
|
|
80
|
+
for (const root of getVersionContentRoots(config, version.dir)) {
|
|
81
|
+
entries.push({
|
|
82
|
+
type: 'meta',
|
|
83
|
+
path: `${version.dir}/${root.contentDir}/meta.json`,
|
|
84
|
+
data: { title: root.contentLabel, root: true },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return entries;
|
|
90
|
+
}
|
|
91
|
+
|
|
44
92
|
let cachedSource: ReturnType<typeof loader> | null = null;
|
|
45
93
|
|
|
46
94
|
async function getSource() {
|
|
@@ -105,6 +153,51 @@ export async function getPage(slugs?: string[]) {
|
|
|
105
153
|
return s.getPage(slugs);
|
|
106
154
|
}
|
|
107
155
|
|
|
156
|
+
export async function getPageTreeForVersion(ctx: VersionContext): Promise<Root> {
|
|
157
|
+
const tree = await getPageTree();
|
|
158
|
+
return filterPageTreeByVersion(tree, ctx, loadConfig());
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function getPagesForVersion(ctx: VersionContext) {
|
|
162
|
+
const pages = await getPages();
|
|
163
|
+
return filterPagesByVersion(pages, ctx, loadConfig());
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function getVersionContextForUrl(url: string): VersionContext {
|
|
167
|
+
return resolveVersionFromUrl(url, loadConfig());
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export type { VersionContext } from './version-source';
|
|
171
|
+
|
|
172
|
+
function titleFromUrl(url: string): string {
|
|
173
|
+
if (url === '/') return 'Home';
|
|
174
|
+
const last = url.split('/').filter(Boolean).pop();
|
|
175
|
+
if (!last) return 'Home';
|
|
176
|
+
return last
|
|
177
|
+
.split('-')
|
|
178
|
+
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
|
179
|
+
.join(' ');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function getPageNav(slug: string[], tree?: Root): Promise<PageNav> {
|
|
183
|
+
const resolvedTree = tree ?? (await getPageTree());
|
|
184
|
+
const pages = flattenTree(resolvedTree.children);
|
|
185
|
+
const url = slug.length === 0 ? '/' : `/${slug.join('/')}`;
|
|
186
|
+
const i = pages.findIndex(p => p.url === url);
|
|
187
|
+
if (i < 0) return { prev: null, next: null };
|
|
188
|
+
const toLink = (p: (typeof pages)[number]): PageNavLink => ({
|
|
189
|
+
url: p.url,
|
|
190
|
+
title:
|
|
191
|
+
typeof p.name === 'string' && p.name.length > 0
|
|
192
|
+
? p.name
|
|
193
|
+
: titleFromUrl(p.url)
|
|
194
|
+
});
|
|
195
|
+
return {
|
|
196
|
+
prev: i > 0 ? toLink(pages[i - 1]) : null,
|
|
197
|
+
next: i < pages.length - 1 ? toLink(pages[i + 1]) : null
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
108
201
|
export function extractFrontmatter(page: { data: unknown }, fallbackTitle?: string): Frontmatter {
|
|
109
202
|
const d = page.data as Record<string, unknown>;
|
|
110
203
|
return {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { Folder, Item, Root } from 'fumadocs-core/page-tree'
|
|
3
|
+
import { type ChronicleConfig, chronicleConfigSchema } from '@/types'
|
|
4
|
+
import {
|
|
5
|
+
filterPagesByVersion,
|
|
6
|
+
filterPageTreeByContentDir,
|
|
7
|
+
filterPageTreeByVersion,
|
|
8
|
+
LATEST_CONTEXT,
|
|
9
|
+
resolveVersionFromUrl,
|
|
10
|
+
} from './version-source'
|
|
11
|
+
|
|
12
|
+
function makeConfig(): ChronicleConfig {
|
|
13
|
+
return chronicleConfigSchema.parse({
|
|
14
|
+
site: { title: 'x' },
|
|
15
|
+
content: [{ dir: 'docs', label: 'Docs' }],
|
|
16
|
+
latest: { label: '3.0' },
|
|
17
|
+
versions: [
|
|
18
|
+
{
|
|
19
|
+
dir: 'v2',
|
|
20
|
+
label: '2.0',
|
|
21
|
+
content: [{ dir: 'docs', label: 'Docs' }],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
dir: 'v1',
|
|
25
|
+
label: '1.0',
|
|
26
|
+
content: [
|
|
27
|
+
{ dir: 'docs', label: 'Docs' },
|
|
28
|
+
{ dir: 'dev', label: 'Dev' },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function page(url: string): Item {
|
|
36
|
+
return { type: 'page', name: url, url }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function folder(name: string, children: (Item | Folder)[]): Folder {
|
|
40
|
+
return { type: 'folder', name, children }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('resolveVersionFromUrl', () => {
|
|
44
|
+
const config = makeConfig()
|
|
45
|
+
|
|
46
|
+
test('returns latest context for unprefixed URLs', () => {
|
|
47
|
+
expect(resolveVersionFromUrl('/docs/getting-started', config)).toEqual(
|
|
48
|
+
LATEST_CONTEXT,
|
|
49
|
+
)
|
|
50
|
+
expect(resolveVersionFromUrl('/', config)).toEqual(LATEST_CONTEXT)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('returns version context when URL matches a version prefix', () => {
|
|
54
|
+
expect(resolveVersionFromUrl('/v1/docs/intro', config)).toEqual({
|
|
55
|
+
dir: 'v1',
|
|
56
|
+
urlPrefix: '/v1',
|
|
57
|
+
})
|
|
58
|
+
expect(resolveVersionFromUrl('/v2', config)).toEqual({
|
|
59
|
+
dir: 'v2',
|
|
60
|
+
urlPrefix: '/v2',
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('does not match a version when prefix is only a substring', () => {
|
|
65
|
+
expect(resolveVersionFromUrl('/v1beta/docs', config)).toEqual(LATEST_CONTEXT)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('filterPagesByVersion', () => {
|
|
70
|
+
const config = makeConfig()
|
|
71
|
+
const pages = [
|
|
72
|
+
{ url: '/docs/a' },
|
|
73
|
+
{ url: '/docs/b' },
|
|
74
|
+
{ url: '/v1/docs/a' },
|
|
75
|
+
{ url: '/v1/dev/b' },
|
|
76
|
+
{ url: '/v2/docs/a' },
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
test('latest excludes all versioned pages', () => {
|
|
80
|
+
expect(filterPagesByVersion(pages, LATEST_CONTEXT, config)).toEqual([
|
|
81
|
+
{ url: '/docs/a' },
|
|
82
|
+
{ url: '/docs/b' },
|
|
83
|
+
])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('version returns only pages under its prefix', () => {
|
|
87
|
+
expect(
|
|
88
|
+
filterPagesByVersion(pages, { dir: 'v1', urlPrefix: '/v1' }, config),
|
|
89
|
+
).toEqual([{ url: '/v1/docs/a' }, { url: '/v1/dev/b' }])
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('filterPageTreeByVersion', () => {
|
|
94
|
+
const config = makeConfig()
|
|
95
|
+
const latestDocs = folder('docs', [page('/docs/a'), page('/docs/b')])
|
|
96
|
+
const v1Folder = folder('v1', [
|
|
97
|
+
folder('docs', [page('/v1/docs/a')]),
|
|
98
|
+
folder('dev', [page('/v1/dev/a')]),
|
|
99
|
+
])
|
|
100
|
+
const v2Folder = folder('v2', [folder('docs', [page('/v2/docs/a')])])
|
|
101
|
+
|
|
102
|
+
const tree: Root = {
|
|
103
|
+
name: 'root',
|
|
104
|
+
children: [latestDocs, v1Folder, v2Folder],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
test('latest drops version folders', () => {
|
|
108
|
+
const filtered = filterPageTreeByVersion(tree, LATEST_CONTEXT, config)
|
|
109
|
+
expect(filtered.children).toEqual([latestDocs])
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('version returns the inner children of its folder', () => {
|
|
113
|
+
const filtered = filterPageTreeByVersion(
|
|
114
|
+
tree,
|
|
115
|
+
{ dir: 'v1', urlPrefix: '/v1' },
|
|
116
|
+
config,
|
|
117
|
+
)
|
|
118
|
+
expect(filtered.children).toEqual(v1Folder.children)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('version returns empty children when the version folder is absent', () => {
|
|
122
|
+
const filtered = filterPageTreeByVersion(
|
|
123
|
+
{ name: 'root', children: [latestDocs] },
|
|
124
|
+
{ dir: 'v1', urlPrefix: '/v1' },
|
|
125
|
+
config,
|
|
126
|
+
)
|
|
127
|
+
expect(filtered.children).toEqual([])
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('filterPageTreeByContentDir', () => {
|
|
132
|
+
const latestDocs = folder('docs', [page('/docs/a'), page('/docs/b')])
|
|
133
|
+
const latestDev = folder('dev', [page('/dev/x')])
|
|
134
|
+
const latestTree: Root = {
|
|
135
|
+
name: 'root',
|
|
136
|
+
children: [latestDocs, latestDev],
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
test('null contentDir returns tree unchanged', () => {
|
|
140
|
+
const out = filterPageTreeByContentDir(latestTree, LATEST_CONTEXT, null)
|
|
141
|
+
expect(out).toBe(latestTree)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('returns just the matching content folder children (latest)', () => {
|
|
145
|
+
const out = filterPageTreeByContentDir(latestTree, LATEST_CONTEXT, 'docs')
|
|
146
|
+
expect(out.children).toEqual(latestDocs.children)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('returns empty children when content dir is absent', () => {
|
|
150
|
+
const out = filterPageTreeByContentDir(latestTree, LATEST_CONTEXT, 'missing')
|
|
151
|
+
expect(out.children).toEqual([])
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('uses version urlPrefix to disambiguate within a version', () => {
|
|
155
|
+
const v1Docs = folder('docs', [page('/v1/docs/a')])
|
|
156
|
+
const v1Dev = folder('dev', [page('/v1/dev/x')])
|
|
157
|
+
const ctx = { dir: 'v1', urlPrefix: '/v1' }
|
|
158
|
+
const tree: Root = { name: 'root', children: [v1Docs, v1Dev] }
|
|
159
|
+
expect(
|
|
160
|
+
filterPageTreeByContentDir(tree, ctx, 'dev').children,
|
|
161
|
+
).toEqual(v1Dev.children)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Folder, Node, Root } from 'fumadocs-core/page-tree'
|
|
2
|
+
import type { ChronicleConfig } from '@/types'
|
|
3
|
+
|
|
4
|
+
export interface VersionContext {
|
|
5
|
+
dir: string | null
|
|
6
|
+
urlPrefix: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const LATEST_CONTEXT: VersionContext = { dir: null, urlPrefix: '' }
|
|
10
|
+
|
|
11
|
+
export function resolveVersionFromUrl(
|
|
12
|
+
url: string,
|
|
13
|
+
config: ChronicleConfig,
|
|
14
|
+
): VersionContext {
|
|
15
|
+
for (const v of config.versions ?? []) {
|
|
16
|
+
const prefix = `/${v.dir}`
|
|
17
|
+
if (url === prefix || url.startsWith(`${prefix}/`)) {
|
|
18
|
+
return { dir: v.dir, urlPrefix: prefix }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return LATEST_CONTEXT
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function versionPrefixes(config: ChronicleConfig): string[] {
|
|
25
|
+
return (config.versions ?? []).map((v) => `/${v.dir}`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isUnderPrefix(url: string, prefix: string): boolean {
|
|
29
|
+
return url === prefix || url.startsWith(`${prefix}/`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function filterPagesByVersion<T extends { url: string }>(
|
|
33
|
+
pages: T[],
|
|
34
|
+
ctx: VersionContext,
|
|
35
|
+
config: ChronicleConfig,
|
|
36
|
+
): T[] {
|
|
37
|
+
if (ctx.dir !== null) {
|
|
38
|
+
return pages.filter((p) => isUnderPrefix(p.url, ctx.urlPrefix))
|
|
39
|
+
}
|
|
40
|
+
const prefixes = versionPrefixes(config)
|
|
41
|
+
return pages.filter((p) => !prefixes.some((pre) => isUnderPrefix(p.url, pre)))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function nodeUrls(node: Node): string[] {
|
|
45
|
+
if (node.type === 'page') return [node.url]
|
|
46
|
+
if (node.type === 'folder') {
|
|
47
|
+
const urls: string[] = []
|
|
48
|
+
if (node.index) urls.push(node.index.url)
|
|
49
|
+
for (const child of node.children) urls.push(...nodeUrls(child))
|
|
50
|
+
return urls
|
|
51
|
+
}
|
|
52
|
+
return []
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function nodeMatchesVersion(
|
|
56
|
+
node: Node,
|
|
57
|
+
ctx: VersionContext,
|
|
58
|
+
config: ChronicleConfig,
|
|
59
|
+
): boolean {
|
|
60
|
+
const urls = nodeUrls(node)
|
|
61
|
+
if (urls.length === 0) return ctx.dir === null
|
|
62
|
+
if (ctx.dir !== null) {
|
|
63
|
+
return urls.every((u) => isUnderPrefix(u, ctx.urlPrefix))
|
|
64
|
+
}
|
|
65
|
+
const prefixes = versionPrefixes(config)
|
|
66
|
+
return urls.every((u) => !prefixes.some((pre) => isUnderPrefix(u, pre)))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function filterPageTreeByVersion(
|
|
70
|
+
tree: Root,
|
|
71
|
+
ctx: VersionContext,
|
|
72
|
+
config: ChronicleConfig,
|
|
73
|
+
): Root {
|
|
74
|
+
if (ctx.dir !== null) {
|
|
75
|
+
const versionFolder = tree.children.find(
|
|
76
|
+
(n): n is Folder =>
|
|
77
|
+
n.type === 'folder' && nodeMatchesVersion(n, ctx, config),
|
|
78
|
+
)
|
|
79
|
+
return { ...tree, children: versionFolder ? versionFolder.children : [] }
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
...tree,
|
|
83
|
+
children: tree.children.filter((n) => nodeMatchesVersion(n, ctx, config)),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function filterPageTreeByContentDir(
|
|
88
|
+
tree: Root,
|
|
89
|
+
ctx: VersionContext,
|
|
90
|
+
contentDir: string | null,
|
|
91
|
+
): Root {
|
|
92
|
+
if (contentDir === null) return tree
|
|
93
|
+
const expectedPrefix = `${ctx.urlPrefix}/${contentDir}`
|
|
94
|
+
const match = tree.children.find((n): n is Folder => {
|
|
95
|
+
if (n.type !== 'folder') return false
|
|
96
|
+
const urls = nodeUrls(n)
|
|
97
|
+
return urls.length > 0 && urls.every((u) => isUnderPrefix(u, expectedPrefix))
|
|
98
|
+
})
|
|
99
|
+
if (!match) return { ...tree, children: [] }
|
|
100
|
+
return { ...tree, children: match.children }
|
|
101
|
+
}
|
package/src/pages/ApiPage.tsx
CHANGED
|
@@ -18,7 +18,7 @@ export function ApiPage({ slug }: ApiPageProps) {
|
|
|
18
18
|
<>
|
|
19
19
|
<Head
|
|
20
20
|
title='API Reference'
|
|
21
|
-
description={`API documentation for ${config.title}`}
|
|
21
|
+
description={`API documentation for ${config.site.title}`}
|
|
22
22
|
config={config}
|
|
23
23
|
/>
|
|
24
24
|
<ApiLanding specs={apiSpecs} />
|
package/src/pages/DocsLayout.tsx
CHANGED
|
@@ -1,17 +1,38 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
+
import { useLocation } from 'react-router';
|
|
2
3
|
import { usePageContext } from '@/lib/page-context';
|
|
4
|
+
import { getActiveContentDir } from '@/lib/navigation';
|
|
5
|
+
import {
|
|
6
|
+
filterPageTreeByContentDir,
|
|
7
|
+
filterPageTreeByVersion,
|
|
8
|
+
} from '@/lib/version-source';
|
|
3
9
|
import { getTheme } from '@/themes/registry';
|
|
4
10
|
|
|
5
11
|
interface DocsLayoutProps {
|
|
6
12
|
children: ReactNode;
|
|
13
|
+
hideSidebar?: boolean;
|
|
7
14
|
}
|
|
8
15
|
|
|
9
|
-
export function DocsLayout({ children }: DocsLayoutProps) {
|
|
10
|
-
const { config, tree } = usePageContext();
|
|
16
|
+
export function DocsLayout({ children, hideSidebar }: DocsLayoutProps) {
|
|
17
|
+
const { config, tree, version } = usePageContext();
|
|
18
|
+
const { pathname } = useLocation();
|
|
11
19
|
const { Layout, className } = getTheme(config.theme?.name);
|
|
12
20
|
|
|
21
|
+
const activeContentDir = getActiveContentDir(pathname, config);
|
|
22
|
+
const versionScoped = filterPageTreeByVersion(tree, version, config);
|
|
23
|
+
const scopedTree = filterPageTreeByContentDir(
|
|
24
|
+
versionScoped,
|
|
25
|
+
version,
|
|
26
|
+
activeContentDir,
|
|
27
|
+
);
|
|
28
|
+
|
|
13
29
|
return (
|
|
14
|
-
<Layout
|
|
30
|
+
<Layout
|
|
31
|
+
config={config}
|
|
32
|
+
tree={scopedTree}
|
|
33
|
+
hideSidebar={hideSidebar}
|
|
34
|
+
classNames={{ layout: className }}
|
|
35
|
+
>
|
|
15
36
|
{children}
|
|
16
37
|
</Layout>
|
|
17
38
|
);
|
package/src/pages/DocsPage.tsx
CHANGED
|
@@ -16,6 +16,7 @@ export function DocsPage({ slug }: DocsPageProps) {
|
|
|
16
16
|
|
|
17
17
|
const { Page } = getTheme(config.theme?.name);
|
|
18
18
|
const pageUrl = config.url ? `${config.url}/${slug.join('/')}` : undefined;
|
|
19
|
+
const markdownHref = `/${slug.join('/')}.md`;
|
|
19
20
|
|
|
20
21
|
return (
|
|
21
22
|
<>
|
|
@@ -23,6 +24,7 @@ export function DocsPage({ slug }: DocsPageProps) {
|
|
|
23
24
|
title={page.frontmatter.title}
|
|
24
25
|
description={page.frontmatter.description}
|
|
25
26
|
config={config}
|
|
27
|
+
markdownHref={markdownHref}
|
|
26
28
|
jsonLd={{
|
|
27
29
|
'@context': 'https://schema.org',
|
|
28
30
|
'@type': 'Article',
|
|
@@ -32,12 +34,7 @@ export function DocsPage({ slug }: DocsPageProps) {
|
|
|
32
34
|
}}
|
|
33
35
|
/>
|
|
34
36
|
<Page
|
|
35
|
-
page={
|
|
36
|
-
slug: page.slug,
|
|
37
|
-
frontmatter: page.frontmatter,
|
|
38
|
-
content: page.content,
|
|
39
|
-
toc: page.toc
|
|
40
|
-
}}
|
|
37
|
+
page={page}
|
|
41
38
|
config={config}
|
|
42
39
|
tree={tree}
|
|
43
40
|
/>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: var(--rs-space-8);
|
|
5
|
+
padding: var(--rs-space-9) var(--rs-space-7);
|
|
6
|
+
max-width: 960px;
|
|
7
|
+
margin: 0 auto;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.title {
|
|
11
|
+
font-size: var(--rs-font-size-h3);
|
|
12
|
+
font-weight: 600;
|
|
13
|
+
color: var(--rs-color-foreground-base-primary);
|
|
14
|
+
margin: 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.description {
|
|
18
|
+
font-size: var(--rs-font-size-regular);
|
|
19
|
+
color: var(--rs-color-foreground-base-secondary);
|
|
20
|
+
margin: 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.grid {
|
|
24
|
+
display: grid;
|
|
25
|
+
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
26
|
+
gap: var(--rs-space-6);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.card {
|
|
30
|
+
display: flex;
|
|
31
|
+
flex-direction: column;
|
|
32
|
+
gap: var(--rs-space-3);
|
|
33
|
+
padding: var(--rs-space-6);
|
|
34
|
+
border: 1px solid var(--rs-color-border-base-primary);
|
|
35
|
+
border-radius: var(--rs-radius-3);
|
|
36
|
+
text-decoration: none;
|
|
37
|
+
color: inherit;
|
|
38
|
+
background: var(--rs-color-background-base-primary);
|
|
39
|
+
transition: border-color 0.15s ease, background 0.15s ease;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.card:hover {
|
|
43
|
+
border-color: var(--rs-color-border-accent-primary);
|
|
44
|
+
background: var(--rs-color-background-neutral-secondary);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.cardLabel {
|
|
48
|
+
font-size: var(--rs-font-size-large);
|
|
49
|
+
font-weight: 500;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.cardHref {
|
|
53
|
+
font-size: var(--rs-font-size-small);
|
|
54
|
+
color: var(--rs-color-foreground-base-secondary);
|
|
55
|
+
font-family: var(--rs-font-family-mono);
|
|
56
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Link as RouterLink } from 'react-router';
|
|
2
|
+
import { getLandingEntries } from '@/lib/config';
|
|
3
|
+
import { usePageContext } from '@/lib/page-context';
|
|
4
|
+
import styles from './LandingPage.module.css';
|
|
5
|
+
|
|
6
|
+
export function LandingPage() {
|
|
7
|
+
const { config, version } = usePageContext();
|
|
8
|
+
const entries = getLandingEntries(config, version.dir);
|
|
9
|
+
|
|
10
|
+
const heading = version.dir === null
|
|
11
|
+
? config.site.title
|
|
12
|
+
: `${config.site.title} — ${versionLabel(config, version.dir)}`;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className={styles.root}>
|
|
16
|
+
<h1 className={styles.title}>{heading}</h1>
|
|
17
|
+
{config.site.description ? (
|
|
18
|
+
<p className={styles.description}>{config.site.description}</p>
|
|
19
|
+
) : null}
|
|
20
|
+
<div className={styles.grid}>
|
|
21
|
+
{entries.map((entry) => (
|
|
22
|
+
<RouterLink key={entry.href} to={entry.href} className={styles.card}>
|
|
23
|
+
<span className={styles.cardLabel}>{entry.label}</span>
|
|
24
|
+
<span className={styles.cardHref}>{entry.href}</span>
|
|
25
|
+
</RouterLink>
|
|
26
|
+
))}
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function versionLabel(
|
|
33
|
+
config: ReturnType<typeof usePageContext>['config'],
|
|
34
|
+
versionDir: string,
|
|
35
|
+
): string {
|
|
36
|
+
return (
|
|
37
|
+
config.versions?.find((v) => v.dir === versionDir)?.label ?? versionDir
|
|
38
|
+
);
|
|
39
|
+
}
|
package/src/pages/NotFound.tsx
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { DocumentTextIcon } from '@heroicons/react/24/outline';
|
|
1
2
|
import { EmptyState } from '@raystack/apsara';
|
|
2
3
|
import styles from './NotFound.module.css';
|
|
3
4
|
|
|
4
5
|
export function NotFound() {
|
|
5
6
|
return (
|
|
6
7
|
<EmptyState
|
|
8
|
+
icon={<DocumentTextIcon width={32} height={32} />}
|
|
7
9
|
heading="404"
|
|
8
10
|
subHeading="Page not found"
|
|
9
11
|
classNames={{ container: styles.emptyState }}
|
package/src/server/App.tsx
CHANGED
|
@@ -1,49 +1,47 @@
|
|
|
1
1
|
import '@raystack/apsara/normalize.css';
|
|
2
2
|
import '@raystack/apsara/style.css';
|
|
3
3
|
import { ThemeProvider } from '@raystack/apsara';
|
|
4
|
-
import { useLocation } from 'react-router';
|
|
4
|
+
import { Navigate, useLocation } from 'react-router';
|
|
5
5
|
import { Head } from '@/lib/head';
|
|
6
6
|
import { usePageContext } from '@/lib/page-context';
|
|
7
|
+
import { resolveRoute, RouteType } from '@/lib/route-resolver';
|
|
7
8
|
import { ApiLayout } from '@/pages/ApiLayout';
|
|
8
9
|
import { ApiPage } from '@/pages/ApiPage';
|
|
9
10
|
import { DocsLayout } from '@/pages/DocsLayout';
|
|
10
11
|
import { DocsPage } from '@/pages/DocsPage';
|
|
12
|
+
import { LandingPage } from '@/pages/LandingPage';
|
|
11
13
|
import type { ChronicleConfig } from '@/types';
|
|
12
14
|
import { getThemeConfig } from '@/themes/registry';
|
|
13
15
|
|
|
14
|
-
function resolveRoute(pathname: string) {
|
|
15
|
-
if (pathname.startsWith('/apis')) {
|
|
16
|
-
const slug = pathname
|
|
17
|
-
.replace(/^\/apis\/?/, '')
|
|
18
|
-
.split('/')
|
|
19
|
-
.filter(Boolean);
|
|
20
|
-
return { type: 'api' as const, slug };
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const slug =
|
|
24
|
-
pathname === '/' ? [] : pathname.slice(1).split('/').filter(Boolean);
|
|
25
|
-
return { type: 'docs' as const, slug };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
16
|
export function App() {
|
|
29
17
|
const { pathname } = useLocation();
|
|
30
18
|
const { config } = usePageContext();
|
|
31
|
-
const route = resolveRoute(pathname);
|
|
19
|
+
const route = resolveRoute(pathname, config);
|
|
32
20
|
const themeConfig = getThemeConfig(config.theme?.name);
|
|
33
21
|
|
|
22
|
+
if (route.type === RouteType.Redirect) {
|
|
23
|
+
return <Navigate to={route.to} replace />;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const isApi =
|
|
27
|
+
route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage;
|
|
28
|
+
const apiSlug = route.type === RouteType.ApiPage ? route.slug : [];
|
|
29
|
+
const docsSlug = route.type === RouteType.DocsPage ? route.slug : [];
|
|
30
|
+
const isLanding = route.type === RouteType.DocsIndex;
|
|
31
|
+
|
|
34
32
|
return (
|
|
35
33
|
<ThemeProvider
|
|
36
34
|
enableSystem={themeConfig.enableSystem}
|
|
37
35
|
forcedTheme={themeConfig.forcedTheme}
|
|
38
36
|
>
|
|
39
37
|
<RootHead config={config} />
|
|
40
|
-
{
|
|
38
|
+
{isApi ? (
|
|
41
39
|
<ApiLayout>
|
|
42
|
-
<ApiPage slug={
|
|
40
|
+
<ApiPage slug={apiSlug} />
|
|
43
41
|
</ApiLayout>
|
|
44
42
|
) : (
|
|
45
43
|
<DocsLayout>
|
|
46
|
-
<DocsPage slug={
|
|
44
|
+
{isLanding ? <LandingPage /> : <DocsPage slug={docsSlug} />}
|
|
47
45
|
</DocsLayout>
|
|
48
46
|
)}
|
|
49
47
|
</ThemeProvider>
|
|
@@ -53,16 +51,16 @@ export function App() {
|
|
|
53
51
|
function RootHead({ config }: { config: ChronicleConfig }) {
|
|
54
52
|
return (
|
|
55
53
|
<Head
|
|
56
|
-
title={config.title}
|
|
57
|
-
description={config.description}
|
|
54
|
+
title={config.site.title}
|
|
55
|
+
description={config.site.description}
|
|
58
56
|
config={config}
|
|
59
57
|
jsonLd={
|
|
60
58
|
config.url
|
|
61
59
|
? {
|
|
62
60
|
'@context': 'https://schema.org',
|
|
63
61
|
'@type': 'WebSite',
|
|
64
|
-
name: config.title,
|
|
65
|
-
description: config.description,
|
|
62
|
+
name: config.site.title,
|
|
63
|
+
description: config.site.description,
|
|
66
64
|
url: config.url
|
|
67
65
|
}
|
|
68
66
|
: undefined
|
package/src/server/api/page.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineHandler, HTTPError } from 'nitro';
|
|
2
|
-
import { getPage, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
|
|
2
|
+
import { getPage, getPageNav, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
|
|
3
3
|
|
|
4
4
|
export default defineHandler(async event => {
|
|
5
5
|
const slugParam = event.url.searchParams.get('slug') ?? '';
|
|
@@ -10,9 +10,13 @@ export default defineHandler(async event => {
|
|
|
10
10
|
throw new HTTPError({ status: 404, message: 'Page not found' });
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
const nav = await getPageNav(slug);
|
|
14
|
+
|
|
13
15
|
return {
|
|
14
16
|
frontmatter: extractFrontmatter(page, slug[slug.length - 1]),
|
|
15
17
|
relativePath: getRelativePath(page),
|
|
16
18
|
originalPath: getOriginalPath(page),
|
|
19
|
+
prev: nav.prev,
|
|
20
|
+
next: nav.next,
|
|
17
21
|
};
|
|
18
22
|
});
|