diff --git a/bun.lock b/bun.lock index d0cf6f1..8380cd4 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "chronicle", + "dependencies": { + "std-env": "^4.0.0", + }, }, "packages/chronicle": { "name": "@raystack/chronicle", @@ -1234,6 +1237,8 @@ "srvx": ["srvx@0.11.12", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-AQfrGqntqVPXgP03pvBDN1KyevHC+KmYVqb8vVf4N+aomQqdhaZxjvoVp+AOm4u6x+GgNQY3MVzAUIn+TqwkOA=="], + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + "string.prototype.codepointat": ["string.prototype.codepointat@0.2.1", "", {}, "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="], "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], diff --git a/docs/chronicle.yaml b/docs/chronicle.yaml index 5a3de82..83bb1e7 100644 --- a/docs/chronicle.yaml +++ b/docs/chronicle.yaml @@ -3,7 +3,7 @@ description: Config-driven documentation framework content: . theme: - name: paper + name: default navigation: links: diff --git a/package.json b/package.json index 6421a8f..0effbab 100644 --- a/package.json +++ b/package.json @@ -13,5 +13,8 @@ "dev:docs": "./packages/chronicle/bin/chronicle.js dev --config docs/chronicle.yaml", "start:docs": "./packages/chronicle/bin/chronicle.js start --config docs/chronicle.yaml", "build:docs": "./packages/chronicle/bin/chronicle.js build --config docs/chronicle.yaml" + }, + "dependencies": { + "std-env": "^4.0.0" } } diff --git a/packages/chronicle/src/components/ui/client-theme-switcher.tsx b/packages/chronicle/src/components/ui/client-theme-switcher.tsx index 34f9051..b504410 100644 --- a/packages/chronicle/src/components/ui/client-theme-switcher.tsx +++ b/packages/chronicle/src/components/ui/client-theme-switcher.tsx @@ -1,18 +1,35 @@ 'use client' -import { ThemeSwitcher } from '@raystack/apsara' -import { useState, useEffect } from 'react' +import { MoonIcon, SunIcon } from '@heroicons/react/24/outline' +import { IconButton, useTheme } from '@raystack/apsara' +import { useEffect, useState } from 'react' interface ClientThemeSwitcherProps { size?: number } -export function ClientThemeSwitcher({ size }: ClientThemeSwitcherProps) { +export function ClientThemeSwitcher({ size = 16 }: ClientThemeSwitcherProps) { const [isClient, setIsClient] = useState(false) + const { resolvedTheme, setTheme } = useTheme() useEffect(() => { setIsClient(true) }, []) - return isClient ? : null + if (!isClient) return null + + const isDark = resolvedTheme === 'dark' + return ( + setTheme(isDark ? 'light' : 'dark')} + > + {isDark ? ( + + ) : ( + + )} + + ) } diff --git a/packages/chronicle/src/components/ui/search.module.css b/packages/chronicle/src/components/ui/search.module.css index 086e7f4..6a6b4bf 100644 --- a/packages/chronicle/src/components/ui/search.module.css +++ b/packages/chronicle/src/components/ui/search.module.css @@ -1,16 +1,3 @@ -.trigger { - gap: 8px; - color: var(--rs-color-foreground-base-secondary); - cursor: pointer; -} - -.kbd { - padding: 2px 6px; - border-radius: 4px; - border: 1px solid var(--rs-color-border-base-primary); - font-size: 12px; -} - .dialogContent { max-width: 600px; padding: 0; diff --git a/packages/chronicle/src/components/ui/search.tsx b/packages/chronicle/src/components/ui/search.tsx index 293893d..086d61f 100644 --- a/packages/chronicle/src/components/ui/search.tsx +++ b/packages/chronicle/src/components/ui/search.tsx @@ -1,6 +1,9 @@ -import { DocumentIcon, HashtagIcon } from '@heroicons/react/24/outline'; -import { Button, Command, Dialog, Text } from '@raystack/apsara'; -import { cx } from 'class-variance-authority'; +import { + DocumentIcon, + HashtagIcon, + MagnifyingGlassIcon +} from '@heroicons/react/24/outline'; +import { Command, Dialog, IconButton, Text } from '@raystack/apsara'; import type { SortedResult } from 'fumadocs-core/search'; import { useDocsSearch } from 'fumadocs-core/search/client'; import { useCallback, useEffect, useState } from 'react'; @@ -8,21 +11,6 @@ import { useNavigate } from 'react-router'; import { MethodBadge } from '@/components/api/method-badge'; import styles from './search.module.css'; -function SearchShortcutKey({ className }: { className?: string }) { - const [key, setKey] = useState('⌘'); - - useEffect(() => { - const isMac = navigator.platform?.toUpperCase().includes('MAC'); - setKey(isMac ? '⌘' : 'Ctrl'); - }, []); - - return ( - - {key} K - - ); -} - interface SearchProps { className?: string; } @@ -64,16 +52,15 @@ export function Search({ className }: SearchProps) { return ( <> - + + diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx index a421459..05ec253 100644 --- a/packages/chronicle/src/lib/page-context.tsx +++ b/packages/chronicle/src/lib/page-context.tsx @@ -7,21 +7,14 @@ import { } from 'react'; import { useLocation } from 'react-router'; import type { ApiSpec } from '@/lib/openapi'; -import type { ChronicleConfig, Frontmatter, Root, TableOfContents } from '@/types'; +import type { ChronicleConfig, Frontmatter, Page, Root, TableOfContents } from '@/types'; export type MdxLoader = (relativePath: string) => Promise<{ content: ReactNode; toc: TableOfContents }>; -interface PageData { - slug: string[]; - frontmatter: Frontmatter; - content: ReactNode; - toc: TableOfContents; -} - interface PageContextValue { config: ChronicleConfig; tree: Root; - page: PageData | null; + page: Page | null; errorStatus: number | null; apiSpecs: ApiSpec[]; } @@ -46,7 +39,7 @@ export function usePageContext(): PageContextValue { interface PageProviderProps { initialConfig: ChronicleConfig; initialTree: Root; - initialPage: PageData | null; + initialPage: Page | null; initialApiSpecs: ApiSpec[]; loadMdx: MdxLoader; children: ReactNode; @@ -56,7 +49,7 @@ function isApisRoute(pathname: string): boolean { return pathname === '/apis' || pathname.startsWith('/apis/'); } -function getInitialErrorStatus(page: PageData | null, pathname: string): number | null { +function getInitialErrorStatus(page: Page | null, pathname: string): number | null { if (page) return null; if (pathname === '/' || isApisRoute(pathname)) return null; return 404; @@ -72,7 +65,7 @@ export function PageProvider({ }: PageProviderProps) { const { pathname } = useLocation(); const [tree] = useState(initialTree); - const [page, setPage] = useState(initialPage); + const [page, setPage] = useState(initialPage); const [errorStatus, setErrorStatus] = useState(getInitialErrorStatus(initialPage, pathname)); const [apiSpecs, setApiSpecs] = useState(initialApiSpecs); const [currentPath, setCurrentPath] = useState(pathname); @@ -112,12 +105,19 @@ export function PageProvider({ } return res.json(); }) - .then(async (data: { frontmatter: Frontmatter; relativePath: string; originalPath?: string } | undefined) => { + .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, frontmatter: data.frontmatter, content, toc }); + setPage({ + slug, + frontmatter: data.frontmatter, + content, + toc, + prev: data.prev ?? null, + next: data.next ?? null + }); }) .catch(() => { if (!cancelled.current) { diff --git a/packages/chronicle/src/lib/source.ts b/packages/chronicle/src/lib/source.ts index 0d9e155..aa4da0a 100644 --- a/packages/chronicle/src/lib/source.ts +++ b/packages/chronicle/src/lib/source.ts @@ -1,8 +1,9 @@ import { loader } from 'fumadocs-core/source'; +import { flattenTree } from 'fumadocs-core/page-tree'; import type { Root, Node, Folder } from 'fumadocs-core/page-tree'; import type { MDXContent } from 'mdx/types'; import type { TableOfContents } from 'fumadocs-core/toc'; -import type { Frontmatter } from '@/types'; +import type { Frontmatter, PageNav, PageNavLink } from '@/types'; const CONTENT_PREFIX = '../../.content/'; @@ -105,6 +106,22 @@ export async function getPage(slugs?: string[]) { return s.getPage(slugs); } +export async function getPageNav(slug: string[]): Promise { + const tree = await getPageTree(); + const pages = flattenTree(tree.children); + const url = slug.length === 0 ? '/' : `/${slug.join('/')}`; + const i = pages.findIndex(p => p.url === url); + if (i < 0) return { prev: null, next: null }; + const toLink = (p: (typeof pages)[number]): PageNavLink => ({ + url: p.url, + title: typeof p.name === 'string' ? p.name : '' + }); + return { + prev: i > 0 ? toLink(pages[i - 1]) : null, + next: i < pages.length - 1 ? toLink(pages[i + 1]) : null + }; +} + export function extractFrontmatter(page: { data: unknown }, fallbackTitle?: string): Frontmatter { const d = page.data as Record; return { diff --git a/packages/chronicle/src/pages/DocsPage.tsx b/packages/chronicle/src/pages/DocsPage.tsx index e1710b4..35ce71f 100644 --- a/packages/chronicle/src/pages/DocsPage.tsx +++ b/packages/chronicle/src/pages/DocsPage.tsx @@ -32,12 +32,7 @@ export function DocsPage({ slug }: DocsPageProps) { }} /> diff --git a/packages/chronicle/src/server/api/page.ts b/packages/chronicle/src/server/api/page.ts index bfb43aa..568d6aa 100644 --- a/packages/chronicle/src/server/api/page.ts +++ b/packages/chronicle/src/server/api/page.ts @@ -1,5 +1,5 @@ import { defineHandler, HTTPError } from 'nitro'; -import { getPage, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source'; +import { getPage, getPageNav, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source'; export default defineHandler(async event => { const slugParam = event.url.searchParams.get('slug') ?? ''; @@ -10,9 +10,13 @@ export default defineHandler(async event => { throw new HTTPError({ status: 404, message: 'Page not found' }); } + const nav = await getPageNav(slug); + return { frontmatter: extractFrontmatter(page, slug[slug.length - 1]), relativePath: getRelativePath(page), originalPath: getOriginalPath(page), + prev: nav.prev, + next: nav.next, }; }); diff --git a/packages/chronicle/src/server/entry-client.tsx b/packages/chronicle/src/server/entry-client.tsx index f972f85..f5a9032 100644 --- a/packages/chronicle/src/server/entry-client.tsx +++ b/packages/chronicle/src/server/entry-client.tsx @@ -5,7 +5,7 @@ import { BrowserRouter } from 'react-router'; import { ReactRouterProvider } from 'fumadocs-core/framework/react-router'; import { mdxComponents } from '@/components/mdx'; import { PageProvider } from '@/lib/page-context'; -import type { ChronicleConfig, Frontmatter, Root, TableOfContents } from '@/types'; +import type { ChronicleConfig, Frontmatter, PageNavLink, Root, TableOfContents } from '@/types'; import type { ApiSpec } from '@/lib/openapi'; import type { ReactNode } from 'react'; import { App } from './App'; @@ -17,6 +17,8 @@ interface EmbeddedData { frontmatter: Frontmatter; relativePath: string; originalPath?: string; + prev: PageNavLink | null; + next: PageNavLink | null; } const contentModules = import.meta.glob<{ default?: React.ComponentType; toc?: TableOfContents }>( @@ -60,6 +62,8 @@ async function hydrate() { ? { slug: embedded!.slug, frontmatter: embedded!.frontmatter, + prev: embedded!.prev, + next: embedded!.next, ...(await loadMdxModule(mdxPath)), } : null; diff --git a/packages/chronicle/src/server/entry-server.tsx b/packages/chronicle/src/server/entry-server.tsx index fe23e1a..2d4536c 100644 --- a/packages/chronicle/src/server/entry-server.tsx +++ b/packages/chronicle/src/server/entry-server.tsx @@ -8,7 +8,7 @@ import { mdxComponents } from '@/components/mdx'; import { loadConfig } from '@/lib/config'; import { loadApiSpecs } from '@/lib/openapi'; import { PageProvider } from '@/lib/page-context'; -import { getPageTree, getPage, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source'; +import { getPageTree, getPage, getPageNav, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source'; import { useNitroApp } from 'nitro/app'; import { App } from './App'; @@ -26,9 +26,10 @@ export default { ? await loadApiSpecs(config.api).catch(() => []) : []; - const [tree, page] = await Promise.all([ + const [tree, page, nav] = await Promise.all([ getPageTree(), getPage(slug), + getPageNav(slug), ]); const relativePath = page ? getRelativePath(page) : null; @@ -43,6 +44,8 @@ export default { ? React.createElement(mdxModule.default, { components: mdxComponents }) : null, toc: mdxModule?.toc ?? [], + prev: nav.prev, + next: nav.next, } : null; @@ -53,6 +56,8 @@ export default { frontmatter: pageData?.frontmatter ?? null, relativePath, originalPath, + prev: pageData?.prev ?? null, + next: pageData?.next ?? null, }; const safeJson = JSON.stringify(embeddedData).replace(/ .sidebarList { - margin-top: var(--rs-space-2); - padding-left: var(--rs-space-4); +.mainArea { + flex: 1; + min-width: 0; +} + +.tabBar { + display: flex; + align-items: center; + height: 48px; + padding: 0 var(--rs-space-2); + background: var(--rs-color-background-base-secondary); +} + +.tabs { + display: flex; + align-items: center; + gap: var(--rs-space-3); } -.navButton { +.tab { display: flex; align-items: center; - height: 32px; - padding: 0 var(--rs-space-4); - border: 1px solid var(--rs-color-border-base-primary); + gap: var(--rs-space-2); + padding: var(--rs-space-2) var(--rs-space-3); + border: 0.5px solid var(--rs-color-border-base-primary); border-radius: var(--rs-radius-2); - font-size: var(--rs-font-size-small); + font-size: var(--rs-font-size-mini); font-weight: var(--rs-font-weight-medium); - color: var(--rs-color-foreground-base-primary); + letter-spacing: var(--rs-letter-spacing-mini); + line-height: var(--rs-line-height-mini); + color: var(--rs-color-foreground-base-secondary); text-decoration: none; + white-space: nowrap; + cursor: pointer; } -.navButton:hover { +.tab:hover { + color: var(--rs-color-foreground-base-primary); background: var(--rs-color-background-base-primary-hover); } +.tabActive { + background: var(--rs-color-background-neutral-primary); + border-color: var(--rs-color-border-base-secondary); + color: var(--rs-color-foreground-base-primary); +} + +.cardWrapper { + flex: 1; + display: flex; + padding: 0 var(--rs-space-5) var(--rs-space-2) var(--rs-space-2); + min-height: 0; + background: var(--rs-color-background-base-secondary); +} + +.card { + flex: 1; + display: flex; + flex-direction: column; + border: 0.5px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-4); + box-shadow: var(--rs-shadow-soft); + overflow: clip; +} + +.subNav { + display: flex; + align-items: center; + justify-content: space-between; + height: 48px; + padding: var(--rs-space-4) var(--rs-space-7); + background: var(--rs-color-background-base-primary); + border-bottom: 0.5px solid var(--rs-color-border-base-primary); + backdrop-filter: blur(1px); +} + +.subNavLeft { + min-width: 0; +} + +.content { + flex: 1; + padding: var(--rs-space-9) var(--rs-space-7); + background: var(--rs-color-background-base-primary); +} + .groupItems { padding-left: var(--rs-space-4); } diff --git a/packages/chronicle/src/themes/default/Layout.tsx b/packages/chronicle/src/themes/default/Layout.tsx index 625784d..a32a6f2 100644 --- a/packages/chronicle/src/themes/default/Layout.tsx +++ b/packages/chronicle/src/themes/default/Layout.tsx @@ -1,19 +1,21 @@ -import { RectangleStackIcon } from '@heroicons/react/24/outline'; import { - Button, - Flex, - Headline, - Link, - Navbar, - Sidebar -} from '@raystack/apsara'; + ArrowLeftIcon, + ArrowRightIcon, + CodeBracketSquareIcon, + DocumentTextIcon, + RectangleStackIcon, + Squares2X2Icon +} from '@heroicons/react/24/outline'; +import { Flex, IconButton, Sidebar } from '@raystack/apsara'; import { cx } from 'class-variance-authority'; -import { useEffect, useRef } from 'react'; -import { Link as RouterLink, useLocation } from 'react-router'; +import { useEffect, useMemo, useRef } from 'react'; +import { Link as RouterLink, useLocation, useNavigate } from 'react-router'; import { MethodBadge } from '@/components/api/method-badge'; +import { Breadcrumbs } from '@/components/ui/breadcrumbs'; import { ClientThemeSwitcher } from '@/components/ui/client-theme-switcher'; import { Footer } from '@/components/ui/footer'; import { Search } from '@/components/ui/search'; +import { usePageContext } from '@/lib/page-context'; import type { Node } from 'fumadocs-core/page-tree'; import type { ThemeLayoutProps } from '@/types'; import styles from './Layout.module.css'; @@ -36,7 +38,18 @@ export function Layout({ classNames }: ThemeLayoutProps) { const { pathname } = useLocation(); + const navigate = useNavigate(); + const { page } = usePageContext(); const scrollRef = useRef(null); + const isApiRoute = pathname.startsWith('/apis'); + const isApiBase = (basePath: string) => + pathname === basePath || pathname.startsWith(`${basePath}/`); + const { prev, next } = page ?? { prev: null, next: null }; + + const slug = useMemo( + () => (pathname === '/' ? [] : pathname.split('/').filter(Boolean)), + [pathname] + ); useEffect(() => { const el = scrollRef.current; @@ -58,45 +71,24 @@ export function Layout({ return ( - - - - - {config.title} - - - - - - {config.api?.map(api => ( - - {api.name} API - - ))} - {config.navigation?.links?.map(link => ( - - {link.label} - - ))} - {config.search?.enabled && } - - - - - +
+ +
+ {config.search?.enabled && } + +
+
+ {tree.children.map((item, i) => (
-
- {children} -
+ + +
+
+ +
+ {children} +
+
+
+