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 }