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 ( <> - setOpen(true)} - className={cx(styles.trigger, className)} - trailingIcon={} + className={className} > - Search... - + + 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} - + + + + + + Docs + + {config.api?.map(api => ( + + + {api.name} API + + ))} + + + + + + + + prev && navigate(prev.url)} + aria-label='Previous page' + > + + + next && navigate(next.url)} + aria-label='Next page' + > + + + + {!isApiRoute && } + + + + {children} + + + + diff --git a/packages/chronicle/src/themes/default/Page.module.css b/packages/chronicle/src/themes/default/Page.module.css index 40cb3f0..b019638 100644 --- a/packages/chronicle/src/themes/default/Page.module.css +++ b/packages/chronicle/src/themes/default/Page.module.css @@ -26,6 +26,36 @@ line-height: 1.4; } +.content > :is(h1, h2, h3, h4, h5, h6):first-child { + margin-top: 0; +} + +.content h1 { + margin-top: 0; + margin-bottom: var(--rs-space-10); +} + +.content p { + color: var(--rs-color-foreground-base-primary); + font-family: var(--rs-font-body); + font-size: var(--rs-font-size-regular); + font-style: normal; + font-weight: var(--rs-font-weight-regular); + line-height: 171.429%; +} + +.content h2 { + margin-top: var(--rs-space-8); + margin-bottom: var(--rs-space-8); + color: var(--rs-color-foreground-base-primary); + font-family: var(--rs-font-title); + font-size: var(--rs-font-size-t3); + font-style: normal; + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-t3); + letter-spacing: var(--rs-letter-spacing-t3); +} + .content ul, .content ol { padding-left: var(--rs-space-5); diff --git a/packages/chronicle/src/themes/default/Page.tsx b/packages/chronicle/src/themes/default/Page.tsx index 247acf9..194cd67 100644 --- a/packages/chronicle/src/themes/default/Page.tsx +++ b/packages/chronicle/src/themes/default/Page.tsx @@ -1,16 +1,14 @@ 'use client'; import { Flex } from '@raystack/apsara'; -import { Breadcrumbs } from '@/components/ui/breadcrumbs'; import type { ThemePageProps } from '@/types'; import styles from './Page.module.css'; import { Toc } from './Toc'; -export function Page({ page, tree }: ThemePageProps) { +export function Page({ page }: ThemePageProps) { return ( - {page.content} diff --git a/packages/chronicle/src/themes/default/Toc.module.css b/packages/chronicle/src/themes/default/Toc.module.css index 16b8f24..48617ad 100644 --- a/packages/chronicle/src/themes/default/Toc.module.css +++ b/packages/chronicle/src/themes/default/Toc.module.css @@ -1,48 +1,117 @@ .toc { - width: 200px; - flex-shrink: 0; - position: sticky; - top: var(--rs-space-9); - max-height: calc(100vh - var(--rs-space-17)); - overflow-y: auto; + position: fixed; + right: calc(var(--rs-space-5) + var(--rs-space-3)); + top: 50%; + transform: translateY(-50%); + z-index: 10; } -.title { +.markers { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--rs-space-4); + opacity: 1; + transition: opacity 150ms ease; +} + +.toc:hover .markers { + opacity: 0; + pointer-events: none; +} + +.marker { display: block; + height: 2px; + background: var(--rs-color-border-base-secondary); + border-radius: 1px; + transition: + width 0.15s ease, + background 0.15s ease; +} + +.marker:hover { + background: var(--rs-color-foreground-base-secondary); +} + +.markerActive { + background: var(--rs-color-border-base-emphasis); +} + + +.panel { + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%) translateX(8px); + display: flex; + flex-direction: column; + gap: var(--rs-space-2); + min-width: 200px; + padding: var(--rs-space-3) 0; + background: var(--rs-color-background-base-primary); + border: 0.5px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-4); + box-shadow: var(--rs-shadow-soft); + opacity: 0; + pointer-events: none; + transition: + opacity 150ms ease, + transform 150ms ease; +} + +.toc:hover .panel { + opacity: 1; + pointer-events: auto; + transform: translateY(-50%) translateX(0); +} + +.panelHeader { + display: flex; + align-items: center; + gap: var(--rs-space-3); + padding: var(--rs-space-3); color: var(--rs-color-foreground-base-secondary); - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: var(--rs-space-3); - font-size: var(--rs-font-size-mini); + font-family: var(--rs-font-body); + font-size: var(--rs-font-size-small); + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); } -.nav { +.panelHeaderLabel { + flex: 1; +} + +.panelList { display: flex; flex-direction: column; - gap: 0; - border-left: 1px solid var(--rs-color-border-base-primary); - padding-left: var(--rs-space-3); - margin-bottom: var(--rs-space-6); + gap: var(--rs-space-1); + padding-left: var(--rs-space-8); } -.link { +.panelItem { + display: block; + padding: var(--rs-space-3); + border-radius: var(--rs-radius-2); color: var(--rs-color-foreground-base-tertiary); - text-decoration: none; + font-family: var(--rs-font-body); font-size: var(--rs-font-size-small); - line-height: 1.4; - padding: var(--rs-space-1) 0; + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + text-decoration: none; transition: color 0.15s ease; } -.link:hover { +.panelItem:hover { color: var(--rs-color-foreground-base-primary); } -.active { - color: var(--rs-color-foreground-base-primary); - font-weight: 500; +.panelItemNested { + padding-left: var(--rs-space-5); } -.nested { - padding-left: var(--rs-space-3); +.panelItemActive { + color: var(--rs-color-foreground-base-primary); } diff --git a/packages/chronicle/src/themes/default/Toc.tsx b/packages/chronicle/src/themes/default/Toc.tsx index 455befc..4a68a80 100644 --- a/packages/chronicle/src/themes/default/Toc.tsx +++ b/packages/chronicle/src/themes/default/Toc.tsx @@ -1,10 +1,23 @@ 'use client'; -import { Text } from '@raystack/apsara'; +import { Bars3BottomLeftIcon } from '@heroicons/react/24/outline'; import { AnchorProvider, useActiveAnchor } from 'fumadocs-core/toc'; import type { TableOfContents, TOCItemType } from 'fumadocs-core/toc'; +import { cx } from 'class-variance-authority'; +import { isValidElement, type ReactNode } from 'react'; import styles from './Toc.module.css'; +function nodeToText(node: ReactNode): string { + if (node == null || typeof node === 'boolean') return ''; + if (typeof node === 'string' || typeof node === 'number') return String(node); + if (Array.isArray(node)) return node.map(nodeToText).join(''); + if (isValidElement(node)) { + const children = (node.props as { children?: ReactNode }).children; + return nodeToText(children); + } + return ''; +} + interface TocProps { items: TableOfContents; } @@ -23,30 +36,63 @@ export function Toc({ items }: TocProps) { ); } +const MARKER_BASE = 8; +const MARKER_PER_CHAR = 1; +const MARKER_MAX = 40; + +function markerWidth(title: ReactNode): number { + const len = nodeToText(title).length; + return Math.min(MARKER_MAX, MARKER_BASE + len * MARKER_PER_CHAR); +} + function TocContent({ items }: { items: TOCItemType[] }) { const activeAnchor = useActiveAnchor(); return ( -