From fb02cd0683d2f22fb40cc95d09ef163f9d97630c Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 2 Sep 2026 02:47:46 -0500 Subject: [PATCH] feat: add the fanfold theme, and a `short` frontmatter field for narrow rails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fanfold is a third built-in theme: continuous-form line printer paper, with tractor-feed strips down both edges, faint zebra banding behind the sheet and monospace type throughout. It was translated from a design file, so its colours and metrics come from there rather than from a style guide. Two changes reach outside the theme. `short` frontmatter gives navigation a shorter label than the page title: title: Space Packet Protocol short: SPP The rail shows `SPP` and keeps the full title on the link's tooltip; headings, breadcrumbs, the browser tab and search all keep using `title`. `source.ts` copies it onto the page tree and `tree-utils.ts` exposes a `shortName` reader, so any theme can pick it up — today only fanfold does, since only its rail is narrow enough to need it. Note `short` had to be added to `KEEP_FIELDS`: that allowlist strips unknown fields when the tree is serialised for the client, so without it the value never reached the browser. Themes may now supply their own landing page through an optional `Landing` slot. The shared `LandingPage` still resolves config, `` tags and the version label, so a theme's `Landing` is presentation only. Themes that leave it out keep the existing layout. `LandingEntry` moved to `types/content.ts` so `types/theme.ts` can name it without closing an import cycle. Smaller shared changes: - `useSearch` is exported, so a theme can build its own search trigger instead of the stock icon button. - Departure Mono is declared once in `themes/fonts/` and shared by the paper and fanfold themes. Both previously declared the same family from their own copy of a byte-identical file, so the build shipped 22KB twice and the two `@font-face` rules collided on family name. - Fanfold requests its web fonts from a `` rather than a stylesheet `@import`. `registry.ts` imports every theme statically, so an `@import` was hoisted into the one bundled stylesheet and every site fetched Doto and Geist Mono — including sites running a different theme that never renders them. --- README.md | 2 +- docs/content/docs/configuration.mdx | 4 +- docs/content/docs/frontmatter.mdx | 19 + docs/content/docs/themes.mdx | 41 +- .../chronicle/src/components/ui/search.tsx | 2 +- packages/chronicle/src/lib/config.ts | 9 +- packages/chronicle/src/lib/source.ts | 54 +- packages/chronicle/src/lib/tree-utils.test.ts | 53 +- packages/chronicle/src/lib/tree-utils.ts | 21 +- packages/chronicle/src/pages/LandingPage.tsx | 44 +- .../src/themes/fanfold/Landing.module.css | 293 ++++++++ .../chronicle/src/themes/fanfold/Landing.tsx | 142 ++++ .../src/themes/fanfold/Layout.module.css | 575 +++++++++++++++ .../chronicle/src/themes/fanfold/Layout.tsx | 305 ++++++++ packages/chronicle/src/themes/fanfold/Nav.tsx | 111 +++ .../src/themes/fanfold/Page.module.css | 655 ++++++++++++++++++ .../chronicle/src/themes/fanfold/Page.tsx | 154 ++++ .../chronicle/src/themes/fanfold/PageNav.tsx | 58 ++ .../chronicle/src/themes/fanfold/Skeleton.tsx | 28 + .../chronicle/src/themes/fanfold/index.ts | 12 + .../fonts/DepartureMono-Regular.woff2 | Bin .../src/themes/fonts/departure-mono.css | 12 + .../src/themes/paper/Layout.module.css | 8 - packages/chronicle/src/themes/registry.ts | 5 +- packages/chronicle/src/types/config.ts | 2 +- packages/chronicle/src/types/content.ts | 22 + packages/chronicle/src/types/theme.ts | 23 +- 27 files changed, 2618 insertions(+), 36 deletions(-) create mode 100644 packages/chronicle/src/themes/fanfold/Landing.module.css create mode 100644 packages/chronicle/src/themes/fanfold/Landing.tsx create mode 100644 packages/chronicle/src/themes/fanfold/Layout.module.css create mode 100644 packages/chronicle/src/themes/fanfold/Layout.tsx create mode 100644 packages/chronicle/src/themes/fanfold/Nav.tsx create mode 100644 packages/chronicle/src/themes/fanfold/Page.module.css create mode 100644 packages/chronicle/src/themes/fanfold/Page.tsx create mode 100644 packages/chronicle/src/themes/fanfold/PageNav.tsx create mode 100644 packages/chronicle/src/themes/fanfold/Skeleton.tsx create mode 100644 packages/chronicle/src/themes/fanfold/index.ts rename packages/chronicle/src/themes/{paper => }/fonts/DepartureMono-Regular.woff2 (100%) create mode 100644 packages/chronicle/src/themes/fonts/departure-mono.css diff --git a/README.md b/README.md index e0495341..285d4276 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Config-driven documentation framework built on Vite, Nitro, and Apsara UI. ## Features - **Config-driven** — Single `chronicle.yaml` for all site configuration -- **Themeable** — Built-in themes: `default` (sidebar + TOC) and `paper` (book-style) +- **Themeable** — Built-in themes: `default` (sidebar + TOC), `paper` (book-style) and `fanfold` (continuous-form line printer) - **MDX** — Write docs in MDX with callouts, tabs, mermaid diagrams, and syntax highlighting - **API docs** — Interactive OpenAPI documentation with "Try it out" panel - **LLMs** — Auto-generate `/llms.txt` and `/llms-full.txt` for AI consumption diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index dc2c08c4..520b575c 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -250,7 +250,7 @@ theme: | Field | Type | Description | Default | |-------|------|-------------|---------| -| `name` | `'default' \| 'paper'` | Theme to use | `default` | +| `name` | `'default' \| 'paper' \| 'fanfold'` | Theme to use | `default` | | `colors` | `Record` | Custom color overrides | — | See [Themes](/docs/themes) for details on each theme. @@ -285,7 +285,7 @@ navigation: ### links -Links shown in the sidebar footer, behind a `?` menu button next to the version switcher. Available in both the `default` and `paper` themes. +Links shown in the sidebar footer, behind a `?` menu button next to the version switcher. Available in the `default`, `paper` and `fanfold` themes. ```yaml links: diff --git a/docs/content/docs/frontmatter.mdx b/docs/content/docs/frontmatter.mdx index 28c47f02..8914845f 100644 --- a/docs/content/docs/frontmatter.mdx +++ b/docs/content/docs/frontmatter.mdx @@ -13,6 +13,7 @@ Every MDX file supports YAML frontmatter at the top of the file for page-level c ```mdx --- title: Getting Started +short: Start description: A quick guide to set up your project order: 2 icon: rectangle-stack @@ -35,6 +36,24 @@ Your content here... title: Installation Guide ``` +### short + +Optional short label for the sidebar. Use it when the full title is too long for +a narrow rail but the page is known by a code its readers already use — a package +name, a command, a standard's abbreviation. + +```yaml +title: Space Packet Protocol +short: SPP +``` + +The sidebar shows `SPP` and keeps the full title on the link's tooltip. Headings, +breadcrumbs, the browser tab and search all keep using `title`. Pages that set no +`short` fall back to their title, so this is opt-in per page. + +Honoured by the `fanfold` theme, whose rail is narrow enough to need it. The +`default` and `paper` themes ignore it and always show `title`. + ### description Optional meta description for the page. Used in SEO metadata. diff --git a/docs/content/docs/themes.mdx b/docs/content/docs/themes.mdx index 251293ba..ccb29dae 100644 --- a/docs/content/docs/themes.mdx +++ b/docs/content/docs/themes.mdx @@ -6,7 +6,7 @@ order: 7 # Themes -Chronicle ships with two built-in themes. Set the theme in your `chronicle.yaml`: +Chronicle ships with three built-in themes. Set the theme in your `chronicle.yaml`: ```yaml theme: @@ -58,3 +58,42 @@ theme: - Reading progress tracking - Optimized typography for long content - Light mode only (dark mode toggle is disabled) + +## Fanfold Theme + +A continuous-form line printer look — tractor-feed strips down both edges, faint +zebra banding behind the page, and monospace type throughout. + +```yaml +theme: + name: fanfold +``` + +### Layout + +- **Left rail** — A section switcher, then the page tree for the section you are in +- **Sheet** — Centered content with a printed header block and a footer block +- **Right rail** — Table of contents plus configured links + +### Features + +- Header block prints the breadcrumb trail, site name, path, and a page counter +- Page title set in a dot-matrix face that steps down in size as titles get longer +- Code blocks print as ruled listings with a line-number gutter +- Tables print as field maps with no cell borders +- Its own landing page: a masthead over a register of every section +- Light and dark mode + +### Landing page + +When `landing` is turned on, this theme prints its own cover sheet instead of the +shared card grid. It shows an ident line, the site title in the dot-matrix face, +the site description, and then every content directory as a numbered row with its +path and description. + +Set it in `chronicle.yaml`: + +```yaml +latest: + landing: true +``` diff --git a/packages/chronicle/src/components/ui/search.tsx b/packages/chronicle/src/components/ui/search.tsx index 95adc402..d573ed0c 100644 --- a/packages/chronicle/src/components/ui/search.tsx +++ b/packages/chronicle/src/components/ui/search.tsx @@ -184,7 +184,7 @@ export function SearchProvider({ children }: { children: ReactNode }) { return {children}; } -function useSearch(): SearchContextValue { +export function useSearch(): SearchContextValue { const ctx = useContext(SearchContext); if (!ctx) throw new Error('Search components must be used within '); return ctx; diff --git a/packages/chronicle/src/lib/config.ts b/packages/chronicle/src/lib/config.ts index af6fbfe2..ed9d34f2 100644 --- a/packages/chronicle/src/lib/config.ts +++ b/packages/chronicle/src/lib/config.ts @@ -4,6 +4,7 @@ import { type BadgeConfig, type ChronicleConfig, chronicleConfigSchema, + type LandingEntry, } from '@/types' const defaultConfig: ChronicleConfig = chronicleConfigSchema.parse({ @@ -80,14 +81,6 @@ export interface VersionDescriptor { isLatest: boolean } -export interface LandingEntry { - label: string - description?: string - href: string - contentDir: string - icon?: string -} - export function getLandingEntries( config: ChronicleConfig, versionDir: string | null, diff --git a/packages/chronicle/src/lib/source.ts b/packages/chronicle/src/lib/source.ts index 7ae7fc0a..acd31a74 100644 --- a/packages/chronicle/src/lib/source.ts +++ b/packages/chronicle/src/lib/source.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { normalizeAuthorList } from './authors'; 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 { Root, Node, Folder, Item } from 'fumadocs-core/page-tree'; import { parentPath, getFolderPath } from './folder-utils'; @@ -181,6 +181,50 @@ function sortTreeByOrder(tree: Root, pages: { url: string; data: unknown }[], me return { ...tree, children: sortNodes(tree.children, pageOrderMap, folderOrderMap) }; } +/** + * Copies each page's `short` frontmatter onto its node in the tree, so a sidebar + * can label a link without having to look the page up again. Nodes for pages + * that set no `short` are left exactly as they were. + */ +function attachShortNames( + tree: Root, + pages: { url: string; data: unknown }[], +): Root { + const shortByUrl = new Map(); + for (const page of pages) { + const short = (page.data as Record).short; + if (typeof short === 'string' && short.length > 0) { + shortByUrl.set(page.url, short); + } + } + if (shortByUrl.size === 0) return tree; + + const withShort = (node: Item): Item => { + const short = shortByUrl.get(node.url); + if (!short) return node; + // fumadocs' `Item` has no `short`, so widen rather than cast a literal. + const labelled: Item & { short: string } = { ...node, short }; + return labelled; + }; + + function walk(nodes: Node[]): Node[] { + return nodes.map(node => { + if (node.type === NodeType.Folder) { + const folder = { ...node, children: walk(node.children) } as Folder; + // Only touch `index` when there is one. Writing the key back as + // `undefined` gives the folder an `index` it never had, and compactTree + // walks every key it keeps — including that one. + if (node.index) folder.index = withShort(node.index); + return folder; + } + if (node.type !== NodeType.Page) return node; + return withShort(node); + }); + } + + return { ...tree, children: walk(tree.children) }; +} + function filterDraftsFromTree(tree: Root, draftUrls: Set): Root { function filterNodes(nodes: Node[]): Node[] { return nodes @@ -197,8 +241,12 @@ export async function getPageTree(): Promise { if (cachedTree) return cachedTree; const s = await getSource(); const metaFiles = buildFiles().filter(f => f.type === 'meta') as { path: string; data: Record }[]; - const sorted = sortTreeByOrder(s.pageTree as Root, s.getPages(), metaFiles); - const draftUrls = new Set(s.getPages().filter(p => isDraft(p)).map(p => p.url)); + const pages = s.getPages(); + const sorted = attachShortNames( + sortTreeByOrder(s.pageTree as Root, pages, metaFiles), + pages, + ); + const draftUrls = new Set(pages.filter(p => isDraft(p)).map(p => p.url)); cachedTree = draftUrls.size > 0 ? filterDraftsFromTree(sorted, draftUrls) : sorted; return cachedTree; } diff --git a/packages/chronicle/src/lib/tree-utils.test.ts b/packages/chronicle/src/lib/tree-utils.test.ts index 757b2294..4d5d56bb 100644 --- a/packages/chronicle/src/lib/tree-utils.test.ts +++ b/packages/chronicle/src/lib/tree-utils.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test' import type { Node, Root } from 'fumadocs-core/page-tree' import type { ChronicleConfig } from '@/types' import type { VersionContext } from './version-source' -import { getFirstPageUrl, findFolderFirstPage, resolveDocsRedirect, resolvePageAndSlug, compactTree } from './tree-utils' +import { getFirstPageUrl, findFolderFirstPage, resolveDocsRedirect, resolvePageAndSlug, compactTree, shortName } from './tree-utils' function page(url: string, name = 'Page'): Node { return { type: 'page', name, url } as Node @@ -300,4 +300,55 @@ describe('compactTree', () => { const tree: Root = { name: 'custom', children: [] } expect(compactTree(tree).name).toBe('custom') }) + + // `short` is not a field fumadocs knows about, so it only survives + // serialisation because it is named in KEEP_FIELDS. + test('keeps short on page nodes', () => { + const tree: Root = { + name: 'root', + children: [{ + type: 'page', name: 'Space Packet Protocol', short: 'SPP', + url: '/protocols/spp', $ref: 'spp.mdx', + } as Node], + } + const result = compactTree(tree) + expect(result.children[0]).toEqual({ + type: 'page', name: 'Space Packet Protocol', short: 'SPP', url: '/protocols/spp', + }) + }) + + test('keeps short on a folder index page', () => { + const tree: Root = { + name: 'root', + children: [{ + type: 'folder', name: 'Transport', + index: { type: 'page', name: 'Transport overview', short: 'TP', url: '/transport' } as Node, + children: [], + } as Node], + } + const folder = compactTree(tree).children[0] as any + expect(folder.index.short).toBe('TP') + }) +}) + +describe('shortName', () => { + test('returns the short label a page set', () => { + const node = { type: 'page', name: 'Space Packet Protocol', short: 'SPP', url: '/spp' } as Node + expect(shortName(node)).toBe('SPP') + }) + + test('returns undefined when a page set none, so callers fall back to title', () => { + const node = { type: 'page', name: 'Install', url: '/install' } as Node + expect(shortName(node)).toBeUndefined() + }) + + test('ignores an empty string', () => { + const node = { type: 'page', name: 'Install', short: '', url: '/install' } as Node + expect(shortName(node)).toBeUndefined() + }) + + test('ignores a non-string value', () => { + const node = { type: 'page', name: 'Install', short: 42, url: '/install' } as unknown as Node + expect(shortName(node)).toBeUndefined() + }) }) diff --git a/packages/chronicle/src/lib/tree-utils.ts b/packages/chronicle/src/lib/tree-utils.ts index d43f2961..be6db86e 100644 --- a/packages/chronicle/src/lib/tree-utils.ts +++ b/packages/chronicle/src/lib/tree-utils.ts @@ -2,7 +2,17 @@ import type { Folder, Node, Root } from 'fumadocs-core/page-tree'; import type { ChronicleConfig } from '@/types'; import type { VersionContext } from './version-source'; -const KEEP_FIELDS = new Set(['type', 'name', 'url', 'icon', 'children', 'index']); +// Anything not listed here is dropped when the tree is serialised for the +// client, so a new node field has to be added or it will not survive the trip. +const KEEP_FIELDS = new Set([ + 'type', + 'name', + 'short', + 'url', + 'icon', + 'children', + 'index', +]); function compactLeaf(node: Node): Node { const out: Record = {}; @@ -28,6 +38,15 @@ export function compactTree(tree: Root): Root { return { ...tree, children: tree.children.map(compactNode) }; } +/** + * The `short` frontmatter a page set, if any. `attachShortNames` in source.ts + * puts it on the node; fumadocs' own `Item` type does not know about it. + */ +export function shortName(node: Node): string | undefined { + const value = (node as { short?: unknown }).short; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + export const NodeType = { Page: 'page', Folder: 'folder', diff --git a/packages/chronicle/src/pages/LandingPage.tsx b/packages/chronicle/src/pages/LandingPage.tsx index 011a765b..5319afbe 100644 --- a/packages/chronicle/src/pages/LandingPage.tsx +++ b/packages/chronicle/src/pages/LandingPage.tsx @@ -3,28 +3,59 @@ import { Link as RouterLink } from 'react-router'; import { getLandingEntries } from '@/lib/config'; import { Head } from '@/lib/head'; import { usePageContext } from '@/lib/page-context'; +import { getTheme } from '@/themes/registry'; +import type { ThemeLandingProps } from '@/types'; import styles from './LandingPage.module.css'; +/** + * Resolves what the landing page shows, then hands it to the active theme. + * + * Themes that fill the `Landing` slot get to lay the page out themselves; the + * rest fall through to `DefaultLanding` below. Config reading, the `` + * tags and the version label stay here either way, so a theme never has to + * repeat them. + */ export function LandingPage() { const { config, version } = usePageContext(); const entries = getLandingEntries(config, version.dir); + const { Landing } = getTheme(config.theme?.name); - const heading = version.dir === null - ? config.site.title - : `${config.site.title} — ${versionLabel(config, version.dir)}`; + // The heading only carries a version when an older one is being read, so the + // latest reads as the site itself. `versionLabel` is the label either way — + // a theme may want to print "0.3" even on the latest version. + const olderLabel = + version.dir === null ? null : versionLabel(config, version.dir); + const heading = olderLabel + ? `${config.site.title} — ${olderLabel}` + : config.site.title; + + const props: ThemeLandingProps = { + config, + entries, + heading, + description: config.site.description, + versionLabel: olderLabel ?? config.latest?.label ?? null, + }; return ( <> + {Landing ? : } + + ); +} + +function DefaultLanding({ entries, heading, description }: ThemeLandingProps) { + return (

