@raystack/chronicle 0.7.2 → 0.7.4
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/package.json +2 -1
- package/src/components/ui/breadcrumbs.tsx +3 -2
- package/src/components/ui/search.tsx +76 -23
- package/src/lib/page-context.tsx +74 -46
- package/src/lib/source.ts +26 -11
- package/src/pages/DocsPage.tsx +3 -3
- package/src/server/entry-server.tsx +1 -1
- package/src/themes/default/Skeleton.tsx +27 -0
- package/src/themes/default/index.ts +4 -2
- package/src/themes/paper/ChapterNav.module.css +4 -0
- package/src/themes/paper/ChapterNav.tsx +1 -1
- package/src/themes/paper/Page.module.css +20 -24
- package/src/themes/paper/Page.tsx +13 -32
- package/src/themes/paper/Skeleton.tsx +32 -0
- package/src/themes/paper/index.ts +3 -1
- package/src/types/theme.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raystack/chronicle",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"description": "Config-driven documentation framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"@opentelemetry/resources": "^2.6.1",
|
|
47
47
|
"@opentelemetry/sdk-metrics": "^2.6.1",
|
|
48
48
|
"@opentelemetry/semantic-conventions": "^1.40.0",
|
|
49
|
+
"@radix-ui/react-icons": "^1.3.2",
|
|
49
50
|
"@raystack/apsara": "1.0.0-rc.4",
|
|
50
51
|
"@shikijs/rehype": "^4.0.2",
|
|
51
52
|
"@vitejs/plugin-react": "^6.0.1",
|
|
@@ -8,16 +8,17 @@ import { Link as RouterLink } from 'react-router'
|
|
|
8
8
|
interface BreadcrumbsProps {
|
|
9
9
|
slug: string[]
|
|
10
10
|
tree: Root
|
|
11
|
+
className?: string
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
export function Breadcrumbs({ slug, tree }: BreadcrumbsProps) {
|
|
14
|
+
export function Breadcrumbs({ slug, tree, className }: BreadcrumbsProps) {
|
|
14
15
|
const url = slug.length === 0 ? '/' : `/${slug.join('/')}`
|
|
15
16
|
const items = getBreadcrumbItems(url, tree, { includePage: true })
|
|
16
17
|
|
|
17
18
|
if (items.length === 0) return null
|
|
18
19
|
|
|
19
20
|
return (
|
|
20
|
-
<Breadcrumb size="small">
|
|
21
|
+
<Breadcrumb size="small" className={className}>
|
|
21
22
|
{items.flatMap((item, index) => {
|
|
22
23
|
const isCurrent = index === items.length - 1
|
|
23
24
|
const breadcrumbItem = (
|
|
@@ -4,30 +4,86 @@ import {
|
|
|
4
4
|
MagnifyingGlassIcon
|
|
5
5
|
} from '@heroicons/react/24/outline';
|
|
6
6
|
import { Command, IconButton, Text } from '@raystack/apsara';
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
import { useCallback, useEffect, useState } from 'react';
|
|
7
|
+
import debounce from 'lodash/debounce';
|
|
8
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
10
9
|
import { useNavigate } from 'react-router';
|
|
11
10
|
import { MethodBadge } from '@/components/api/method-badge';
|
|
12
11
|
import { usePageContext } from '@/lib/page-context';
|
|
13
12
|
import styles from './search.module.css';
|
|
14
13
|
|
|
14
|
+
interface SearchResult {
|
|
15
|
+
id: string;
|
|
16
|
+
url: string;
|
|
17
|
+
type: string;
|
|
18
|
+
content: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
interface SearchProps {
|
|
16
22
|
classNames?: { trigger?: string };
|
|
17
23
|
}
|
|
18
24
|
|
|
25
|
+
function buildSearchUrl(query: string, tag?: string): string {
|
|
26
|
+
const params = new URLSearchParams();
|
|
27
|
+
if (query) params.set('query', query);
|
|
28
|
+
if (tag) params.set('tag', tag);
|
|
29
|
+
const qs = params.toString();
|
|
30
|
+
return qs ? `/api/search?${qs}` : '/api/search';
|
|
31
|
+
}
|
|
32
|
+
|
|
19
33
|
export function Search({ classNames }: SearchProps) {
|
|
20
34
|
const [open, setOpen] = useState(false);
|
|
35
|
+
const [search, setSearch] = useState('');
|
|
36
|
+
const [results, setResults] = useState<SearchResult[]>([]);
|
|
37
|
+
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
|
38
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
21
39
|
const navigate = useNavigate();
|
|
22
40
|
const { version } = usePageContext();
|
|
41
|
+
const tag = version.dir ?? undefined;
|
|
42
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
23
43
|
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
44
|
+
const fetchResults = useCallback(async (query: string, signal?: AbortSignal) => {
|
|
45
|
+
setIsLoading(true);
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(buildSearchUrl(query, tag), { signal });
|
|
48
|
+
if (!res.ok || signal?.aborted) return;
|
|
49
|
+
const data: SearchResult[] = await res.json();
|
|
50
|
+
if (signal?.aborted) return;
|
|
51
|
+
if (query) {
|
|
52
|
+
setResults(data);
|
|
53
|
+
} else {
|
|
54
|
+
setSuggestions(data);
|
|
55
|
+
}
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
58
|
+
console.error('Search fetch failed:', err);
|
|
59
|
+
} finally {
|
|
60
|
+
setIsLoading(false);
|
|
61
|
+
}
|
|
62
|
+
}, [tag]);
|
|
63
|
+
|
|
64
|
+
const debouncedSearch = useMemo(
|
|
65
|
+
() => debounce((query: string) => {
|
|
66
|
+
abortRef.current?.abort();
|
|
67
|
+
const controller = new AbortController();
|
|
68
|
+
abortRef.current = controller;
|
|
69
|
+
fetchResults(query, controller.signal);
|
|
70
|
+
}, 150),
|
|
71
|
+
[fetchResults]
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!open) {
|
|
76
|
+
setSearch('');
|
|
77
|
+
setResults([]);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (!search) {
|
|
81
|
+
fetchResults('');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
debouncedSearch(search);
|
|
85
|
+
return () => debouncedSearch.cancel();
|
|
86
|
+
}, [open, search, fetchResults, debouncedSearch]);
|
|
31
87
|
|
|
32
88
|
const onSelect = useCallback(
|
|
33
89
|
(url: string) => {
|
|
@@ -49,9 +105,7 @@ export function Search({ classNames }: SearchProps) {
|
|
|
49
105
|
return () => document.removeEventListener('keydown', down);
|
|
50
106
|
}, []);
|
|
51
107
|
|
|
52
|
-
const
|
|
53
|
-
query.data === 'empty' ? [] : (query.data ?? [])
|
|
54
|
-
);
|
|
108
|
+
const displayResults = deduplicateByUrl(search ? results : suggestions);
|
|
55
109
|
|
|
56
110
|
return (
|
|
57
111
|
<>
|
|
@@ -77,18 +131,17 @@ export function Search({ classNames }: SearchProps) {
|
|
|
77
131
|
/>
|
|
78
132
|
|
|
79
133
|
<Command.Content className={styles.list}>
|
|
80
|
-
{
|
|
81
|
-
{!
|
|
134
|
+
{isLoading && displayResults.length === 0 && <Command.Empty>Loading...</Command.Empty>}
|
|
135
|
+
{!isLoading &&
|
|
82
136
|
search.length > 0 &&
|
|
83
|
-
|
|
137
|
+
displayResults.length === 0 && (
|
|
84
138
|
<Command.Empty>No results found.</Command.Empty>
|
|
85
139
|
)}
|
|
86
|
-
{
|
|
87
|
-
|
|
88
|
-
results.length > 0 && (
|
|
140
|
+
{search.length === 0 &&
|
|
141
|
+
displayResults.length > 0 && (
|
|
89
142
|
<Command.Group>
|
|
90
143
|
<Command.Label>Suggestions</Command.Label>
|
|
91
|
-
{
|
|
144
|
+
{displayResults.slice(0, 8).map((result) => (
|
|
92
145
|
<Command.Item
|
|
93
146
|
key={result.id}
|
|
94
147
|
value={result.id}
|
|
@@ -108,7 +161,7 @@ export function Search({ classNames }: SearchProps) {
|
|
|
108
161
|
</Command.Group>
|
|
109
162
|
)}
|
|
110
163
|
{search.length > 0 &&
|
|
111
|
-
|
|
164
|
+
displayResults.map((result) => (
|
|
112
165
|
<Command.Item
|
|
113
166
|
key={result.id}
|
|
114
167
|
value={result.id}
|
|
@@ -149,7 +202,7 @@ export function Search({ classNames }: SearchProps) {
|
|
|
149
202
|
);
|
|
150
203
|
}
|
|
151
204
|
|
|
152
|
-
function deduplicateByUrl(results:
|
|
205
|
+
function deduplicateByUrl(results: SearchResult[]): SearchResult[] {
|
|
153
206
|
const seen = new Set<string>();
|
|
154
207
|
return results.filter(r => {
|
|
155
208
|
const base = r.url.split('#')[0];
|
|
@@ -183,7 +236,7 @@ function HighlightedText({
|
|
|
183
236
|
);
|
|
184
237
|
}
|
|
185
238
|
|
|
186
|
-
function getResultIcon(result:
|
|
239
|
+
function getResultIcon(result: SearchResult): React.ReactNode {
|
|
187
240
|
if (!result.url.startsWith('/apis/')) {
|
|
188
241
|
return result.type === 'page' ? (
|
|
189
242
|
<DocumentIcon className={styles.icon} />
|
package/src/lib/page-context.tsx
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createContext,
|
|
3
3
|
type ReactNode,
|
|
4
|
+
useCallback,
|
|
4
5
|
useContext,
|
|
5
6
|
useEffect,
|
|
7
|
+
useRef,
|
|
6
8
|
useState
|
|
7
9
|
} from 'react';
|
|
8
10
|
import { useLocation } from 'react-router';
|
|
@@ -10,7 +12,7 @@ import type { ApiSpec } from '@/lib/openapi';
|
|
|
10
12
|
import { resolveRoute, RouteType } from '@/lib/route-resolver';
|
|
11
13
|
import type { VersionContext } from '@/lib/version-source';
|
|
12
14
|
import { LATEST_CONTEXT } from '@/lib/version-source';
|
|
13
|
-
import type { ChronicleConfig, Frontmatter, Page, Root, TableOfContents } from '@/types';
|
|
15
|
+
import type { ChronicleConfig, Frontmatter, Page, PageNavLink, Root, TableOfContents } from '@/types';
|
|
14
16
|
|
|
15
17
|
export type MdxLoader = (relativePath: string) => Promise<{ content: ReactNode; toc: TableOfContents }>;
|
|
16
18
|
|
|
@@ -18,6 +20,7 @@ interface PageContextValue {
|
|
|
18
20
|
config: ChronicleConfig;
|
|
19
21
|
tree: Root;
|
|
20
22
|
page: Page | null;
|
|
23
|
+
isLoading: boolean;
|
|
21
24
|
errorStatus: number | null;
|
|
22
25
|
apiSpecs: ApiSpec[];
|
|
23
26
|
version: VersionContext;
|
|
@@ -36,6 +39,7 @@ export function usePageContext(): PageContextValue {
|
|
|
36
39
|
},
|
|
37
40
|
tree: { name: 'root', children: [] } as Root,
|
|
38
41
|
page: null,
|
|
42
|
+
isLoading: false,
|
|
39
43
|
errorStatus: null,
|
|
40
44
|
apiSpecs: [],
|
|
41
45
|
version: LATEST_CONTEXT,
|
|
@@ -82,11 +86,71 @@ export function PageProvider({
|
|
|
82
86
|
const [errorStatus, setErrorStatus] = useState<number | null>(getInitialErrorStatus(initialPage, initialConfig, pathname));
|
|
83
87
|
const [apiSpecs, setApiSpecs] = useState<ApiSpec[]>(initialApiSpecs);
|
|
84
88
|
const [version, setVersion] = useState<VersionContext>(initialVersion);
|
|
85
|
-
const [
|
|
89
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
90
|
+
const currentPathRef = useRef(pathname);
|
|
91
|
+
|
|
92
|
+
const fetchApiSpecs = useCallback(async (route: { version: VersionContext }, cancelled: { current: boolean }) => {
|
|
93
|
+
setIsLoading(true);
|
|
94
|
+
try {
|
|
95
|
+
const specsUrl = route.version.dir
|
|
96
|
+
? `/api/specs?version=${encodeURIComponent(route.version.dir)}`
|
|
97
|
+
: '/api/specs';
|
|
98
|
+
const res = await fetch(specsUrl);
|
|
99
|
+
const specs = await res.json();
|
|
100
|
+
if (!cancelled.current) setApiSpecs(specs);
|
|
101
|
+
} catch {
|
|
102
|
+
// best-effort on client nav
|
|
103
|
+
} finally {
|
|
104
|
+
setIsLoading(false);
|
|
105
|
+
}
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
interface PageData {
|
|
109
|
+
frontmatter: Frontmatter;
|
|
110
|
+
relativePath: string;
|
|
111
|
+
originalPath?: string;
|
|
112
|
+
prev?: PageNavLink | null;
|
|
113
|
+
next?: PageNavLink | null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const fetchPageData = useCallback(async (slug: string[]): Promise<PageData> => {
|
|
117
|
+
const apiPath = slug.length === 0
|
|
118
|
+
? '/api/page'
|
|
119
|
+
: `/api/page?slug=${slug.join(',')}`;
|
|
120
|
+
const res = await fetch(apiPath);
|
|
121
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
122
|
+
return res.json();
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
const loadDocsPage = useCallback(async (slug: string[], cancelled: { current: boolean }) => {
|
|
126
|
+
setIsLoading(true);
|
|
127
|
+
try {
|
|
128
|
+
const data = await fetchPageData(slug);
|
|
129
|
+
if (cancelled.current) return;
|
|
130
|
+
const { content, toc } = await loadMdx(data.originalPath || data.relativePath);
|
|
131
|
+
if (cancelled.current) return;
|
|
132
|
+
setErrorStatus(null);
|
|
133
|
+
setPage({
|
|
134
|
+
slug,
|
|
135
|
+
frontmatter: data.frontmatter,
|
|
136
|
+
content,
|
|
137
|
+
toc,
|
|
138
|
+
prev: data.prev ?? null,
|
|
139
|
+
next: data.next ?? null,
|
|
140
|
+
});
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (cancelled.current) return;
|
|
143
|
+
const status = Number((err as Error).message) || 500;
|
|
144
|
+
setPage(null);
|
|
145
|
+
setErrorStatus(status);
|
|
146
|
+
} finally {
|
|
147
|
+
if (!cancelled.current) setIsLoading(false);
|
|
148
|
+
}
|
|
149
|
+
}, [fetchPageData, loadMdx]);
|
|
86
150
|
|
|
87
151
|
useEffect(() => {
|
|
88
|
-
if (pathname ===
|
|
89
|
-
|
|
152
|
+
if (pathname === currentPathRef.current) return;
|
|
153
|
+
currentPathRef.current = pathname;
|
|
90
154
|
|
|
91
155
|
const route = resolveRoute(pathname, initialConfig);
|
|
92
156
|
if (route.type !== RouteType.Redirect) setVersion(route.version);
|
|
@@ -96,17 +160,7 @@ export function PageProvider({
|
|
|
96
160
|
if (route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage) {
|
|
97
161
|
setPage(null);
|
|
98
162
|
setErrorStatus(null);
|
|
99
|
-
|
|
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
|
-
});
|
|
163
|
+
fetchApiSpecs(route, cancelled);
|
|
110
164
|
return () => { cancelled.current = true; };
|
|
111
165
|
}
|
|
112
166
|
|
|
@@ -116,41 +170,15 @@ export function PageProvider({
|
|
|
116
170
|
return () => { cancelled.current = true; };
|
|
117
171
|
}
|
|
118
172
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
fetch(apiPath)
|
|
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;
|
|
136
|
-
const { content, toc } = await loadMdx(data.originalPath || data.relativePath);
|
|
137
|
-
if (cancelled.current) return;
|
|
138
|
-
setErrorStatus(null);
|
|
139
|
-
setPage({ slug: route.slug, frontmatter: data.frontmatter, content, toc, prev: data.prev, next: data.next });
|
|
140
|
-
})
|
|
141
|
-
.catch(() => {
|
|
142
|
-
if (!cancelled.current) {
|
|
143
|
-
setPage(null);
|
|
144
|
-
setErrorStatus(500);
|
|
145
|
-
}
|
|
146
|
-
});
|
|
147
|
-
|
|
173
|
+
setPage(null);
|
|
174
|
+
setErrorStatus(null);
|
|
175
|
+
loadDocsPage(route.slug, cancelled);
|
|
148
176
|
return () => { cancelled.current = true; };
|
|
149
|
-
}, [pathname]);
|
|
177
|
+
}, [pathname, initialConfig, fetchApiSpecs, loadDocsPage]);
|
|
150
178
|
|
|
151
179
|
return (
|
|
152
180
|
<PageContext.Provider
|
|
153
|
-
value={{ config: initialConfig, tree, page, errorStatus, apiSpecs, version }}
|
|
181
|
+
value={{ config: initialConfig, tree, page, isLoading, errorStatus, apiSpecs, version }}
|
|
154
182
|
>
|
|
155
183
|
{children}
|
|
156
184
|
</PageContext.Provider>
|
package/src/lib/source.ts
CHANGED
|
@@ -97,6 +97,8 @@ function buildSyntheticMeta(): {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
let cachedSource: ReturnType<typeof loader> | null = null;
|
|
100
|
+
let cachedTree: Root | null = null;
|
|
101
|
+
let cachedNavMap: Map<string, PageNav> | null = null;
|
|
100
102
|
|
|
101
103
|
async function getSource() {
|
|
102
104
|
if (cachedSource) return cachedSource;
|
|
@@ -112,6 +114,8 @@ export { getSource as source };
|
|
|
112
114
|
|
|
113
115
|
export function invalidate() {
|
|
114
116
|
cachedSource = null;
|
|
117
|
+
cachedTree = null;
|
|
118
|
+
cachedNavMap = null;
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
function getOrder(node: Node, orderMap: Map<string, number>): number | undefined {
|
|
@@ -146,8 +150,10 @@ function sortTreeByOrder(tree: Root, pages: { url: string; data: unknown }[]): R
|
|
|
146
150
|
}
|
|
147
151
|
|
|
148
152
|
export async function getPageTree(): Promise<Root> {
|
|
153
|
+
if (cachedTree) return cachedTree;
|
|
149
154
|
const s = await getSource();
|
|
150
|
-
|
|
155
|
+
cachedTree = sortTreeByOrder(s.pageTree as Root, s.getPages());
|
|
156
|
+
return cachedTree;
|
|
151
157
|
}
|
|
152
158
|
|
|
153
159
|
export async function getPages() {
|
|
@@ -186,12 +192,10 @@ function titleFromUrl(url: string): string {
|
|
|
186
192
|
.join(' ');
|
|
187
193
|
}
|
|
188
194
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
const i = pages.findIndex(p => p.url === url);
|
|
194
|
-
if (i < 0) return { prev: null, next: null };
|
|
195
|
+
async function getNavMap(): Promise<Map<string, PageNav>> {
|
|
196
|
+
if (cachedNavMap) return cachedNavMap;
|
|
197
|
+
const tree = await getPageTree();
|
|
198
|
+
const pages = flattenTree(tree.children);
|
|
195
199
|
const toLink = (p: (typeof pages)[number]): PageNavLink => ({
|
|
196
200
|
url: p.url,
|
|
197
201
|
title:
|
|
@@ -199,10 +203,21 @@ export async function getPageNav(slug: string[], tree?: Root): Promise<PageNav>
|
|
|
199
203
|
? p.name
|
|
200
204
|
: titleFromUrl(p.url)
|
|
201
205
|
});
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
+
const navMap = new Map<string, PageNav>();
|
|
207
|
+
for (let i = 0; i < pages.length; i++) {
|
|
208
|
+
navMap.set(pages[i].url, {
|
|
209
|
+
prev: i > 0 ? toLink(pages[i - 1]) : null,
|
|
210
|
+
next: i < pages.length - 1 ? toLink(pages[i + 1]) : null
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
cachedNavMap = navMap;
|
|
214
|
+
return cachedNavMap;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function getPageNav(slug: string[]): Promise<PageNav> {
|
|
218
|
+
const navMap = await getNavMap();
|
|
219
|
+
const url = slug.length === 0 ? '/' : `/${slug.join('/')}`;
|
|
220
|
+
return navMap.get(url) ?? { prev: null, next: null };
|
|
206
221
|
}
|
|
207
222
|
|
|
208
223
|
export function extractFrontmatter(page: { data: unknown }, fallbackTitle?: string): Frontmatter {
|
package/src/pages/DocsPage.tsx
CHANGED
|
@@ -8,13 +8,13 @@ interface DocsPageProps {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export function DocsPage({ slug }: DocsPageProps) {
|
|
11
|
-
const { config, tree, page, errorStatus } = usePageContext();
|
|
11
|
+
const { config, tree, page, isLoading, errorStatus } = usePageContext();
|
|
12
12
|
|
|
13
13
|
if (errorStatus === 404) return <NotFound />;
|
|
14
14
|
if (errorStatus) return <NotFound />;
|
|
15
|
-
|
|
15
|
+
const { Page, Skeleton } = getTheme(config.theme?.name);
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
if (isLoading || !page) return <Skeleton />;
|
|
18
18
|
const pageUrl = config.url ? `${config.url}/${slug.join('/')}` : undefined;
|
|
19
19
|
const markdownHref = `/${slug.join('/')}.md`;
|
|
20
20
|
|
|
@@ -45,7 +45,7 @@ export default {
|
|
|
45
45
|
getPageTree(),
|
|
46
46
|
route.type === RouteType.DocsPage ? getPage(route.slug) : Promise.resolve(null),
|
|
47
47
|
]);
|
|
48
|
-
const nav = page ? await getPageNav(pageSlug
|
|
48
|
+
const nav = page ? await getPageNav(pageSlug) : { prev: null, next: null };
|
|
49
49
|
|
|
50
50
|
const relativePath = page ? getRelativePath(page) : null;
|
|
51
51
|
const originalPath = page ? getOriginalPath(page) : null;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Skeleton } from '@raystack/apsara';
|
|
2
|
+
import { Flex } from '@raystack/apsara';
|
|
3
|
+
import styles from './Page.module.css';
|
|
4
|
+
|
|
5
|
+
export function PageSkeleton() {
|
|
6
|
+
return (
|
|
7
|
+
<Flex className={styles.page}>
|
|
8
|
+
<article className={styles.article}>
|
|
9
|
+
<Skeleton width="40%" height="32px" />
|
|
10
|
+
<Skeleton.Provider duration={2}>
|
|
11
|
+
<Skeleton width="100%" height="16px" />
|
|
12
|
+
<Skeleton width="95%" height="16px" />
|
|
13
|
+
<Skeleton width="80%" height="16px" />
|
|
14
|
+
<Skeleton width="100%" height="16px" />
|
|
15
|
+
<Skeleton width="60%" height="16px" />
|
|
16
|
+
</Skeleton.Provider>
|
|
17
|
+
<Skeleton width="30%" height="24px" />
|
|
18
|
+
<Skeleton.Provider duration={2}>
|
|
19
|
+
<Skeleton width="100%" height="16px" />
|
|
20
|
+
<Skeleton width="90%" height="16px" />
|
|
21
|
+
<Skeleton width="100%" height="16px" />
|
|
22
|
+
<Skeleton width="70%" height="16px" />
|
|
23
|
+
</Skeleton.Provider>
|
|
24
|
+
</article>
|
|
25
|
+
</Flex>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { Theme } from '@/types';
|
|
2
2
|
import { Layout } from './Layout';
|
|
3
3
|
import { Page } from './Page';
|
|
4
|
+
import { PageSkeleton } from './Skeleton';
|
|
4
5
|
import { Toc } from './Toc';
|
|
5
6
|
|
|
6
7
|
export const defaultTheme: Theme = {
|
|
7
8
|
Layout,
|
|
8
|
-
Page
|
|
9
|
+
Page,
|
|
10
|
+
Skeleton: PageSkeleton,
|
|
9
11
|
};
|
|
10
12
|
|
|
11
|
-
export { Layout, Page, Toc };
|
|
13
|
+
export { Layout, Page, PageSkeleton, Toc };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
.main {
|
|
2
2
|
flex: 1;
|
|
3
|
-
width:
|
|
3
|
+
width: 100%;
|
|
4
4
|
max-width: calc(1024px + var(--rs-space-17));
|
|
5
5
|
margin: 0 auto;
|
|
6
6
|
padding-top: var(--rs-space-12);
|
|
@@ -68,34 +68,12 @@
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
.breadcrumb {
|
|
71
|
-
display: flex;
|
|
72
|
-
align-items: center;
|
|
73
71
|
font-family: var(--paper-font-mono);
|
|
74
72
|
font-size: var(--rs-font-size-small);
|
|
75
73
|
line-height: var(--rs-line-height-small);
|
|
76
74
|
letter-spacing: var(--rs-letter-spacing-small);
|
|
77
75
|
}
|
|
78
76
|
|
|
79
|
-
.separator {
|
|
80
|
-
margin: 0 var(--rs-space-1);
|
|
81
|
-
color: var(--rs-color-foreground-base-tertiary);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
.crumbLink {
|
|
85
|
-
color: var(--rs-color-foreground-base-tertiary);
|
|
86
|
-
font-weight: var(--rs-font-weight-medium);
|
|
87
|
-
text-decoration: none;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
.crumbLink:hover {
|
|
91
|
-
color: var(--rs-color-foreground-base-primary);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
.crumbActive {
|
|
95
|
-
color: var(--rs-color-foreground-base-primary);
|
|
96
|
-
font-weight: var(--rs-font-weight-medium);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
77
|
.article {
|
|
100
78
|
flex: 1;
|
|
101
79
|
min-width: 0;
|
|
@@ -159,7 +137,8 @@
|
|
|
159
137
|
box-shadow:
|
|
160
138
|
0 1px 3px rgba(0, 0, 0, 0.08),
|
|
161
139
|
0 4px 12px rgba(0, 0, 0, 0.04);
|
|
162
|
-
|
|
140
|
+
min-height: calc(100vh - var(--rs-space-12));
|
|
141
|
+
margin: 0 var(--rs-space-7) var(--rs-space-9) var(--rs-space-7);
|
|
163
142
|
}
|
|
164
143
|
|
|
165
144
|
.content h1,
|
|
@@ -219,6 +198,9 @@
|
|
|
219
198
|
}
|
|
220
199
|
|
|
221
200
|
.content table {
|
|
201
|
+
display: block;
|
|
202
|
+
width: 100%;
|
|
203
|
+
overflow-x: auto;
|
|
222
204
|
margin-bottom: var(--rs-space-5);
|
|
223
205
|
}
|
|
224
206
|
|
|
@@ -236,3 +218,17 @@
|
|
|
236
218
|
padding-left: 1rem;
|
|
237
219
|
border-left: 3px solid var(--rs-color-border-base-primary);
|
|
238
220
|
}
|
|
221
|
+
|
|
222
|
+
.headerLoader {
|
|
223
|
+
align-items: center;
|
|
224
|
+
margin-bottom: var(--rs-space-5)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.loader {
|
|
228
|
+
flex: 1;
|
|
229
|
+
margin-bottom: var(--rs-space-3)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.navbarLoaderWrapper {
|
|
233
|
+
width: 30%;
|
|
234
|
+
}
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import {
|
|
2
|
-
ArrowLeftIcon,
|
|
3
|
-
ArrowRightIcon,
|
|
4
|
-
ChevronRightIcon,
|
|
5
|
-
AdjustmentsHorizontalIcon,
|
|
6
2
|
EyeIcon,
|
|
7
3
|
SunIcon,
|
|
8
4
|
MoonIcon,
|
|
9
|
-
XMarkIcon,
|
|
10
5
|
} from '@heroicons/react/24/outline';
|
|
6
|
+
import {
|
|
7
|
+
ArrowLeftIcon,
|
|
8
|
+
ArrowRightIcon,
|
|
9
|
+
MixerHorizontalIcon,
|
|
10
|
+
Cross2Icon
|
|
11
|
+
} from '@radix-ui/react-icons'
|
|
11
12
|
import { IconButton, useTheme } from '@raystack/apsara';
|
|
12
13
|
import { useEffect, useMemo, useState } from 'react';
|
|
13
14
|
import { Link as RouterLink, useLocation } from 'react-router';
|
|
14
|
-
import { getBreadcrumbItems } from 'fumadocs-core/breadcrumb';
|
|
15
15
|
import { flattenTree } from 'fumadocs-core/page-tree';
|
|
16
|
+
import { Breadcrumbs } from '@/components/ui/breadcrumbs';
|
|
16
17
|
import type { ThemePageProps } from '@/types';
|
|
17
18
|
import styles from './Page.module.css';
|
|
18
19
|
import { useReaderMode } from './ReaderModeContext';
|
|
@@ -27,21 +28,14 @@ export function Page({ page, tree }: ThemePageProps) {
|
|
|
27
28
|
|
|
28
29
|
useEffect(() => { setIsClient(true); }, []);
|
|
29
30
|
|
|
30
|
-
const
|
|
31
|
+
const slug = pathname === '/' ? [] : pathname.replace(/^\//, '').split('/');
|
|
32
|
+
|
|
33
|
+
const { prev, next } = useMemo(() => {
|
|
31
34
|
const pages = flattenTree(tree.children);
|
|
32
35
|
const currentIndex = pages.findIndex(p => p.url === pathname);
|
|
33
|
-
const breadcrumbItems = getBreadcrumbItems(
|
|
34
|
-
pathname,
|
|
35
|
-
tree,
|
|
36
|
-
{ includePage: true }
|
|
37
|
-
);
|
|
38
36
|
return {
|
|
39
37
|
prev: currentIndex > 0 ? pages[currentIndex - 1] : null,
|
|
40
38
|
next: currentIndex < pages.length - 1 ? pages[currentIndex + 1] : null,
|
|
41
|
-
crumbs: breadcrumbItems.map(item => ({
|
|
42
|
-
label: item.name,
|
|
43
|
-
href: item.url ?? pathname,
|
|
44
|
-
})),
|
|
45
39
|
};
|
|
46
40
|
}, [tree, pathname]);
|
|
47
41
|
|
|
@@ -70,20 +64,7 @@ export function Page({ page, tree }: ThemePageProps) {
|
|
|
70
64
|
</span>
|
|
71
65
|
)}
|
|
72
66
|
</div>
|
|
73
|
-
<
|
|
74
|
-
{crumbs.map((crumb, i) => (
|
|
75
|
-
<span key={crumb.href}>
|
|
76
|
-
{i > 0 && <ChevronRightIcon width={12} height={12} className={styles.separator} />}
|
|
77
|
-
{i === crumbs.length - 1 ? (
|
|
78
|
-
<span className={styles.crumbActive}>{crumb.label}</span>
|
|
79
|
-
) : (
|
|
80
|
-
<RouterLink to={crumb.href} className={styles.crumbLink}>
|
|
81
|
-
{crumb.label}
|
|
82
|
-
</RouterLink>
|
|
83
|
-
)}
|
|
84
|
-
</span>
|
|
85
|
-
))}
|
|
86
|
-
</nav>
|
|
67
|
+
<Breadcrumbs slug={slug} tree={tree} className={styles.breadcrumb} />
|
|
87
68
|
</div>
|
|
88
69
|
<div className={styles.navRight}>
|
|
89
70
|
{settingsOpen ? (
|
|
@@ -104,12 +85,12 @@ export function Page({ page, tree }: ThemePageProps) {
|
|
|
104
85
|
</IconButton>
|
|
105
86
|
)}
|
|
106
87
|
<IconButton size={2} onClick={() => setSettingsOpen(false)} aria-label='Close settings'>
|
|
107
|
-
<
|
|
88
|
+
<Cross2Icon width={14} height={14} />
|
|
108
89
|
</IconButton>
|
|
109
90
|
</>
|
|
110
91
|
) : (
|
|
111
92
|
<IconButton size={2} onClick={() => setSettingsOpen(true)} aria-label='Open settings'>
|
|
112
|
-
<
|
|
93
|
+
<MixerHorizontalIcon width={14} height={14} />
|
|
113
94
|
</IconButton>
|
|
114
95
|
)}
|
|
115
96
|
</div>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Skeleton } from '@raystack/apsara';
|
|
2
|
+
import styles from './Page.module.css';
|
|
3
|
+
import { cx } from 'class-variance-authority';
|
|
4
|
+
|
|
5
|
+
export function PageSkeleton() {
|
|
6
|
+
return (
|
|
7
|
+
<main className={styles.main}>
|
|
8
|
+
<div className={styles.navbar}>
|
|
9
|
+
<div className={cx(styles.navLeft, styles.navbarLoaderWrapper)}>
|
|
10
|
+
<Skeleton highlightColor="var(--rs-color-foreground-base-emphasis)" containerClassName={styles.loader}/>
|
|
11
|
+
</div>
|
|
12
|
+
<div className={cx(styles.navRight, styles.navbarLoaderWrapper)}>
|
|
13
|
+
<Skeleton highlightColor="var(--rs-color-foreground-base-emphasis)" containerClassName={styles.loader}/>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
<div className={styles.content}>
|
|
17
|
+
<header className={styles.articleHeader}>
|
|
18
|
+
<Skeleton width="50%" height="16px" containerClassName={styles.headerLoader}/>
|
|
19
|
+
<Skeleton width="70%" height="32px" containerClassName={styles.headerLoader}/>
|
|
20
|
+
<Skeleton width="50%" height="16px" containerClassName={styles.headerLoader}/>
|
|
21
|
+
</header>
|
|
22
|
+
<div className={styles.article}>
|
|
23
|
+
{
|
|
24
|
+
[...new Array(30)].map((_, i) => {
|
|
25
|
+
return <Skeleton key={i} width="100%" height="20px" containerClassName={styles.loader}/>
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
</main>
|
|
31
|
+
);
|
|
32
|
+
}
|