diff --git a/examples/basic/chronicle.yaml b/examples/basic/chronicle.yaml index 76a0bdb..c75d3a9 100644 --- a/examples/basic/chronicle.yaml +++ b/examples/basic/chronicle.yaml @@ -6,3 +6,10 @@ theme: search: enabled: true placeholder: Search documentation... +footer: + copyright: "© 2024 Chronicle. All rights reserved." + links: + - label: GitHub + href: https://github.com/raystack/chronicle + - label: Documentation + href: / diff --git a/packages/chronicle/package.json b/packages/chronicle/package.json index 9ddc9cd..17b7a35 100644 --- a/packages/chronicle/package.json +++ b/packages/chronicle/package.json @@ -13,6 +13,7 @@ "devDependencies": { "@biomejs/biome": "^2.3.13", "@raystack/tools-config": "0.56.0", + "@types/lodash": "^4.17.23", "@types/mdx": "^2.0.13", "@types/node": "^25.1.0", "@types/react": "^19.2.10", @@ -21,13 +22,16 @@ "typescript": "5.9.3" }, "dependencies": { + "@heroicons/react": "^2.2.0", "@raystack/apsara": "^0.56.0", "chalk": "^5.6.2", "commander": "^14.0.2", "fumadocs-core": "^16.4.9", "fumadocs-mdx": "^14.2.6", + "lodash": "^4.17.23", "next": "16.1.6", "react": "^19.0.0", + "react-device-detect": "^2.2.3", "react-dom": "^19.0.0", "remark-attr": "^0.11.1", "yaml": "^2.8.2", diff --git a/packages/chronicle/src/app/[[...slug]]/page.tsx b/packages/chronicle/src/app/[[...slug]]/page.tsx index 0591f12..8231f34 100644 --- a/packages/chronicle/src/app/[[...slug]]/page.tsx +++ b/packages/chronicle/src/app/[[...slug]]/page.tsx @@ -46,6 +46,7 @@ export default async function DocsPage({ params }: PageProps) { toc: data.toc ?? [], }} config={config} + tree={tree} /> ) diff --git a/packages/chronicle/src/app/api/search/route.ts b/packages/chronicle/src/app/api/search/route.ts new file mode 100644 index 0000000..b4e3a93 --- /dev/null +++ b/packages/chronicle/src/app/api/search/route.ts @@ -0,0 +1,40 @@ +import { source } from '../../../lib/source' +import { createSearchAPI, type AdvancedIndex } from 'fumadocs-core/search/server' +import type { StructuredData } from 'fumadocs-core/mdx-plugins' + +interface PageData { + title?: string + description?: string + structuredData?: StructuredData + load?: () => Promise<{ structuredData?: StructuredData }> +} + +export const { GET } = createSearchAPI('advanced', { + indexes: async (): Promise => { + const pages = source.getPages() + const indexes = await Promise.all( + pages.map(async (page): Promise => { + const data = page.data as PageData + let structuredData: StructuredData | undefined = data.structuredData + + if (!structuredData && data.load) { + try { + const loaded = await data.load() + structuredData = loaded.structuredData + } catch (error) { + console.error(`Failed to load structured data for ${page.url}:`, error) + } + } + + return { + id: page.url, + url: page.url, + title: data.title ?? '', + description: data.description ?? '', + structuredData: structuredData ?? { headings: [], contents: [] }, + } + }) + ) + return indexes + }, +}) diff --git a/packages/chronicle/src/app/layout.tsx b/packages/chronicle/src/app/layout.tsx index 86b02a1..40f38eb 100644 --- a/packages/chronicle/src/app/layout.tsx +++ b/packages/chronicle/src/app/layout.tsx @@ -17,7 +17,7 @@ export default function RootLayout({ }) { return ( - + {children} diff --git a/packages/chronicle/src/components/ui/breadcrumbs.tsx b/packages/chronicle/src/components/ui/breadcrumbs.tsx new file mode 100644 index 0000000..fb4c0f2 --- /dev/null +++ b/packages/chronicle/src/components/ui/breadcrumbs.tsx @@ -0,0 +1,72 @@ +'use client' + +import { Breadcrumb } from '@raystack/apsara' +import type { PageTree, PageTreeItem } from '../../types' + +interface BreadcrumbsProps { + slug: string[] + tree: PageTree +} + +function findInTree(items: PageTreeItem[], targetPath: string): PageTreeItem | undefined { + for (const item of items) { + const itemUrl = item.url || `/${item.name.toLowerCase().replace(/\s+/g, '-')}` + if (itemUrl === targetPath || itemUrl === `/${targetPath}`) { + return item + } + if (item.children) { + const found = findInTree(item.children, targetPath) + if (found) return found + } + } + return undefined +} + +function getFirstPageUrl(item: PageTreeItem): string | undefined { + if (item.type === 'page' && item.url) { + return item.url + } + if (item.children) { + for (const child of item.children) { + const url = getFirstPageUrl(child) + if (url) return url + } + } + return undefined +} + +export function Breadcrumbs({ slug, tree }: BreadcrumbsProps) { + const items: { label: string; href: string }[] = [] + + for (let i = 0; i < slug.length; i++) { + const currentPath = `/${slug.slice(0, i + 1).join('/')}` + const node = findInTree(tree.children, currentPath) + const href = node?.url || (node && getFirstPageUrl(node)) || currentPath + const label = node?.name ?? slug[i] + items.push({ + label: label.charAt(0).toUpperCase() + label.slice(1), + href, + }) + } + + return ( + + {items.flatMap((item, index) => { + const breadcrumbItem = ( + + {item.label} + + ) + if (index === 0) return [breadcrumbItem] + return [ + , + breadcrumbItem, + ] + })} + + ) +} diff --git a/packages/chronicle/src/components/ui/footer.module.css b/packages/chronicle/src/components/ui/footer.module.css new file mode 100644 index 0000000..b40bc91 --- /dev/null +++ b/packages/chronicle/src/components/ui/footer.module.css @@ -0,0 +1,27 @@ +.footer { + border-top: 1px solid var(--rs-color-border-base-primary); + padding: var(--rs-space-5) var(--rs-space-7); +} + +.container { + max-width: 1200px; + margin: 0 auto; + width: 100%; +} + +.copyright { + color: var(--rs-color-foreground-base-secondary); +} + +.links { + flex-wrap: wrap; +} + +.link { + color: var(--rs-color-foreground-base-secondary); + font-size: 14px; +} + +.link:hover { + color: var(--rs-color-foreground-base-primary); +} diff --git a/packages/chronicle/src/components/ui/footer.tsx b/packages/chronicle/src/components/ui/footer.tsx new file mode 100644 index 0000000..476ce18 --- /dev/null +++ b/packages/chronicle/src/components/ui/footer.tsx @@ -0,0 +1,30 @@ +import { Flex, Link, Text } from "@raystack/apsara"; +import type { FooterConfig } from "../../types"; +import styles from "./footer.module.css"; + +interface FooterProps { + config?: FooterConfig; +} + +export function Footer({ config }: FooterProps) { + return ( +
+ + {config?.copyright && ( + + {config.copyright} + + )} + {config?.links && config.links.length > 0 && ( + + {config.links.map((link) => ( + + {link.label} + + ))} + + )} + +
+ ); +} diff --git a/packages/chronicle/src/components/ui/search.module.css b/packages/chronicle/src/components/ui/search.module.css new file mode 100644 index 0000000..b80c60f --- /dev/null +++ b/packages/chronicle/src/components/ui/search.module.css @@ -0,0 +1,111 @@ +.trigger { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 6px; + border: 1px solid var(--rs-color-border-base-primary); + background: transparent; + color: var(--rs-color-foreground-base-secondary); + font-size: 14px; + 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; + min-height: 0; + position: fixed; + top: 20%; + left: 50%; + transform: translateX(-50%); + border-radius: var(--rs-radius-4); +} + +.input { + flex: 1; + border: none; + outline: none; + background: transparent; + font-size: 16px; + color: var(--rs-color-foreground-base-primary); +} + +.list { + max-height: 300px; + overflow: auto; + padding: 0; +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.item { + padding: 12px 16px; + cursor: pointer; + border-radius: 6px; +} + +.item[data-selected="true"] { + background: var(--rs-color-background-base-secondary); + color: var(--rs-color-foreground-accent-primary-hover); +} + +.itemContent { + display: flex; + align-items: center; + gap: 12px; +} + +.resultText { + display: flex; + align-items: center; + gap: 8px; +} + +.headingText { + color: var(--rs-color-foreground-base-secondary); +} + +.item[data-selected="true"] .headingText { + color: var(--rs-color-foreground-accent-primary-hover); +} + +.separator { + color: var(--rs-color-foreground-base-secondary); +} + +.pageText { + color: var(--rs-color-foreground-base-primary); +} + +.item[data-selected="true"] .pageText { + color: var(--rs-color-foreground-accent-primary-hover); +} + +.icon { + width: 18px; + height: 18px; + color: var(--rs-color-foreground-base-secondary); + flex-shrink: 0; +} + +.item[data-selected="true"] .icon { + color: var(--rs-color-foreground-accent-primary-hover); +} diff --git a/packages/chronicle/src/components/ui/search.tsx b/packages/chronicle/src/components/ui/search.tsx new file mode 100644 index 0000000..6775aa1 --- /dev/null +++ b/packages/chronicle/src/components/ui/search.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Button, Command, Dialog, Text } from "@raystack/apsara"; +import { useDocsSearch } from "fumadocs-core/search/client"; +import type { SortedResult } from "fumadocs-core/search"; +import { DocumentIcon, HashtagIcon } from "@heroicons/react/24/outline"; +import { isMacOs } from "react-device-detect"; +import styles from "./search.module.css"; + +function SearchShortcutKey({ className }: { className?: string }) { + const [key, setKey] = useState("⌘"); + + useEffect(() => { + setKey(isMacOs ? "⌘" : "Ctrl"); + }, []); + + return ( + + {key} K + + ); +} + +export function Search() { + const [open, setOpen] = useState(false); + const router = useRouter(); + + const { search, setSearch, query } = useDocsSearch({ + type: "fetch", + api: "/api/search", + delayMs: 100, + allowEmpty: true, + }); + + const onSelect = useCallback( + (url: string) => { + setOpen(false); + router.push(url); + }, + [router], + ); + + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setOpen((open) => !open); + } + }; + + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, []); + + const results = query.data === "empty" ? [] : (query.data ?? []); + + return ( + <> + + + + + + Search documentation + + + + + + {query.isLoading && Loading...} + {!query.isLoading && + search.length > 0 && + results.length === 0 && ( + No results found. + )} + {!query.isLoading && + search.length === 0 && + results.length > 0 && ( + + {results.slice(0, 8).map((result: SortedResult) => ( + onSelect(result.url)} + className={styles.item} + > +
+ + + {result.content} + +
+
+ ))} +
+ )} + {search.length > 0 && + results.map((result: SortedResult) => ( + onSelect(result.url)} + className={styles.item} + > +
+ {result.type === "page" ? ( + + ) : ( + + )} +
+ {result.type === "heading" ? ( + <> + + {result.content} + + - + + {getPageTitle(result.url)} + + + ) : ( + + {result.content} + + )} +
+
+
+ ))} +
+
+
+
+ + ); +} + +function getPageTitle(url: string): string { + const path = url.split("#")[0]; + const segments = path.split("/").filter(Boolean); + const lastSegment = segments[segments.length - 1]; + if (!lastSegment) return "Home"; + return lastSegment + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} diff --git a/packages/chronicle/src/lib/config.ts b/packages/chronicle/src/lib/config.ts index 6249cdb..97fce84 100644 --- a/packages/chronicle/src/lib/config.ts +++ b/packages/chronicle/src/lib/config.ts @@ -11,8 +11,9 @@ const defaultConfig: ChronicleConfig = { search: { enabled: true, placeholder: 'Search...' }, } -export function loadConfig(contentDir: string = './content'): ChronicleConfig { - const configPath = path.join(contentDir, CONFIG_FILE) +export function loadConfig(): ChronicleConfig { + const dir = process.env.CHRONICLE_CONTENT_DIR ?? process.cwd() + const configPath = path.join(dir, CONFIG_FILE) if (!fs.existsSync(configPath)) { return defaultConfig @@ -29,6 +30,7 @@ export function loadConfig(contentDir: string = './content'): ChronicleConfig { colors: { ...defaultConfig.theme?.colors, ...userConfig.theme?.colors }, }, search: { ...defaultConfig.search, ...userConfig.search }, + footer: userConfig.footer, } } diff --git a/packages/chronicle/src/themes/default/Layout.module.css b/packages/chronicle/src/themes/default/Layout.module.css index 96c83fe..2b97e43 100644 --- a/packages/chronicle/src/themes/default/Layout.module.css +++ b/packages/chronicle/src/themes/default/Layout.module.css @@ -62,13 +62,3 @@ .page { padding: var(--rs-space-2) 0; } - -.footer { - border-top: 1px solid var(--rs-color-border-base-primary); - padding: var(--rs-space-5) var(--rs-space-9); - text-align: center; -} - -.footerText { - color: var(--rs-color-text-base-secondary); -} diff --git a/packages/chronicle/src/themes/default/Layout.tsx b/packages/chronicle/src/themes/default/Layout.tsx index 643edaf..d6319b5 100644 --- a/packages/chronicle/src/themes/default/Layout.tsx +++ b/packages/chronicle/src/themes/default/Layout.tsx @@ -1,14 +1,16 @@ -'use client' +"use client"; -import { usePathname } from 'next/navigation' -import { Flex, Navbar, Headline, Link, Sidebar, Text } from '@raystack/apsara' -import { ClientThemeSwitcher } from '../../components/ui/client-theme-switcher' -import type { ThemeLayoutProps, PageTreeItem } from '../../types' -import styles from './Layout.module.css' +import { usePathname } from "next/navigation"; +import { Flex, Navbar, Headline, Link, Sidebar } from "@raystack/apsara"; +import { ClientThemeSwitcher } from "../../components/ui/client-theme-switcher"; +import { Search } from "../../components/ui/search"; +import { Footer } from "../../components/ui/footer"; +import type { ThemeLayoutProps, PageTreeItem } from "../../types"; +import styles from "./Layout.module.css"; export function Layout({ children, config, tree }: ThemeLayoutProps) { - const pathname = usePathname() - + const pathname = usePathname(); + console.log(config); return ( @@ -25,11 +27,7 @@ export function Layout({ children, config, tree }: ThemeLayoutProps) { ))} - {config.search?.enabled && ( -
- {/* Search component will be added later */} -
- )} + {config.search?.enabled && } @@ -37,43 +35,54 @@ export function Layout({ children, config, tree }: ThemeLayoutProps) { {tree.children.map((item) => ( - + ))} -
- {children} -
+
{children}
-
- - Built with Chronicle - -
+