{heading}

- {config.site.description ? ( -

{config.site.description}

+ {description ? ( +

{description}

) : null}
@@ -49,7 +80,6 @@ export function LandingPage() { ))}
- ); } diff --git a/packages/chronicle/src/themes/fanfold/Landing.module.css b/packages/chronicle/src/themes/fanfold/Landing.module.css new file mode 100644 index 00000000..9e340471 --- /dev/null +++ b/packages/chronicle/src/themes/fanfold/Landing.module.css @@ -0,0 +1,293 @@ +/* The landing page as a cover sheet: an ident line struck across the top, a + dot-matrix masthead, then the sections printed as a register. + + The bands read wider than the ones on a content page. A content page is a + column of prose capped at 904px; this is a form, so it uses the full sheet + and only the register's own columns hold the eye. */ + +.root { + --fan-landing-gutter: clamp(24px, 6vw, 120px); + + display: flex; + flex-direction: column; + /* A site with few sections leaves the register short. Filling the sheet lets + the footer drop to the bottom of the paper rather than stopping halfway + down with blank zebra under it. */ + min-height: 100%; + max-width: 1384px; + margin-inline: auto; +} + +/* ---- header band ---- */ + +/* Set in from the sheet's edge by a hair rather than the band gutter, so the + rule of asterisks reads as struck across the whole page. */ +.headerBand { + display: flex; + flex-direction: column; + padding: 34px 24px 26px; +} + +.stars { + overflow: hidden; + white-space: nowrap; + font-size: 10.5px; + line-height: 18px; + letter-spacing: 0.14em; + color: var(--fan-stars); + user-select: none; +} + +.identRow { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 26px; + padding-top: 6px; +} + +/* Holds the struck lines as one unit. `min-width: 0` so a long site title + ellipses inside the row instead of pushing the links off the sheet. */ +.identLines { + display: flex; + flex-direction: column; + min-width: 0; +} + +.ident { + font-size: 10.5px; + line-height: 18px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fan-ink-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.headerLinks { + display: flex; + flex-shrink: 0; + gap: 26px; +} + +.headerLink { + font-size: 10.5px; + line-height: 18px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fan-ink-3); + text-decoration: none; + white-space: nowrap; +} + +.headerLink:hover { + color: var(--fan-ink); +} + +/* ---- hero band ---- */ + +.heroBand { + padding: 56px var(--fan-landing-gutter) 20px; +} + +.display { + margin: 0; + font-family: var(--fan-display); + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--fan-ink); + overflow-wrap: anywhere; +} + +/* Two sizes, not a ladder — same reasoning as the display on a content page. */ +.display[data-size="wordmark"] { + font-size: clamp(34px, 11vw, 160px); + line-height: 0.93; +} + +.display[data-size="name"] { + font-size: clamp(32px, 6vw, 84px); + line-height: 0.98; +} + +/* A step above body copy, and the one place on this page words come from the + site's own config. */ +.lede { + margin: 22px 0 0; + max-width: 680px; + font-size: 18px; + font-weight: 500; + line-height: 28px; + letter-spacing: 0.01em; + color: var(--fan-ink); + text-wrap: pretty; +} + +/* ---- register band ---- */ + +.registerBand { + padding: 56px var(--fan-landing-gutter) 20px; +} + +.sectionTitle { + margin: 0 0 34px; + font-family: var(--fan-head); + font-size: 22px; + font-weight: 400; + line-height: 26px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fan-ink); +} + +/* Fixed-width slots so the four columns hold their lanes down the register, + whatever a row's label or description happens to be. */ +.headRow, +.row { + display: flex; + align-items: baseline; + gap: 0; +} + +.headRow { + padding-bottom: 10px; + border-bottom: 1px dashed var(--fan-rule-strong); + font-size: 10.5px; + line-height: 18px; + letter-spacing: 0.14em; + color: var(--fan-ink-3); +} + +.row { + padding: 9px 0; + font-size: 13.5px; + line-height: 24px; + color: var(--fan-ink-3); + text-decoration: none; + border-bottom: 1px dashed transparent; +} + +/* Hover is the only thing separating this register from a printed one, so it + stays to a rule under the row and ink in the label — no fill, no lift. */ +.row:hover { + border-bottom-color: var(--fan-rule); +} + +.row:hover .name { + border-bottom-color: var(--fan-ink); +} + +.colNo, +.colName, +.colPath { + flex-shrink: 0; +} + +.colNo { + width: 56px; + font-variant-numeric: tabular-nums; +} + +.colName { + width: 170px; + padding-right: 16px; + font-weight: 500; + text-transform: uppercase; + color: var(--fan-ink); +} + +/* The rule on hover belongs to the label, not to the lane it sits in — set on + the fixed-width slot it ran the whole column and read as a stray underscore. */ +.name { + border-bottom: 1px solid transparent; +} + +.colPath { + width: 170px; + padding-right: 16px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.colWhat { + flex: 1; + min-width: 0; + color: var(--fan-ink); +} + +/* ---- footer band ---- */ + +.footerBand { + margin-top: auto; + padding: 64px var(--fan-landing-gutter); +} + +.footerRule { + border-top: 1px dashed var(--fan-rule-strong); +} + +.footerMeta { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 24px; + padding-top: 22px; + font-size: 10.5px; + line-height: 18px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fan-ink-3); +} + +/* ---- narrow widths ---- */ + +/* Below the register's own width the four lanes cannot hold, so each row + becomes a small printed block: number and label on one line, path and + description stacked under it. The head row has nothing left to label. */ +@media (max-width: 860px) { + .headRow { + display: none; + } + + .row { + display: grid; + grid-template-columns: 32px 1fr; + column-gap: 12px; + row-gap: 2px; + padding: 14px 0; + border-bottom: 1px dashed var(--fan-rule); + } + + .colNo { + width: auto; + grid-row: 1; + } + + .colName, + .colPath, + .colWhat { + width: auto; + grid-column: 2; + padding-right: 0; + } + + .colPath { + font-size: 12px; + color: var(--fan-ink-3); + } +} + +@media (max-width: 600px) { + .identRow { + flex-direction: column; + gap: 4px; + } + + .headerLinks { + flex-wrap: wrap; + gap: 4px 18px; + } +} diff --git a/packages/chronicle/src/themes/fanfold/Landing.tsx b/packages/chronicle/src/themes/fanfold/Landing.tsx new file mode 100644 index 00000000..1771f71d --- /dev/null +++ b/packages/chronicle/src/themes/fanfold/Landing.tsx @@ -0,0 +1,142 @@ +'use client'; + +import { Link as RouterLink } from 'react-router'; +import type { ChronicleConfig, ThemeLandingProps } from '@/types'; +import styles from './Landing.module.css'; + +const STARS = '*'.repeat(400); + +const pad = (n: number) => String(n).padStart(2, '0'); + +const isExternal = (href: string) => /^https?:/.test(href); + +/** + * A title this short is a wordmark and gets the masthead. Anything longer is a + * sentence-shaped name and gets one smaller size, for the same reason `Page` + * uses two steps and not a ladder: sizing by character count in many steps made + * near-identical titles come out visibly different. + */ +const MASTHEAD_MAX_CHARS = 12; + +/** Links for the header rule, deduplicated by destination. */ +function headerLinks(config: ChronicleConfig) { + const all = [ + ...(config.navigation?.links ?? []), + ...(config.links ?? []), + ...(config.navigation?.social ?? []).map(s => ({ + label: s.type, + href: s.href + })) + ]; + const seen = new Set(); + return all.filter(link => { + const key = link.href.replace(/\/+$/, ''); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +/** Strips the scheme so the printed line reads as a citation, not a URL. */ +function bareUrl(url: string) { + return url.replace(/^https?:\/\//, '').replace(/\/+$/, ''); +} + +export function Landing({ + config, + entries, + heading, + description, + versionLabel +}: ThemeLandingProps) { + const links = headerLinks(config); + const size = heading.length <= MASTHEAD_MAX_CHARS ? 'wordmark' : 'name'; + + // "** DOCS * V2.1" — the ident line a printer lays down before the report. + const ident = `** ${[config.site.title, versionLabel] + .filter(Boolean) + .join(' * ')}`; + + return ( + // The data attribute lets the layout widen this band past the measure it + // caps other full-width pages at. See `Layout.module.css`. +
+
+ + {/* Both struck lines stay together in one block so that when the row + turns into a column at narrow widths the links drop below them, + rather than landing between the two. */} +
+
+ {ident} + {config.url ? ( + ** {bareUrl(config.url)} + ) : null} +
+ {links.length ? ( + + ) : null} +
+
+ +
+

+ {heading} +

+ {description ?

{description}

: null} +
+ +
+ {/* The column headers and this title are the only copy the theme + supplies. Prose belongs to the site, so the hero description is the + one place words come from config. */} +

THE REGISTER

+ + + +
+ {entries.map((entry, i) => ( + + {pad(i + 1)} + + {entry.label} + + {entry.href} + {entry.description ?? '—'} + + ))} +
+
+ +
+
+
+ + ** {config.site.title} ** {entries.length}{' '} + {entries.length === 1 ? 'SECTION' : 'SECTIONS'} ** + + PAGE 01 / 01 +
+
+
+ ); +} diff --git a/packages/chronicle/src/themes/fanfold/Layout.module.css b/packages/chronicle/src/themes/fanfold/Layout.module.css new file mode 100644 index 00000000..87dc7968 --- /dev/null +++ b/packages/chronicle/src/themes/fanfold/Layout.module.css @@ -0,0 +1,575 @@ +/* Continuous-form line printer paper: tractor-feed strips down both edges, + zebra banding behind the sheet, and monospace type throughout. */ +.layout { + --fan-strip: 44px; + --fan-rail: 240px; + --fan-bar-height: 42px; + --fan-gutter: 24px; + --fan-mobile-header: 48px; + --fan-page-top: 24px; + + /* Spacing and radii come from Apsara wherever a value lands exactly on its + scale, so this theme moves with the design system. + Colour does not, and deliberately. This palette is a specific paper stock + taken from the design — a warm off-white ground, warm greys, an oxidised + rule — and Apsara's neutrals are a different, cooler set. Mapping to the + nearest token would shift every one of them, which is a change to the + design rather than an adoption of the system. Overriding a `--fan-*` here + is the supported way to retint the whole theme. */ + --fan-ground: #fffdfe; + --fan-bar: oklch(98.9% 0.005 3.3 / 0.85); + --fan-ink: #1d1a1a; + --fan-ink-2: #5b5758; + --fan-ink-3: #908b8c; + --fan-ink-4: #c1bcbd; + --fan-rule: #dddada; + --fan-rule-strong: #c6c3bb; + --fan-underline: #c9c0ae; + --fan-stars: #cdc9ca; + --fan-hole: #f6f2f3; + --fan-desk: #fffdfe; + /* A hairline ring draws the edge crisply and wraps all four sides, so the top + of the sheet is as defined as its sides. The lift underneath is kept wide + and faint — enough to separate the page from the surface without pooling + into a dark band around it. */ + --fan-lift: 0 0 0 1px rgba(29, 26, 26, 0.045), + 0 1px 1px rgba(29, 26, 26, 0.025), + 0 10px 24px -10px rgba(29, 26, 26, 0.05); + --fan-wash: #f8f5f5; + --fan-chip-line: #ebe6e6; + + --fan-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --fan-display: "Doto", "Geist Mono", ui-monospace, monospace; + --fan-head: "Departure Mono", "Geist Mono", ui-monospace, monospace; + + /* 1600 = two 44px feed strips, two 240px rails, a 904px column of type and a + 64px gutter either side of it. Past that the paper stops growing, so the + rails never drift away from what they describe. */ + max-width: 1600px; + /* The gap sits above the paper, not inside it, so the sheet — bands, feed + strips and all — starts a little down the window and reads as a page laid + on a surface rather than one running off the top of the screen. */ + margin: var(--fan-page-top) auto 0; + min-height: calc(100vh - var(--fan-page-top)); + color: var(--fan-ink); + font-family: var(--fan-mono); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +:global([data-theme="dark"]) .layout { + --fan-ground: #131111; + --fan-bar: oklch(19.5% 0.002 3.3 / 0.85); + --fan-ink: #ece8e8; + --fan-ink-2: #b3adad; + --fan-ink-3: #8b8585; + --fan-ink-4: #575151; + --fan-rule: #302c2c; + --fan-rule-strong: #423d3d; + --fan-underline: #4c443a; + --fan-stars: #3a3535; + --fan-hole: #201d1d; + --fan-desk: #131111; + /* A shadow alone is invisible against a ground this dark, so the sheet gets a + hairline of light around it instead, with the shadow left to give depth. */ + --fan-lift: 0 0 0 1px rgba(236, 232, 232, 0.1), + 0 10px 26px -10px rgba(0, 0, 0, 0.3); + --fan-wash: #1d1b1b; + --fan-chip-line: #332f2f; +} + +/* The surface the sheet lies on. + This cannot be done with a `body` rule. Every theme's stylesheet ships in the + same bundle whichever theme is active, so two unscoped `body` selectors of + equal specificity collide and bundle order decides the winner — the paper + theme's rule was winning here regardless of the configured theme. A theme's + custom properties are also declared on `.layout`, so `var(--fan-ground)` would + not even resolve at body scope. + + A fixed backdrop behind the layout avoids both problems: it only exists while + this theme is mounted, and it reads the tokens from the element that declares + them. It carries the same tone as the paper — the sheet's edges are drawn by + the shadow on `.frame`, not by a change of colour. */ +.layout::before { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + background: var(--fan-desk); + pointer-events: none; +} + +/* The desk is the same tone as the paper, so this shadow is the only thing + marking where the sheet ends. */ +.frame { + display: flex; + align-items: flex-start; + background-color: var(--fan-ground); + box-shadow: var(--fan-lift); +} + +/* ApiLayout wraps the theme in a fixed-height, `overflow: hidden` shell and + styles the content column as the scroller. For that to work a definite height + has to reach it, so these wrappers take one — and the page's top margin comes + off, since the shell is exactly a viewport tall and the margin would push its + last 24px out of sight. `height: 100%` against the auto-height layout of a + normal docs page resolves to auto, so none of this touches those. */ +.layout[data-api] { + display: flex; + flex-direction: column; + margin-top: 0; + min-height: 0; +} + +/* `flex: 1` rather than `height: 100%`: at narrow widths the mobile header is + also in this column, and a full-height frame beneath it overflowed the shell + by the header's height — clipping the last 48px of the scroll area. */ +.layout[data-api] .frame { + flex: 1; + min-height: 0; +} + +.layout[data-api] .sheet { + height: 100%; + min-height: 0; +} + +/* Real elements rather than a background on the layout: the paper is capped and + centred, and a `background-attachment: fixed` gradient tiles from the window + edge, not the paper's, so the perforations would fall outside the sheet. + Sticky keeps them still while they stay in flow beside it. */ +.strip { + position: sticky; + top: 0; + width: var(--fan-strip); + height: 100vh; + flex-shrink: 0; + background-image: radial-gradient( + circle at 22px 20px, + var(--fan-hole) 5.5px, + transparent 6px + ); + background-size: var(--fan-strip) 40px; + background-repeat: repeat-y; + /* The strip is a viewport tall so it still fills the window once stuck. The + negative margin stops that height from adding the page's top margin back on + as overflow at the bottom of a short page. */ + margin-bottom: calc(var(--fan-page-top) * -1); +} + +/* The zebra sits between the two feed strips and scrolls with the page. */ +.sheet { + flex: 1; + min-width: 0; + display: flex; + align-items: flex-start; + border-inline: 1px dashed var(--fan-rule); + background-image: repeating-linear-gradient( + to bottom, + var(--fan-bar) 0, + var(--fan-bar) var(--fan-bar-height), + transparent var(--fan-bar-height), + transparent calc(var(--fan-bar-height) * 2) + ); + min-height: calc(100vh - var(--fan-page-top)); +} + +.rail { + width: var(--fan-rail); + flex-shrink: 0; + position: sticky; + top: 0; + max-height: 100vh; + overflow-y: auto; + padding: 34px 0 var(--rs-space-13) var(--rs-space-7); + scrollbar-width: thin; +} + +.content { + flex: 1; + min-width: 0; +} + +/* The sidebar is also hidden on the authors pages, which are shared across + themes: they style themselves from Apsara tokens and the `--paper-font-*` + hooks a theme is expected to set. Remapping those here prints them in the + same ink as the rest of the sheet, with no change to the shared components. */ +.contentFull { + margin-inline: auto; + max-width: 1000px; + + --paper-font-mono: var(--fan-mono); + --paper-font-body: var(--fan-mono); + --rs-color-foreground-accent-primary: var(--fan-ink); + --rs-color-background-accent-secondary: var(--fan-bar); + --rs-color-foreground-base-primary: var(--fan-ink); + --rs-color-foreground-base-secondary: var(--fan-ink-3); + --rs-color-border-base-primary: var(--fan-rule-strong); +} + +/* The theme's own landing page sets its own measure and gutters, and its rule of + asterisks is meant to run the width of the sheet, so the cap comes off for it. + + `hideSidebar` is all the layout is told, and it is true for the authors pages + too, so this cannot be decided from a prop. The landing marks itself with a + data attribute instead — CSS modules rewrite class names but leave attributes + alone, so the selector still matches from here. */ +.contentFull:has(> [data-fanfold-landing]) { + max-width: none; + /* `.sheet` aligns its children to the start, so the content column is only as + tall as what is in it. Stretching it gives the landing a height to fill, so + its footer can sit at the foot of the paper instead of partway down. */ + align-self: stretch; +} + +/* Excluded rather than reset: a reset here would be the more specific rule and + would beat the landing's own display type, which is what it exists to allow. */ +.contentFull:not(:has(> [data-fanfold-landing])) h1 { + font-family: var(--fan-display); + font-weight: 700; + letter-spacing: 0.02em; +} + +/* ---- brand ---- */ + +.brand { + display: block; + margin-bottom: 26px; + padding-left: var(--rs-space-3); + text-decoration: none; +} + +.brandName { + font-family: var(--fan-display); + font-weight: 800; + font-size: 24px; + line-height: 21px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fan-ink); +} + +.brandVersion { + display: block; + margin-top: 6px; + font-size: 10.5px; + line-height: 16px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--fan-ink-3); +} + +/* ---- search ---- */ + +/* The search trigger lives in the mobile header alone — the rail deliberately + has no search row — so it is sized for that one place. It used to carry rail + geometry (a 200px width, a bottom rule, a 22px margin) that every rendered + instance then overrode. */ +.search { + display: flex; + align-items: baseline; + gap: 10px; + padding: 0; + border: 0; + background: none; + color: var(--fan-ink-3); + font-family: inherit; + font-size: 10.5px; + line-height: 18px; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; +} + +.search:hover { + color: var(--fan-ink); + border-bottom-color: var(--fan-ink-3); +} + +.searchKey { + color: var(--fan-ink-4); +} + +/* ---- section switcher ---- + One row per content directory. The active row is a solid block of ink that + bleeds 8px left of the text, so the current section reads as struck through + the margin rather than merely bolded. */ + +.switcher { + display: flex; + flex-direction: column; + gap: var(--rs-space-1); + width: 208px; +} + +.switcherItem { + display: flex; + align-items: center; + height: 24px; + padding-left: var(--rs-space-3); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 10.5px; + line-height: 19px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--fan-ink-2); + text-decoration: none; +} + +.switcherItem:hover { + color: var(--fan-ink); + background: var(--fan-bar); +} + +.switcherItem[data-active="true"] { + background: var(--fan-ink); + color: var(--fan-ground); + font-weight: 500; +} + +.separator { + width: 200px; + height: 0; + margin: 18px 0 0 var(--rs-space-3); + border-top: 1px dotted var(--fan-rule-strong); +} + +/* ---- nav ---- + Group headings stay in caps — they are short labels and the caps are what make + them read as headings. Page names are set in their natural case: a protocol + name like "Proximity-1 Data Link Layer" loses every word shape in caps, which + is exactly the cue you need to read it at this size. The case difference then + does most of the work separating headings from links, so colour and size only + have to widen the gap a little further. */ + +.nav { + display: flex; + flex-direction: column; + width: 208px; + padding: 18px 0 0 var(--rs-space-3); +} + +.navGroup { + padding-top: 26px; +} + +.navGroup:first-child { + padding-top: 0; +} + +.navLabel, +.navSubLabel { + display: block; + margin-bottom: 6px; + text-decoration: none; + font-size: 10px; + line-height: 16px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--fan-ink-3); +} + +.navSubLabel { + padding-top: 22px; +} + +a.navLabel:hover, +a.navSubLabel:hover { + color: var(--fan-ink); +} + +.navList { + list-style: none; + margin: 0; + padding: 0; +} + +/* No case transform: a page's `short` frontmatter already carries the case it + wants ("SPP"), and forcing uppercase would wreck a site that falls back to + full titles. Nothing wraps either, so every entry is one line and every line + is one entry; the `title` attribute carries the full name for anything the + ellipsis cuts. */ +.navLink { + position: relative; + display: flex; + align-items: center; + gap: 7px; + margin-bottom: var(--rs-space-2); + font-size: 12.5px; + line-height: 20px; + letter-spacing: 0.02em; + color: var(--fan-ink); + text-decoration: none; +} + +/* `min-width: 0` lets the text shrink below its content width, which is what + allows the ellipsis; without it a flex item refuses to go narrower and the + marker beside it gets pushed out of the rail instead. */ +.navLinkText { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.navLink:hover { + color: var(--fan-ink-2); + text-decoration: underline; + text-underline-offset: 3px; + text-decoration-color: var(--fan-underline); +} + +.navLink[data-active="true"] { + font-weight: 500; +} + +/* A struck square marks the current page, the way a printout marks a line. It is + absolutely placed in the gutter left of the text rather than being a flex item + ahead of it, so the names all keep one left edge whether active or not — and + it can never be clipped by the ellipsis. */ +.navLink[data-active="true"]::before { + content: ""; + position: absolute; + left: -8px; + top: 50%; + margin-top: -2px; + width: 4px; + height: 4px; + background: var(--fan-ink); +} + +.navIcon { + display: inline-flex; + align-items: center; + flex-shrink: 0; +} + +/* ---- rail footer ---- */ + +.railFooter { + width: 200px; + margin: var(--rs-space-10) 0 0 var(--rs-space-3); + padding-top: var(--rs-space-5); + border-top: 1px dotted var(--fan-rule-strong); + display: flex; + flex-direction: column; + gap: var(--rs-space-2); +} + +/* SidebarLinks brings its own list markup; this only has to line it up with the + rest of the footer column. */ +.railFooterLinks { + display: flex; + flex-direction: column; +} + +.railFooterToggle { + justify-content: flex-start; + font-size: 12px; + line-height: 20px; + color: var(--fan-ink-3); +} + +.railFooterToggle:hover { + color: var(--fan-ink); +} + +.railFooterLink { + font-size: 12px; + line-height: 20px; + letter-spacing: 0; + color: var(--fan-ink-3); + text-decoration: none; +} + +.railFooterLink:hover { + color: var(--fan-ink); +} + +/* ---- mobile ---- */ + +.mobileHeader { + display: none; + position: sticky; + top: 0; + z-index: 20; + align-items: center; + justify-content: space-between; + min-height: var(--fan-mobile-header); + gap: var(--rs-space-5); + padding: 10px var(--rs-space-6); + background: var(--fan-ground); + border-bottom: 1px dashed var(--fan-rule); +} + +/* In the rail both blocks stack with room to breathe; in the header bar they + sit on one line, so the vertical rhythm they carry has to come off. */ +.mobileHeader .brand { + margin-bottom: 0; +} + +.mobileActions { + display: flex; + align-items: center; + gap: 14px; +} + +.iconButton { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + background: none; + color: var(--fan-ink-2); + cursor: pointer; +} + +.iconButton:hover { + color: var(--fan-ink); +} + +.mobileMenu { + display: none; +} + +@media (max-width: 1080px) { + .layout { + --fan-page-top: 0px; + } + + /* The sheet is the whole window here, so there is no edge to lift. */ + .frame { + box-shadow: none; + } + + .strip { + display: none; + } + + .mobileHeader { + display: flex; + } + + .sheet { + border-inline: 0; + min-height: auto; + } + + .rail { + display: none; + } + + .mobileMenu { + display: none; + padding: var(--rs-space-6); + border-bottom: 1px dashed var(--fan-rule); + background: var(--fan-ground); + } + + .mobileMenu[data-open="true"] { + display: block; + } +} diff --git a/packages/chronicle/src/themes/fanfold/Layout.tsx b/packages/chronicle/src/themes/fanfold/Layout.tsx new file mode 100644 index 00000000..ac066994 --- /dev/null +++ b/packages/chronicle/src/themes/fanfold/Layout.tsx @@ -0,0 +1,305 @@ +'use client'; + +import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline'; +import { useTheme } from '@raystack/apsara'; +import { cx } from 'class-variance-authority'; +import { useEffect, useState } from 'react'; +import { Link as RouterLink, useLocation } from 'react-router'; +import { useSearch } from '@/components/ui/search'; +import { SidebarLinks } from '@/components/ui/sidebar-links'; +import { + getAllVersions, + getApiConfigsForVersion, + getLandingEntries +} from '@/lib/config'; +import { getActiveContentDir, getVersionHomeHref } from '@/lib/navigation'; +import { usePageContext } from '@/lib/page-context'; +import { RouteType, resolveRoute } from '@/lib/route-resolver'; +import type { ThemeLayoutProps } from '@/types'; +import styles from './Layout.module.css'; +import { Nav } from './Nav'; + +/** + * Doto and Geist Mono, requested from the document rather than with an `@import` + * in the stylesheet. + * + * `registry.ts` imports every theme statically, so an `@import` here was hoisted + * into the one bundled stylesheet and every Chronicle site fetched these two + * families — including sites running a different theme that never renders them. + * React hoists and deduplicates a `` rendered anywhere in the tree, so + * this loads only while this layout is mounted. + */ +const WEB_FONTS = + 'https://fonts.googleapis.com/css2?family=Doto:wght@400;700;800&family=Geist+Mono:wght@400;500;700&display=swap'; + +/** + * `navigation.links` and `navigation.social` for the left rail. + * + * `config.links` is deliberately not here: `SidebarLinks` owns those, and it + * adds UTM parameters, routes relative hrefs through the router instead of + * reloading the document, and opens external ones with `noopener` alone so the + * destination still sees this site as the referrer. Re-emitting them as plain + * anchors threw all of that away. + * + * `navigation.social` often points at the same repository as a + * `navigation.links` entry, so the list is deduplicated by destination — the + * first spelling of a URL wins. + */ +function useRailLinks() { + const { config } = usePageContext(); + const all = [ + ...(config.navigation?.links ?? []), + ...(config.navigation?.social ?? []).map(s => ({ + label: s.type, + href: s.href + })) + ]; + const seen = new Set(); + return all.filter(link => { + const key = link.href.replace(/\/+$/, ''); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function Brand() { + const { config, version } = usePageContext(); + const versions = getAllVersions(config); + const active = versions.find(v => + v.isLatest ? version.dir === null : v.dir === version.dir + ); + const label = active?.label ?? config.latest?.label; + + return ( + + {config.site.title} + {label ? ~/{label} : null} + + ); +} + +function SearchLine() { + const { config } = usePageContext(); + const { setOpen } = useSearch(); + + if (!config.search?.enabled) return null; + + return ( + + ); +} + +/** + * The sections of the site — one row per content directory, plus any API. The + * tree below only ever shows the section you are in, so this is what moves you + * between them. + */ +function Switcher() { + const { config, version } = usePageContext(); + const { pathname } = useLocation(); + + const activeDir = getActiveContentDir(pathname, config); + const docs = getLandingEntries(config, version.dir).map(entry => ({ + key: entry.contentDir, + label: entry.label, + href: entry.href, + active: entry.contentDir === activeDir + })); + const apis = getApiConfigsForVersion(config, version.dir).map(api => ({ + key: api.basePath, + label: api.name, + href: api.basePath, + active: + pathname === api.basePath || pathname.startsWith(`${api.basePath}/`) + })); + + const entries = [...docs, ...apis]; + // A single section has nowhere to switch to; the tree alone says where you are. + if (entries.length < 2) return null; + + return ( + + ); +} + +/** + * Rendered in the rail footer and again in the mobile header, where it is the + * only way to reach the setting — the rail is not on screen at that width. + * Mounts empty on the server: the resolved theme is not known until hydration, + * and guessing it makes the label flip after paint. + */ +function ThemeToggle({ className }: { className?: string }) { + const { resolvedTheme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => setMounted(true), []); + if (!mounted) return null; + + const next = resolvedTheme === 'dark' ? 'light' : 'dark'; + return ( + + ); +} + +function RailFooter() { + const { config, version } = usePageContext(); + const links = useRailLinks(); + const versions = getAllVersions(config); + + const showVersions = versions.length > 1; + if (!links.length && !showVersions && !config.links?.length) return null; + + return ( +
+ {showVersions + ? versions.map(v => ( + + ~/{v.label} + + )) + : null} + {links.map(link => ( + + {link.label} + + ))} + {config.links?.length ? ( +
+ +
+ ) : null} + +
+ ); +} + +export function Layout({ + children, + config, + tree, + hideSidebar, + classNames +}: ThemeLayoutProps) { + const { pathname } = useLocation(); + const [menuOpen, setMenuOpen] = useState(false); + + /** + * ApiLayout hands the theme a fixed-height, `overflow: hidden` shell and + * expects the content column to be the scroller. That only works if a + * definite height reaches it, so the wrappers in between opt into one — and + * the page's top margin comes off, since the shell is exactly a viewport tall + * and the margin would push its last 24px out of sight. + */ + const routeType = resolveRoute(pathname, config).type; + const isApiRoute = + routeType === RouteType.ApiPage || routeType === RouteType.ApiIndex; + + // Navigating from inside the overlay should reveal the page rather than leave + // the menu covering it. `pathname` is the trigger, not a value the body reads. + // biome-ignore lint/correctness/useExhaustiveDependencies: pathname is the trigger + useEffect(() => { + setMenuOpen(false); + }, [pathname]); + + const showRail = !hideSidebar; + + return ( +
+ + {/* The header itself is not gated on the rail. A landing or author page + has no tree to show, but on a phone it still needs the site title, the + search trigger and the theme toggle — only the menu button depends on + there being a tree. */} +
+ +
+ + + {showRail ? ( + + ) : null} +
+
+ {showRail ? ( +
+ +
+
+ ) : null} +
+ + ); +} diff --git a/packages/chronicle/src/themes/fanfold/Nav.tsx b/packages/chronicle/src/themes/fanfold/Nav.tsx new file mode 100644 index 00000000..d308425e --- /dev/null +++ b/packages/chronicle/src/themes/fanfold/Nav.tsx @@ -0,0 +1,111 @@ +import type { Item, Node, Root } from 'fumadocs-core/page-tree'; +import { Link as RouterLink, useLocation } from 'react-router'; +import { MethodBadge } from '@/components/api/method-badge'; +import { shortName } from '@/lib/tree-utils'; +import styles from './Layout.module.css'; + +const iconMap: Record = { + 'method-get': , + 'method-post': , + 'method-put': , + 'method-delete': , + 'method-patch': +}; + +const MAX_DEPTH = 3; + +function nodeKey(node: Node, index: number): string { + if (node.type === 'page') return node.url; + return `${node.name?.toString() ?? 'node'}-${index}`; +} + +interface NavProps { + tree: Root; +} + +export function Nav({ tree }: NavProps) { + return ( + + ); +} + +function NavNode({ node, depth }: { node: Node; depth: number }) { + const { pathname } = useLocation(); + + if (node.type === 'separator') { + return {node.name}; + } + + if (node.type === 'folder') { + // `>` not `>=`, so depths 0 through MAX_DEPTH render — the default theme + // draws the same range, and `>=` quietly hid a whole level of pages. + if (depth > MAX_DEPTH) return null; + // The top level reads as a printout section header; anything deeper is a + // category line inside that section. + const labelClass = depth === 0 ? styles.navLabel : styles.navSubLabel; + return ( +
+ {node.index ? ( + + ) : ( + {node.name} + )} +
    + {node.children.map((child, index) => ( +
  • + +
  • + ))} +
+
+ ); + } + + const isActive = pathname === node.url; + const icon = typeof node.icon === 'string' ? iconMap[node.icon] : node.icon; + + // A page can give the rail a shorter label than its title — a package or + // command name. Where it does, the full title moves to the tooltip. + const label = shortName(node) ?? node.name; + const title = typeof node.name === 'string' ? node.name : undefined; + + return ( + + {icon ? {icon} : null} + {label} + + ); +} + +function FolderIndexLink({ + node, + labelClass +}: { + node: Item; + labelClass: string; +}) { + const { pathname } = useLocation(); + // A folder's index page is still a page: `attachShortNames` puts `short` on it + // and it survives serialisation, so honour it here as well. + const label = shortName(node) ?? node.name; + return ( + + {label} + + ); +} diff --git a/packages/chronicle/src/themes/fanfold/Page.module.css b/packages/chronicle/src/themes/fanfold/Page.module.css new file mode 100644 index 00000000..09d9ed29 --- /dev/null +++ b/packages/chronicle/src/themes/fanfold/Page.module.css @@ -0,0 +1,655 @@ +.pageRow { + display: flex; + align-items: flex-start; +} + +/* The rails are pinned to the window edges, so the column of type is capped and + centred in what is left between them. Auto inline margins on a flex item soak + up the leftover space, which keeps the type centred on screen because the two + rails are the same width. */ +.sheet { + flex: 1; + min-width: 0; + max-width: 904px; + margin-inline: auto; +} + +/* ---- header band: the strip a printer lays down before the report ---- */ + +.headerBand { + display: flex; + flex-direction: column; + padding: 22px var(--fan-gutter); +} + +.stars { + overflow: hidden; + white-space: nowrap; + font-size: 10.5px; + line-height: 19px; + letter-spacing: 0.06em; + color: var(--fan-stars); + user-select: none; +} + +.metaLine { + font-size: 10.5px; + line-height: 19px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fan-ink-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + + + +/* ---- display band ---- */ + +.displayBand { + padding: 30px var(--fan-gutter); +} + +.display { + margin: 0; + font-family: var(--fan-display); + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--fan-ink); + overflow-wrap: anywhere; +} + +/* A code gets the mock's masthead; a name gets one size on every page. */ +.display[data-size="code"] { + font-size: 96px; + line-height: 88px; +} + +.display[data-size="name"] { + font-size: 44px; + line-height: 46px; +} + +/* The lede stays a step above body copy. */ +.subtitle { + margin: 18px 0 0; + max-width: 620px; + font-size: 15px; + line-height: 26px; + color: var(--fan-ink-2); +} + +.byline { + padding: 0 var(--fan-gutter); +} + +/* ---- article ---- */ + +.article { + padding: 0 var(--fan-gutter) 12px; + font-size: 15.5px; + line-height: 28px; + color: var(--fan-ink); + /* Prose is capped at 680px and never overflows, so this only ever engages for + a table with more columns than the sheet can wrap into. Without it such a + table scrolls the whole page sideways, rails and all. */ + overflow-x: auto; +} + +.article > :first-child { + margin-top: 0; +} + +/* Apsara's paragraph, table and callout components set font-size, line-height + and letter-spacing on the elements themselves, so the article's values never + reach them by inheritance — body copy was rendering at 14px with the 28px + leading meant for 15.5px, a ratio of 2.0. Restated here so they land. + + The 1.71 ratio suits Geist Mono's 0.53em x-height, which is large for a + monospace — the text sits taller in the line and needs less leading than the + face the mock was drawn with. */ +.article :is(p, li, dd, dt) { + font-size: 14px; + line-height: 24px; + letter-spacing: 0; +} + +/* 600px is 71 characters at 14px, since a monospace advance is a flat 0.6em. + Holding the old 680px would have run the line to 81. */ +.article p, +.article ul, +.article ol, +.article dl { + max-width: 600px; + margin: 0 0 var(--rs-space-6); +} + +.article li { + margin-bottom: 6px; +} + +.article ul, +.article ol { + padding-left: 22px; +} + +.article ul { + list-style: none; +} + +/* Dashes rather than bullets — a dot-matrix printer had no round glyph. */ +.article ul > li::before { + content: "\2013"; + position: absolute; + margin-left: -22px; + color: var(--fan-ink-3); +} + +.article ul > li { + position: relative; +} + +.article h1, +.article h2, +.article h3, +.article h4, +.article h5, +.article h6 { + font-weight: 400; + text-transform: uppercase; + color: var(--fan-ink); + scroll-margin-top: 24px; +} + +.article h2 { + font-family: var(--fan-head); + font-size: 22px; + line-height: 26px; + letter-spacing: 0.06em; + margin: var(--rs-space-12) 0 22px; +} + +.article h3 { + font-family: var(--fan-head); + font-size: 16px; + line-height: 24px; + letter-spacing: 0.06em; + margin: var(--rs-space-10) 0 var(--rs-space-5); +} + +.article h4, +.article h5, +.article h6 { + font-size: 12px; + line-height: 20px; + letter-spacing: 0.1em; + color: var(--fan-ink-3); + margin: var(--rs-space-9) 0 var(--rs-space-4); +} + +.article a { + color: var(--fan-ink); + text-decoration: none; + border-bottom: 1px solid var(--fan-underline); +} + +.article a:hover { + border-bottom-color: var(--fan-ink); +} + +.article strong { + font-weight: 700; +} + +.article hr { + border: 0; + border-top: 1px dashed var(--fan-rule-strong); + margin: 44px 0; +} + +.article blockquote { + margin: 0 0 var(--rs-space-6); + max-width: 600px; + padding-left: var(--rs-space-6); + border-left: 1px solid var(--fan-rule-strong); + color: var(--fan-ink-2); +} + +.article img { + max-width: 100%; + height: auto; + border: 1px dashed var(--fan-rule); +} + +/* ---- callouts: a ruled aside, not a rounded card ---- + Apsara's Callout is the only thing in an article carrying aria-live, so that + attribute targets the container without depending on its hashed class. Its + inner elements share the same class prefix, hence the descendant reset. */ + +.article div[aria-live] { + max-width: 600px; + margin: 0 0 var(--rs-space-6); + padding: var(--rs-space-4) 0 var(--rs-space-4) 18px; + border: 0; + border-left: 2px solid var(--fan-rule-strong); + border-radius: 0; + background: none; + box-shadow: none; + color: var(--fan-ink-2); + font-size: 14px; + line-height: 24px; +} + +.article div[aria-live] * { + border: 0; + background: none; + box-shadow: none; +} + +/* Same proportional tracking leak as the table cells. */ +.article div[aria-live], +.article div[aria-live] * { + letter-spacing: 0; +} + +.article div[aria-live] :is(p, li) { + font-size: inherit; + line-height: inherit; +} + +.article div[aria-live] strong { + color: var(--fan-ink); +} + +/* The callout reset above clears borders and backgrounds, so the chip gets both + of its own back. */ +.article div[aria-live] code { + border: 1px solid var(--fan-chip-line); + background: var(--fan-wash); +} + +/* The reset above strips the border that draws a link's underline. */ +.article div[aria-live] a { + border-bottom: 1px solid var(--fan-underline); +} + +.article div[aria-live] a:hover { + border-bottom-color: var(--fan-ink); +} + +/* ---- inline code ---- + A wash with a hairline around it. The border is keyed to the fill rather than + to the paper — a step of about ten levels — so it draws the edge without + reading as a second mark competing with the fill, which is what made the + original bordered chip so heavy. + + Sized in `em` so a chip tracks whatever it sits in — body copy, a table cell, + a callout. No vertical padding, and an inline box's border does not enter the + line-height calculation, so nothing here pushes the lines of a paragraph + apart. + + `:not(pre) > code` rather than a list of parents: it catches inline code + wherever it appears and leaves listings alone. */ + +.article :not(pre) > code { + font-family: var(--fan-mono); + font-size: 0.92em; + padding: 0 3px; + border: 1px solid var(--fan-chip-line); + border-radius: 0; + background: var(--fan-wash); + color: var(--fan-ink); +} + +/* ---- code blocks: a ruled listing, not a card ---- + Dashed rules top and bottom frame the listing the way a printer separates a + block from body copy, and the line numbers sit in their own gutter. Lines are + set tighter than body copy: code is scanned down a column, not read across. */ + +.article div:has(> pre) { + margin: 26px 0; + border: 0; + border-radius: 0; + background: none; + overflow: visible; + max-width: 100%; +} + +/* The `title` bar becomes the printout's file label. */ +.article div:has(> pre) > :not(pre) { + padding: 0 0 9px; + background: none; + border: 0; + border-bottom: 1px solid var(--fan-rule); + font-family: var(--fan-mono); + font-size: 10.5px; + line-height: 18px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fan-ink-3); +} + +.article pre { + margin: 0; + padding: var(--rs-space-4) 0; + background: none; + border: 0; + border-top: 1px dashed var(--fan-rule); + border-bottom: 1px dashed var(--fan-rule); + border-radius: 0; + overflow-x: auto; +} + +/* A file label already draws the rule above the listing. */ +.article div:has(> pre) > :not(pre) + pre { + border-top: 0; +} + +.article pre code { + display: block; + font-family: var(--fan-mono); + font-size: 13px; + line-height: 22px; + counter-reset: fan-line; +} + +/* Shiki separates its line spans with real newline characters. Once those spans + are blocks, each newline becomes an anonymous block of its own and doubles the + spacing. Normal white-space processing drops whitespace between blocks, so the + newlines disappear and each line keeps `pre` handling for its own indentation. + The `:has` guard leaves a code block with no line spans untouched. */ +.article pre code:has(> :global(.line)) { + white-space: normal; +} + +.article pre code :global(.line) { + display: block; + white-space: pre; + counter-increment: fan-line; + min-height: 22px; +} + +/* Right-aligned so the digits stay in a column once a listing passes line 99. */ +.article pre code :global(.line)::before { + content: counter(fan-line, decimal-leading-zero); + display: inline-block; + width: 3ch; + margin-right: var(--rs-space-6); + text-align: right; + font-size: 11px; + color: var(--fan-ink-4); + user-select: none; +} + +.article pre code span { + color: var(--shiki-light); +} + +/* Shiki's palette is pitched for a bright editor and sits loud against paper + this muted. Pulling the saturation down keeps the token hierarchy readable + while letting the listing belong to the rest of the sheet. */ +.article pre code { + filter: saturate(0.45); +} + +:global([data-theme="dark"]) .article pre code span { + color: var(--shiki-dark); +} + +/* ---- tables: the field map ---- */ + +.article table { + width: 100%; + max-width: 100%; + margin: 26px 0; + border-collapse: collapse; + background: none; + border: 0; + font-size: 13.5px; + line-height: 24px; +} +/* Apsara tints the header band on `thead` itself, so clearing the row and the + cells leaves the colour behind. The field map wants ruled columns, not a + shaded strip. */ +.article thead, +.article thead tr, +.article tr { + background: none; + border: 0; +} + +/* Apsara's table cells are nowrap, which lets one long cell stretch the table + past the sheet and scroll the whole page sideways. A printout wraps its + columns instead, so wrapping is turned back on here. + + `break-word` is used rather than `anywhere` because `anywhere` also lowers a + column's minimum width, which lets the table algorithm split short headings + like "BITS" down the middle even when the sheet has room. A table too wide to + wrap scrolls inside the article instead — see the rule on `.article`. */ +.article :is(th, td) { + white-space: normal; + overflow-wrap: break-word; +} + +.article th { + padding: 0 var(--rs-space-5) 10px 0; + border: 0; + border-bottom: 1px solid var(--fan-rule-strong); + text-align: left; + vertical-align: bottom; + font-family: var(--fan-mono); + font-weight: 400; + font-size: 10.5px; + line-height: 18px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--fan-ink-3); + background: none; +} + +/* Apsara's cells carry 20px leading and 0.25px of tracking — spacing meant for a + proportional face, which reads gappy in mono and crowds the rows. */ +.article td { + padding: 13px var(--rs-space-5) 13px 0; + border: 0; + vertical-align: top; + font-family: var(--fan-mono); + font-size: 13.5px; + line-height: 22px; + letter-spacing: 0; + color: var(--fan-ink-2); + background: none; +} + +.article tbody td:first-child { + font-size: 14px; + font-weight: 500; + color: var(--fan-ink); +} + +/* The field-name column is the spine of a field map. Auto table layout shares + width by content, so a long notes column would otherwise break short names + like "Packet Version Number" across three lines. A plain length is used + because Chrome ignores a table cell's min-width once a percentage is in it, + which is why the narrow-screen value is set in the media query below. */ +.article :is(th, td):first-child { + min-width: 24ch; +} + +/* ---- footer band ---- */ + +.footerBand { + padding: 0 var(--fan-gutter) 56px; +} + +.footerRule { + border-top: 1px dashed var(--fan-rule-strong); + margin: 0 calc(var(--fan-gutter) * -1); +} + +.footerNav { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--rs-space-7); + padding-top: 26px; +} + +.footerLink { + font-size: 14px; + line-height: 26px; + text-transform: uppercase; + color: var(--fan-ink); + text-decoration: none; + border-bottom: 1px solid var(--fan-underline); +} + +.footerLink:hover { + border-bottom-color: var(--fan-ink); +} + +.footerLinkNext { + margin-left: auto; + text-align: right; +} + +.footerMeta { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--rs-space-7); + padding-top: 34px; + font-size: 10.5px; + line-height: 18px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fan-ink-3); +} + +/* ---- right rail: on this page ---- */ + +.pageNav { + width: var(--fan-rail); + flex-shrink: 0; + position: sticky; + top: 0; + max-height: 100vh; + overflow-y: auto; + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 34px var(--rs-space-9) var(--rs-space-13) var(--rs-space-7); + /* A long page can still outrun the window, so the rail stays scrollable — but + a track drawn down the edge of the sheet reads as a tear in the paper. */ + scrollbar-width: none; +} + +.pageNav::-webkit-scrollbar { + display: none; +} + +.pageNavLabel { + font-size: 10px; + line-height: 16px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--fan-ink-3); + margin-bottom: var(--rs-space-3); +} + + +/* Headings wrap rather than truncate — a cut heading loses the words that tell + it from its neighbours. Lines within one heading are set tighter than the gap + between headings (4px against 9px), so a second line still reads as a + continuation rather than a new entry. */ +.pageNavItem { + position: relative; + display: flex; + align-items: flex-start; + gap: 7px; + margin-bottom: 9px; + font-size: 12.5px; + line-height: 16px; + letter-spacing: 0; + color: var(--fan-ink-3); + text-decoration: none; +} + +.pageNavItemText { + min-width: 0; + overflow-wrap: break-word; +} + +.pageNavItem:hover { + color: var(--fan-ink); +} + +/* Nothing wraps now, so the indent means one thing only: this heading is nested + under the one above it. */ +.pageNavItemNested { + padding-left: var(--rs-space-4); +} + +.pageNavItem[data-active="true"] { + color: var(--fan-ink); + font-weight: 500; +} + +/* The same struck square the left rail uses to mark the current line. It sits in + the gutter rather than in the text flow, so an active heading stays on the + same left edge as every other one. */ +.pageNavItem[data-active="true"]::before { + content: ""; + position: absolute; + left: -8px; + /* Pinned to the middle of the first line, not the middle of the box, so it + stays level with the heading when the heading runs to two lines. */ + top: 6px; + width: 4px; + height: 4px; + background: var(--fan-ink); +} + +/* A nested heading is already inset, so the marker steps in with it. */ +.pageNavItemNested[data-active="true"]::before { + left: 4px; +} + +@media (max-width: 1080px) { + .pageNav { + display: none; + } + + .display[data-size="code"] { + font-size: 64px; + line-height: 60px; + } + + .display[data-size="name"] { + font-size: 38px; + line-height: 40px; + } +} + +@media (max-width: 640px) { + .article { + font-size: 14.5px; + line-height: 26px; + } + + .article :is(th, td):first-child { + min-width: 10ch; + } + + .display[data-size="code"] { + font-size: 44px; + line-height: 44px; + } + + .display[data-size="name"] { + font-size: 30px; + line-height: 33px; + } +} diff --git a/packages/chronicle/src/themes/fanfold/Page.tsx b/packages/chronicle/src/themes/fanfold/Page.tsx new file mode 100644 index 00000000..c69ac1f0 --- /dev/null +++ b/packages/chronicle/src/themes/fanfold/Page.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { getBreadcrumbItems } from 'fumadocs-core/breadcrumb'; +import { flattenTree } from 'fumadocs-core/page-tree'; +import { useMemo } from 'react'; +import { Link as RouterLink, useLocation } from 'react-router'; +import { AuthorByline } from '@/components/common/author-byline'; +import { getActiveContentDir } from '@/lib/navigation'; +import { usePageContext } from '@/lib/page-context'; +import { + filterPageTreeByContentDir, + filterPageTreeByVersion +} from '@/lib/version-source'; +import type { ThemePageProps } from '@/types'; +import styles from './Page.module.css'; +import { PageNav } from './PageNav'; + +/** The asterisk rule a printer lays down before a report. Exported so the + * skeleton draws the same one rather than keeping its own copy. */ +export const STARS = '*'.repeat(400); + +const pad = (n: number) => String(n).padStart(2, '0'); + +/** + * A title this short is a code or a command — `SPP`, `XTCE`, `astro spp` — and + * gets the masthead. Tune this and nothing else: it is the only place the + * display has a boundary. + */ +const MASTHEAD_MAX_CHARS = 10; + +/** + * Two sizes, not a ladder. Sizing in several steps by character count meant one + * character could cost 30% of the size, so `Space Packet Protocol` and + * `Encapsulation Packet Protocol` — siblings in the same folder — came out + * visibly different. Every title that is a name now renders at one size. + */ +function displaySize(title: string): 'code' | 'name' { + return title.length <= MASTHEAD_MAX_CHARS ? 'code' : 'name'; +} + +export function Page({ page, config, tree }: ThemePageProps) { + const { pathname } = useLocation(); + const { version } = usePageContext(); + + const contentDir = getActiveContentDir(pathname, config); + const section = config.content?.find(c => c.dir === contentDir)?.label; + + /** + * The printed header reads as a report on one section, so the trail and the + * page counter are both scoped to the content directory being read. + * + * The tree may or may not already be scoped: `entry-server` unwraps it itself + * when a site has a single content directory. Scoping an unwrapped tree again + * silently returns its first sub-folder — or nothing, for a flat directory — + * which emptied the trail and dropped the counter. So the narrower tree is + * only taken when it still contains the page being rendered. + */ + const sectionTree = useMemo(() => { + const versioned = filterPageTreeByVersion(tree, version, config); + const scoped = filterPageTreeByContentDir(versioned, version, contentDir); + const holdsThisPage = flattenTree(scoped.children).some( + p => p.url === pathname + ); + return holdsThisPage ? scoped : versioned; + }, [tree, version, config, contentDir, pathname]); + + const crumbs = useMemo( + () => + getBreadcrumbItems(pathname, sectionTree, { includePage: true }).map( + item => item.name + ), + [pathname, sectionTree] + ); + + // "PAGE 03 / 22" — where this page falls in the section being read. + const { index, total } = useMemo(() => { + const pages = flattenTree(sectionTree.children); + return { + index: pages.findIndex(p => p.url === pathname) + 1, + total: pages.length + }; + }, [sectionTree, pathname]); + const title = page.frontmatter.title ?? ''; + + const trail = [section, ...crumbs].filter(Boolean).join(' / '); + const counter = index > 0 ? `PAGE ${pad(index)} / ${pad(total)}` : null; + + return ( +
+
+
+ +
+ ** {trail} + {counter ? ` * ${counter}` : ''} +
+
+ ** {config.site.title} + {section ? ` * ${section}` : ''} +
+
** {pathname}
+
+ +
+

+ {title} +

+ {page.frontmatter.description ? ( +

{page.frontmatter.description}

+ ) : null} +
+ + {page.frontmatter.authors?.length ? ( +
+ +
+ ) : null} + +
+ {page.content} +
+ +
+
+ {page.prev || page.next ? ( +
+ {page.prev ? ( + + ← {page.prev.title} + + ) : null} + {page.next ? ( + + {page.next.title} → + + ) : null} +
+ ) : null} +
+ ** {config.site.description ?? config.site.title} ** + {counter ? {counter} : null} +
+
+
+ + +
+ ); +} diff --git a/packages/chronicle/src/themes/fanfold/PageNav.tsx b/packages/chronicle/src/themes/fanfold/PageNav.tsx new file mode 100644 index 00000000..70e421a1 --- /dev/null +++ b/packages/chronicle/src/themes/fanfold/PageNav.tsx @@ -0,0 +1,58 @@ +'use client'; + +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 './Page.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)) { + return nodeToText((node.props as { children?: ReactNode }).children); + } + return ''; +} + +export function PageNav({ items }: { items: TableOfContents }) { + const headings = items.filter(item => item.depth >= 2 && item.depth <= 3); + + return ( + + + + ); +} + +function Headings({ items }: { items: TOCItemType[] }) { + const active = useActiveAnchor(); + + return ( + + ); +} + diff --git a/packages/chronicle/src/themes/fanfold/Skeleton.tsx b/packages/chronicle/src/themes/fanfold/Skeleton.tsx new file mode 100644 index 00000000..8a4b7806 --- /dev/null +++ b/packages/chronicle/src/themes/fanfold/Skeleton.tsx @@ -0,0 +1,28 @@ +import { Skeleton } from '@raystack/apsara'; +import { STARS } from './Page'; +import styles from './Page.module.css'; + +export function PageSkeleton() { + return ( +
+
+
+ + + + +
+
+ +
+
+ {[...new Array(18)].map((_, i) => ( + + ))} +
+
+
+ ); +} diff --git a/packages/chronicle/src/themes/fanfold/index.ts b/packages/chronicle/src/themes/fanfold/index.ts new file mode 100644 index 00000000..8c6a8c6b --- /dev/null +++ b/packages/chronicle/src/themes/fanfold/index.ts @@ -0,0 +1,12 @@ +import type { Theme } from '@/types'; +import { Landing } from './Landing'; +import { Layout } from './Layout'; +import { Page } from './Page'; +import { PageSkeleton } from './Skeleton'; + +export const fanfoldTheme: Theme = { + Layout, + Page, + Landing, + Skeleton: PageSkeleton, +}; diff --git a/packages/chronicle/src/themes/paper/fonts/DepartureMono-Regular.woff2 b/packages/chronicle/src/themes/fonts/DepartureMono-Regular.woff2 similarity index 100% rename from packages/chronicle/src/themes/paper/fonts/DepartureMono-Regular.woff2 rename to packages/chronicle/src/themes/fonts/DepartureMono-Regular.woff2 diff --git a/packages/chronicle/src/themes/fonts/departure-mono.css b/packages/chronicle/src/themes/fonts/departure-mono.css new file mode 100644 index 00000000..4f244e66 --- /dev/null +++ b/packages/chronicle/src/themes/fonts/departure-mono.css @@ -0,0 +1,12 @@ +/* Declared once, here, rather than in each theme's CSS module. + The paper and fanfold themes both set this face. Two identical @font-face + rules in one bundle is harmless but pointless, and when they pointed at + per-theme copies of the file it also shipped the 22KB twice. registry.ts + imports this as a plain stylesheet, so it lands exactly once. */ +@font-face { + font-family: "Departure Mono"; + src: url("./DepartureMono-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} diff --git a/packages/chronicle/src/themes/paper/Layout.module.css b/packages/chronicle/src/themes/paper/Layout.module.css index 4518e9ee..e6fdf96b 100644 --- a/packages/chronicle/src/themes/paper/Layout.module.css +++ b/packages/chronicle/src/themes/paper/Layout.module.css @@ -1,13 +1,5 @@ @import url("https://fonts.googleapis.com/css2?family=Hanuman:wght@400;700&display=swap"); -@font-face { - font-family: "Departure Mono"; - src: url("./fonts/DepartureMono-Regular.woff2") format("woff2"); - font-weight: 400; - font-style: normal; - font-display: swap; -} - .layout { --paper-sidebar-width: 262px; /* Consumed here and by Page.module.css, which parks the page navbar below diff --git a/packages/chronicle/src/themes/registry.ts b/packages/chronicle/src/themes/registry.ts index 8628abff..d859a2ec 100644 --- a/packages/chronicle/src/themes/registry.ts +++ b/packages/chronicle/src/themes/registry.ts @@ -1,10 +1,13 @@ +import './fonts/departure-mono.css'; import type { Theme } from '@/types'; import { defaultTheme } from './default'; +import { fanfoldTheme } from './fanfold'; import { paperTheme } from './paper'; const themes: Record = { default: defaultTheme, - paper: paperTheme + paper: paperTheme, + fanfold: fanfoldTheme }; export function getTheme(name?: string): Theme { diff --git a/packages/chronicle/src/types/config.ts b/packages/chronicle/src/types/config.ts index 6ef750cd..fe633d67 100644 --- a/packages/chronicle/src/types/config.ts +++ b/packages/chronicle/src/types/config.ts @@ -7,7 +7,7 @@ const logoSchema = z.object({ }) const themeSchema = z.object({ - name: z.enum(['default', 'paper']), + name: z.enum(['default', 'paper', 'fanfold']), colors: z.record(z.string(), z.string()).optional(), }) diff --git a/packages/chronicle/src/types/content.ts b/packages/chronicle/src/types/content.ts index 90819b0f..621b2956 100644 --- a/packages/chronicle/src/types/content.ts +++ b/packages/chronicle/src/types/content.ts @@ -20,6 +20,12 @@ export interface Author { export interface Frontmatter { title: string + /** + * Short label for navigation — a package or command name such as `SPP`. Falls + * back to `title`. Useful where the full title is too long for a sidebar but + * the page is known by a code its readers already use. + */ + short?: string description?: string order?: number icon?: string @@ -60,3 +66,19 @@ export interface Page extends PageNav { content: ReactNode toc: TableOfContents } + +/** + * One content root offered on the landing page. Built from config rather than + * from the page tree, so it is available before any page is loaded. + * + * It lives here rather than beside `getLandingEntries` in `lib/config.ts` + * because `types/theme.ts` needs it for the `Landing` slot, and a type in + * `types/` importing from `lib/` would close an import cycle. + */ +export interface LandingEntry { + label: string + description?: string + href: string + contentDir: string + icon?: string +} diff --git a/packages/chronicle/src/types/theme.ts b/packages/chronicle/src/types/theme.ts index 627a8dd2..0b30145b 100644 --- a/packages/chronicle/src/types/theme.ts +++ b/packages/chronicle/src/types/theme.ts @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' import type { Root } from 'fumadocs-core/page-tree' import type { ChronicleConfig } from './config' -import type { Page } from './content' +import type { LandingEntry, Page } from './content' export interface ThemeLayoutProps { children: ReactNode @@ -17,9 +17,30 @@ export interface ThemePageProps { tree: Root } +/** + * Props for a theme's own landing page. Everything is resolved by the shared + * `LandingPage` wrapper, so a theme's `Landing` is presentation only — it never + * reads config or the router itself. + */ +export interface ThemeLandingProps { + config: ChronicleConfig + /** Content roots to offer, in config order. Never empty when this renders. */ + entries: LandingEntry[] + /** Site title, suffixed with the version label when one is being viewed. */ + heading: string + description?: string + /** Label of the version being viewed, latest included. Null if unlabelled. */ + versionLabel: string | null +} + export interface Theme { Layout: React.ComponentType Page: React.ComponentType Skeleton: React.ComponentType + /** + * Optional. Themes that leave this out fall back to the shared landing page + * in `pages/LandingPage.tsx`. + */ + Landing?: React.ComponentType className?: string }