@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raystack/chronicle",
3
- "version": "0.7.2",
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 type { SortedResult } from 'fumadocs-core/search';
8
- import { useDocsSearch } from 'fumadocs-core/search/client';
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 { search, setSearch, query } = useDocsSearch({
25
- type: 'fetch',
26
- api: '/api/search',
27
- tag: version.dir ?? undefined,
28
- delayMs: 100,
29
- allowEmpty: true
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 results = deduplicateByUrl(
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
- {query.isLoading && <Command.Empty>Loading...</Command.Empty>}
81
- {!query.isLoading &&
134
+ {isLoading && displayResults.length === 0 && <Command.Empty>Loading...</Command.Empty>}
135
+ {!isLoading &&
82
136
  search.length > 0 &&
83
- results.length === 0 && (
137
+ displayResults.length === 0 && (
84
138
  <Command.Empty>No results found.</Command.Empty>
85
139
  )}
86
- {!query.isLoading &&
87
- search.length === 0 &&
88
- results.length > 0 && (
140
+ {search.length === 0 &&
141
+ displayResults.length > 0 && (
89
142
  <Command.Group>
90
143
  <Command.Label>Suggestions</Command.Label>
91
- {results.slice(0, 8).map((result: SortedResult) => (
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
- results.map((result: SortedResult) => (
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: SortedResult[]): SortedResult[] {
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: SortedResult): React.ReactNode {
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} />
@@ -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 [currentPath, setCurrentPath] = useState(pathname);
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 === currentPath) return;
89
- setCurrentPath(pathname);
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
- 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
- });
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
- const apiPath = route.slug.length === 0
120
- ? '/api/page'
121
- : `/api/page?slug=${route.slug.join(',')}`;
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
- return sortTreeByOrder(s.pageTree as Root, s.getPages());
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
- export async function getPageNav(slug: string[], tree?: Root): Promise<PageNav> {
190
- const resolvedTree = tree ?? (await getPageTree());
191
- const pages = flattenTree(resolvedTree.children);
192
- const url = slug.length === 0 ? '/' : `/${slug.join('/')}`;
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
- return {
203
- prev: i > 0 ? toLink(pages[i - 1]) : null,
204
- next: i < pages.length - 1 ? toLink(pages[i + 1]) : null
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 {
@@ -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
- if (!page) return null;
15
+ const { Page, Skeleton } = getTheme(config.theme?.name);
16
16
 
17
- const { Page } = getTheme(config.theme?.name);
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, tree) : { prev: null, next: null };
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 };
@@ -65,6 +65,10 @@
65
65
  flex-shrink: 0;
66
66
  }
67
67
 
68
+ .subFolder {
69
+ margin-top: var(--rs-space-5);
70
+ }
71
+
68
72
  .subLabel {
69
73
  font-family: var(--paper-font-mono);
70
74
  font-size: var(--rs-font-size-small);
@@ -67,7 +67,7 @@ function ChapterItem({
67
67
 
68
68
  if (item.type === 'folder') {
69
69
  return (
70
- <li>
70
+ <li className={styles.subFolder}>
71
71
  <span className={styles.subLabel}>{item.name}</span>
72
72
  <ul className={styles.chapterItems}>
73
73
  {item.children.map(child => (
@@ -1,6 +1,6 @@
1
1
  .main {
2
2
  flex: 1;
3
- width: 90%;
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
- margin-bottom: var(--rs-space-9);
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 { prev, next, crumbs } = useMemo(() => {
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
- <nav className={styles.breadcrumb}>
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
- <XMarkIcon width={14} height={14} />
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
- <AdjustmentsHorizontalIcon width={14} height={14} />
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
+ }
@@ -1,8 +1,10 @@
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
 
5
6
  export const paperTheme: Theme = {
6
7
  Layout,
7
- Page
8
+ Page,
9
+ Skeleton: PageSkeleton,
8
10
  };
@@ -20,5 +20,6 @@ export interface ThemePageProps {
20
20
  export interface Theme {
21
21
  Layout: React.ComponentType<ThemeLayoutProps>
22
22
  Page: React.ComponentType<ThemePageProps>
23
+ Skeleton: React.ComponentType
23
24
  className?: string
24
25
  }