Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 74 additions & 46 deletions packages/chronicle/src/lib/page-context.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState
} from 'react';
import { useLocation } from 'react-router';
import type { ApiSpec } from '@/lib/openapi';
import { resolveRoute, RouteType } from '@/lib/route-resolver';
import type { VersionContext } from '@/lib/version-source';
import { LATEST_CONTEXT } from '@/lib/version-source';
import type { ChronicleConfig, Frontmatter, Page, Root, TableOfContents } from '@/types';
import type { ChronicleConfig, Frontmatter, Page, PageNavLink, Root, TableOfContents } from '@/types';

export type MdxLoader = (relativePath: string) => Promise<{ content: ReactNode; toc: TableOfContents }>;

interface PageContextValue {
config: ChronicleConfig;
tree: Root;
page: Page | null;
isLoading: boolean;
errorStatus: number | null;
apiSpecs: ApiSpec[];
version: VersionContext;
Expand All @@ -36,6 +39,7 @@ export function usePageContext(): PageContextValue {
},
tree: { name: 'root', children: [] } as Root,
page: null,
isLoading: false,
errorStatus: null,
apiSpecs: [],
version: LATEST_CONTEXT,
Expand Down Expand Up @@ -82,11 +86,71 @@ export function PageProvider({
const [errorStatus, setErrorStatus] = useState<number | null>(getInitialErrorStatus(initialPage, initialConfig, pathname));
const [apiSpecs, setApiSpecs] = useState<ApiSpec[]>(initialApiSpecs);
const [version, setVersion] = useState<VersionContext>(initialVersion);
const [currentPath, setCurrentPath] = useState(pathname);
const [isLoading, setIsLoading] = useState(false);
const currentPathRef = useRef(pathname);

const fetchApiSpecs = useCallback(async (route: { version: VersionContext }, cancelled: { current: boolean }) => {
setIsLoading(true);
try {
const specsUrl = route.version.dir
? `/api/specs?version=${encodeURIComponent(route.version.dir)}`
: '/api/specs';
const res = await fetch(specsUrl);
const specs = await res.json();
if (!cancelled.current) setApiSpecs(specs);
} catch {
// best-effort on client nav
} finally {
setIsLoading(false);
}
}, []);

interface PageData {
frontmatter: Frontmatter;
relativePath: string;
originalPath?: string;
prev?: PageNavLink | null;
next?: PageNavLink | null;
}

const fetchPageData = useCallback(async (slug: string[]): Promise<PageData> => {
const apiPath = slug.length === 0
? '/api/page'
: `/api/page?slug=${slug.join(',')}`;
const res = await fetch(apiPath);
if (!res.ok) throw new Error(String(res.status));
return res.json();
}, []);

const loadDocsPage = useCallback(async (slug: string[], cancelled: { current: boolean }) => {
setIsLoading(true);
try {
const data = await fetchPageData(slug);
if (cancelled.current) return;
const { content, toc } = await loadMdx(data.originalPath || data.relativePath);
if (cancelled.current) return;
setErrorStatus(null);
setPage({
slug,
frontmatter: data.frontmatter,
content,
toc,
prev: data.prev ?? null,
next: data.next ?? null,
});
} catch (err) {
if (cancelled.current) return;
const status = Number((err as Error).message) || 500;
setPage(null);
setErrorStatus(status);
} finally {
if (!cancelled.current) setIsLoading(false);
}
}, [fetchPageData, loadMdx]);

useEffect(() => {
if (pathname === currentPath) return;
setCurrentPath(pathname);
if (pathname === currentPathRef.current) return;
currentPathRef.current = pathname;

const route = resolveRoute(pathname, initialConfig);
if (route.type !== RouteType.Redirect) setVersion(route.version);
Expand All @@ -96,17 +160,7 @@ export function PageProvider({
if (route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage) {
setPage(null);
setErrorStatus(null);
const specsUrl = route.version.dir
? `/api/specs?version=${encodeURIComponent(route.version.dir)}`
: '/api/specs';
fetch(specsUrl)
.then(res => res.json())
.then(specs => {
if (!cancelled.current) setApiSpecs(specs);
})
.catch(() => {
// swallow — api specs are best-effort on client nav
});
fetchApiSpecs(route, cancelled);
return () => { cancelled.current = true; };
}

Expand All @@ -116,41 +170,15 @@ export function PageProvider({
return () => { cancelled.current = true; };
}

const apiPath = route.slug.length === 0
? '/api/page'
: `/api/page?slug=${route.slug.join(',')}`;

fetch(apiPath)
.then(res => {
if (!res.ok) {
if (!cancelled.current) {
setPage(null);
setErrorStatus(res.status);
}
return;
}
return res.json();
})
.then(async (data: { frontmatter: Frontmatter; relativePath: string; originalPath?: string; prev?: Page['prev']; next?: Page['next'] } | undefined) => {
if (cancelled.current || !data) return;
const { content, toc } = await loadMdx(data.originalPath || data.relativePath);
if (cancelled.current) return;
setErrorStatus(null);
setPage({ slug: route.slug, frontmatter: data.frontmatter, content, toc, prev: data.prev, next: data.next });
})
.catch(() => {
if (!cancelled.current) {
setPage(null);
setErrorStatus(500);
}
});

setPage(null);
setErrorStatus(null);
loadDocsPage(route.slug, cancelled);
return () => { cancelled.current = true; };
}, [pathname]);
}, [pathname, initialConfig, fetchApiSpecs, loadDocsPage]);

return (
<PageContext.Provider
value={{ config: initialConfig, tree, page, errorStatus, apiSpecs, version }}
value={{ config: initialConfig, tree, page, isLoading, errorStatus, apiSpecs, version }}
>
{children}
</PageContext.Provider>
Expand Down
6 changes: 3 additions & 3 deletions packages/chronicle/src/pages/DocsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ interface DocsPageProps {
}

export function DocsPage({ slug }: DocsPageProps) {
const { config, tree, page, errorStatus } = usePageContext();
const { config, tree, page, isLoading, errorStatus } = usePageContext();

if (errorStatus === 404) return <NotFound />;
if (errorStatus) return <NotFound />;
if (!page) return null;
const { Page, Skeleton } = getTheme(config.theme?.name);

const { Page } = getTheme(config.theme?.name);
if (isLoading || !page) return <Skeleton />;
const pageUrl = config.url ? `${config.url}/${slug.join('/')}` : undefined;
const markdownHref = `/${slug.join('/')}.md`;

Expand Down
27 changes: 27 additions & 0 deletions packages/chronicle/src/themes/default/Skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Skeleton } from '@raystack/apsara';
import { Flex } from '@raystack/apsara';
import styles from './Page.module.css';

export function PageSkeleton() {
return (
<Flex className={styles.page}>
<article className={styles.article}>
<Skeleton width="40%" height="32px" />
<Skeleton.Provider duration={2}>
<Skeleton width="100%" height="16px" />
<Skeleton width="95%" height="16px" />
<Skeleton width="80%" height="16px" />
<Skeleton width="100%" height="16px" />
<Skeleton width="60%" height="16px" />
</Skeleton.Provider>
<Skeleton width="30%" height="24px" />
<Skeleton.Provider duration={2}>
<Skeleton width="100%" height="16px" />
<Skeleton width="90%" height="16px" />
<Skeleton width="100%" height="16px" />
<Skeleton width="70%" height="16px" />
</Skeleton.Provider>
</article>
</Flex>
);
}
6 changes: 4 additions & 2 deletions packages/chronicle/src/themes/default/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { Theme } from '@/types';
import { Layout } from './Layout';
import { Page } from './Page';
import { PageSkeleton } from './Skeleton';
import { Toc } from './Toc';

export const defaultTheme: Theme = {
Layout,
Page
Page,
Skeleton: PageSkeleton,
};

export { Layout, Page, Toc };
export { Layout, Page, PageSkeleton, Toc };
10 changes: 10 additions & 0 deletions packages/chronicle/src/themes/paper/Page.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
0 1px 3px rgba(0, 0, 0, 0.08),
0 4px 12px rgba(0, 0, 0, 0.04);
margin-bottom: var(--rs-space-9);
min-height: calc(100vh - var(--rs-space-12));
}

.content h1,
Expand Down Expand Up @@ -236,3 +237,12 @@
padding-left: 1rem;
border-left: 3px solid var(--rs-color-border-base-primary);
}

.headerLoader {
align-items: center;
margin-bottom: var(--rs-space-5)
}
Comment thread
rsbh marked this conversation as resolved.

.loader {
margin-bottom: var(--rs-space-3)
}
23 changes: 23 additions & 0 deletions packages/chronicle/src/themes/paper/Skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Skeleton } from '@raystack/apsara';
import styles from './Page.module.css';

export function PageSkeleton() {
return (
<main className={styles.main}>
<div className={styles.content}>
<header className={styles.articleHeader}>
<Skeleton width="50%" height="16px" containerClassName={styles.headerLoader}/>
<Skeleton width="70%" height="32px" containerClassName={styles.headerLoader}/>
<Skeleton width="50%" height="16px" containerClassName={styles.headerLoader}/>
</header>
<div className={styles.article}>
{
[...new Array(30)].map((_, i) => {
return <Skeleton key={i} width="100%" height="20px" containerClassName={styles.loader}/>
})
}
</div>
</div>
</main>
);
}
4 changes: 3 additions & 1 deletion packages/chronicle/src/themes/paper/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { Theme } from '@/types';
import { Layout } from './Layout';
import { Page } from './Page';
import { PageSkeleton } from './Skeleton';

export const paperTheme: Theme = {
Layout,
Page
Page,
Skeleton: PageSkeleton,
};
1 change: 1 addition & 0 deletions packages/chronicle/src/types/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ export interface ThemePageProps {
export interface Theme {
Layout: React.ComponentType<ThemeLayoutProps>
Page: React.ComponentType<ThemePageProps>
Skeleton: React.ComponentType
Comment thread
rsbh marked this conversation as resolved.
className?: string
}
Loading