diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index ed73031..9c407c6 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -50,7 +50,9 @@ jobs: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} # `--branch` must match the project's production branch for this to # publish to the live domain rather than to a preview URL. + # TanStack Start writes the prerendered site to `dist/client`; the + # server build beside it in `dist/server` only runs the prerender. command: >- - pages deploy packages/docs/dist + pages deploy packages/docs/dist/client --project-name=keyrove --branch=main diff --git a/.gitignore b/.gitignore index b3c7653..d14262f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ dist/ .tmp .pnpm-store/ + +# TanStack Router writes its generator scratch files here during dev and build. +.tanstack/ diff --git a/.prettierignore b/.prettierignore index bd2ac2a..7438261 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,6 @@ coverage/ pnpm-lock.yaml *.log CHANGELOG.md + +# Generated by TanStack Router on every dev start and build. +packages/docs/src/routeTree.gen.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d3e8146..5b5a503 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -132,22 +132,22 @@ leaving a document that stands on its own when fetched in isolation. ### Documentation search -The header search button and `Cmd+K` / `Ctrl+K` open a native dialog. MiniSearch -and `search-index.json` load on first use. The index contains docs page leads -and individual sections, with heading IDs allocated by the same parser as the -rendered pages; landing and `noindex` pages are excluded. Titles and headings -rank above body matches, with prefix matching and typo tolerance enabled. - -The results are a keyrove group: the site navigating with its own library. -Each result is a link and keeps its own tab stop, so Tab walks the results as -well as the arrows; `keyRove` moves focus between the links, looping at the -ends. keyrove leaves a text field its caret keys, so the box -focuses an end of the list itself to hand focus over, and a character typed on -a result sends focus back to the box. - -The Vite plugin generates the index in development and production. Markdown -edits invalidate the development cache and reload the page, including search. -Run the search indexing tests with `pnpm --filter @mixedrays/keyrove/docs test`. +The header search button and `Cmd+K` / `Ctrl+K` open a dialog. The dialog's +code, MiniSearch and `search-index.json` load on first use. The index contains +docs page leads and individual sections, with heading IDs allocated by the +same parser as the rendered pages; landing and `noindex` pages are excluded. +Titles and headings rank above body matches, with prefix matching and typo +tolerance enabled. + +The dialog is Base UI's, with its autocomplete inside: focus stays in the +box while the arrows move a highlight through the results, and Enter follows +the highlighted result — the first one until the reader moves it. Each result +is a link, so a modified click still opens it in a new tab. + +The content plugin generates the index in development and production. +Markdown edits invalidate the development cache and reload the page, +including search. Run the search indexing tests with +`pnpm --filter @mixedrays/keyrove/docs test`. ### Sidebar navigation @@ -159,10 +159,10 @@ opens DevTools). The shortcuts use `focusKeys` to return to each group's tab stop. The left group starts on the current page; the right one follows the active section while focus is outside it. The left shortcut opens the mobile drawer when needed; shortcuts leave an -open search dialog alone. The closed drawer is `visibility: hidden`, so its -links are out of the Tab order. Each sidebar shows its shortcut beside its -first heading on hover, and the arrow keys as well while keyboard focus is -inside it. +open search dialog alone. The drawer is a Base UI sheet: it exists only while +open, traps focus while it is, and opens on the current page's link. Each +sidebar shows its shortcut beside its first heading on hover, and the arrow +keys as well while keyboard focus is inside it. ### Live demos @@ -180,7 +180,10 @@ Layout the demo needs but the library does not teach — the grid's columns, the long list's scroll box — goes in `data-demo-class` on the placeholder rather than in the fragment, so what a reader copies is markup they can paste as-is. [packages/docs/src/demos.ts](packages/docs/src/demos.ts) wires the behaviour -markup cannot carry: the keydown listener, the move log, and the copy button. +markup cannot carry: the keydown listener and the move log. React draws the +panel around a demo, with its code tabs and copy button, but leaves the live +preview as the fragment's own markup, which keyrove rearranges as keys are +pressed. For a compact **demo panel** on any docs page, add a label: @@ -207,18 +210,51 @@ are required. Code-only examples can use a `
` around adjacent fenced blocks, with blank lines between the wrapper and the fences. -### Build +### UI components + +The site is a [TanStack Start](https://tanstack.com/start) app, and its UI +components are [shadcn/ui](https://ui.shadcn.com) on +[Base UI](https://base-ui.com) primitives, in +[packages/docs/src/components/ui](packages/docs/src/components/ui). They keep +shadcn's API but not its styles: each passes the class style.css already draws +that part of the site with, since a Tailwind utility on the component would +outrank every rule in the stylesheet's components layer. `components.json` +is set up for the shadcn CLI, so `pnpm dlx shadcn@latest add ` +works from `packages/docs`. -[packages/docs/vite-plugin-docs.ts](packages/docs/vite-plugin-docs.ts) drives -both modes from one renderer. In dev a middleware renders on request; in the -build, Vite bundles `index.html` once and every page is stamped out of the -result, so all pages share one set of hashed asset URLs. +Keyboard navigation is split by who has it built in. Base UI owns it where its +component does — the code tabs and the landing page's pattern tabs, the search +results, the dialog's and drawer's focus trap. keyrove owns everything Base UI +has no component for: the sidebar and "On this page" rails, the 404's links, +the landing hero and every demo. + +### Build -Pages are emitted as `dist/docs/api.html` and served at extensionless URLs -(`/docs/api`), which is what lets `.md` be appended. Cloudflare Pages redirects -`/docs/api/` there, so the URL that links, canonical tags and the sitemap name -answers directly. Extensionless URLs rule out relative asset paths, so links -are absolute to `base` — set `DOCS_BASE` when deploying to a subpath: +[packages/docs/vite-plugin-content.ts](packages/docs/vite-plugin-content.ts) +hands the content to the app as virtual modules: the sidebar and each page's +metadata in one, each page's rendered body in a chunk of its own, and the +generated files — the `.md` twins, `llms.txt`, the sitemap — in a third that +only the server routes under +[packages/docs/src/routes](packages/docs/src/routes) import. Markdown renders +through the same code in dev and in the build. A page's body reaches the +browser as a tree of elements built at build time rather than as HTML, so +there is no HTML parser in the bundle and nothing for hydration to disagree +about. + +`pnpm build` prerenders every page, every `.md` twin and every generated file +into `dist/client`, which is what Cloudflare Pages serves; nothing runs on a +server. The first page a reader opens is that HTML. Following a link from there +swaps the page in place, loading only that page's chunk — hovering the link +starts the download. + +Pages are emitted as `dist/client/docs/api.html` and served at extensionless +URLs (`/docs/api`), which is what lets `.md` be appended. Cloudflare Pages +redirects `/docs/api/` there, so the URL that links, canonical tags and the +sitemap name answers directly. The not-found page is prerendered to +`404.html`, which Pages serves for any URL it has no file for; the router then +hydrates it as the page it was rendered as. Extensionless URLs rule out +relative asset paths, so links are absolute to `base` — set `DOCS_BASE` when +deploying to a subpath: ```sh DOCS_BASE=/keyrove/ pnpm --filter @mixedrays/keyrove/docs build diff --git a/packages/bench/README.md b/packages/bench/README.md index fed4662..4e717d7 100644 --- a/packages/bench/README.md +++ b/packages/bench/README.md @@ -112,7 +112,7 @@ to see reports saved since. | `src/types.ts` | The shape of a report, as `--json` writes it. | | `results/` | Saved reports. `recorded.json` is the run the Results below come from. | -The UI uses the docs site's stylesheet and theme toggle directly from +The UI uses the docs site's stylesheet and theme helpers directly from `packages/docs/src`, so it looks like the site with no copy to keep in step. The reports in `results/` and the modules in `src/` other than `main.ts` do diff --git a/packages/bench/src/main.ts b/packages/bench/src/main.ts index 42ecc78..3618cf6 100644 --- a/packages/bench/src/main.ts +++ b/packages/bench/src/main.ts @@ -1,4 +1,4 @@ -import { mountThemeToggle } from '../../docs/src/theme.ts'; +import { themeLabel, toggleTheme } from '../../docs/src/theme.ts'; import { icon } from './icons.ts'; import { CASE_COUNT, DEFAULT_OPTIONS } from './plan.ts'; import { toComparisonMarkdown, toMarkdown } from './report.ts'; @@ -81,6 +81,7 @@ const $ = (selector: string) => document.querySelector(selector)!; const ui = { + themeToggle: $('[data-theme-toggle]'), form: $('[data-run-form]'), run: $('[data-run]'), stop: $('[data-stop]'), @@ -573,7 +574,6 @@ const startRun = async () => { /* ---------- wiring ---------- */ -mountThemeToggle(); renderChecks(); fillSettings(DEFAULT_OPTIONS); for (const element of document.querySelectorAll('[data-case-count]')) { @@ -593,6 +593,9 @@ document.addEventListener('visibilitychange', () => { if (controller && document.hidden) wasHidden = true; }); +ui.themeToggle.addEventListener('click', () => + ui.themeToggle.setAttribute('aria-label', themeLabel(toggleTheme())), +); ui.settingsToggle.addEventListener('click', () => setSettingsOpen(ui.settingsToggle.getAttribute('aria-expanded') !== 'true'), ); diff --git a/packages/docs/build/content.ts b/packages/docs/build/content.ts index 66fe648..24ddddc 100644 --- a/packages/docs/build/content.ts +++ b/packages/docs/build/content.ts @@ -11,7 +11,7 @@ import { loadLastModified } from './git.ts'; * * Nothing here renders anything — this module answers "what pages exist, what * do they claim about themselves, and how do they group in the sidebar", and - * the render step in `layout.ts` takes it from there. + * the content plugin in `vite-plugin-content.ts` takes it from there. */ export const CONTENT_DIR = fileURLToPath( diff --git a/packages/docs/build/head.ts b/packages/docs/build/head.ts new file mode 100644 index 0000000..8482ec4 --- /dev/null +++ b/packages/docs/build/head.ts @@ -0,0 +1,122 @@ +import type { NavGroup, Page } from './content.ts'; +import { META } from './meta.ts'; +import { createHrefResolver, routeToPath, toMarkdownPath } from './paths.ts'; +import { toStructuredData } from './structured-data.ts'; + +/** + * A page's ``, in the shape a TanStack Router `head()` returns: the + * title, the description, and what a crawler and a link unfurler need. + * + * Worked out at build time beside the page's HTML, so the browser bundle never + * carries the manifests or the site's origin around just to rebuild it. + */ + +export type HeadMeta = + | { title: string } + | { name: string; content: string } + | { property: string; content: string } + | { 'script:ld+json': Record }; + +export type HeadLink = { rel: string; href: string; type?: string }; + +export type Head = { meta: HeadMeta[]; links: HeadLink[] }; + +/** + * The social card, reused by every page: 1200x630, served from `public/`. + * + * One image rather than one per page — the card carries the library's name and + * what it does, which is what an unfurled link needs to say whichever page was + * shared. + */ +const OG_IMAGE = { + path: '/og.png', + width: 1200, + height: 630, + alt: 'keyrove — framework-agnostic keyboard navigation for lists, grids and trees.', +} as const; + +/** + * Every page hangs its own name off the wordmark, except the landing page, + * whose name is the wordmark — leaving it to describe itself at a length no + * search result or link unfurl will show. `titleTag` in frontmatter is how + * either falls back to a line written for the slot. + */ +const toTitle = (page: Page) => + page.titleTag ?? + (page.layout === 'landing' + ? `keyrove — ${page.description}` + : `${page.title} — keyrove`); + +/** + * Which URL is the canonical one, and what to show when the page is shared. + * + * Every route has a `.md` twin at a sibling path, so which of the two is the + * indexable one has to be said outright rather than left to be guessed — and + * the twin is named as an alternate, so an agent holding the page finds the + * markdown without guessing at its URL. A page marked `noindex` skips all of it + * and says so instead — it is served at every dead URL, so it has no canonical + * URL of its own to claim. + */ +export const toHead = (page: Page, nav: NavGroup[], base: string): Head => { + const title = toTitle(page); + const meta: HeadMeta[] = [ + { title }, + { name: 'description', content: page.description }, + ]; + + if (page.noindex) { + return { + meta: [...meta, { name: 'robots', content: 'noindex, follow' }], + links: [], + }; + } + + const resolveHref = createHrefResolver(base); + const toUrl = (route: string) => + `${META.siteUrl}${resolveHref(routeToPath(route))}`; + const url = toUrl(page.route); + const image = `${META.siteUrl}${resolveHref(OG_IMAGE.path)}`; + + return { + meta: [ + ...meta, + { + property: 'og:type', + content: page.layout === 'landing' ? 'website' : 'article', + }, + { property: 'og:site_name', content: 'keyrove' }, + // The prose is American — "license", "color", "behavior". That is also + // what an absent og:locale is taken to mean, so this only says it outright. + { property: 'og:locale', content: 'en_US' }, + { property: 'og:title', content: title }, + { property: 'og:description', content: page.description }, + { property: 'og:url', content: url }, + { property: 'og:image', content: image }, + { property: 'og:image:width', content: String(OG_IMAGE.width) }, + { property: 'og:image:height', content: String(OG_IMAGE.height) }, + { property: 'og:image:alt', content: OG_IMAGE.alt }, + { name: 'twitter:card', content: 'summary_large_image' }, + { name: 'twitter:title', content: title }, + { name: 'twitter:description', content: page.description }, + { name: 'twitter:image', content: image }, + { + 'script:ld+json': toStructuredData({ + page, + nav, + toUrl, + imageUrl: image, + }), + }, + ], + links: [ + { rel: 'canonical', href: url }, + // `type` names the format. The twin is served as `text/plain` so that a + // browser shows it rather than downloading it — see public/_headers. + { + rel: 'alternate', + type: 'text/markdown', + href: `${META.siteUrl}${resolveHref(toMarkdownPath(page.route))}`, + }, + ], + }; +}; diff --git a/packages/docs/build/html-tree.ts b/packages/docs/build/html-tree.ts new file mode 100644 index 0000000..11c703d --- /dev/null +++ b/packages/docs/build/html-tree.ts @@ -0,0 +1,93 @@ +import { + attributesToProps, + domToReact, + type DOMNode, + type Element as DomElement, +} from 'html-react-parser'; +import { DomUtils, parseDocument } from 'htmlparser2'; + +/** + * A page's rendered HTML as a tree React can draw without parsing anything. + * + * Parsed here, at build time, so the browser never has to: parsing there + * would mean shipping an HTML parser, its entity tables and an attribute map + * to every reader — more code than draws the chrome. The page's chunk carries + * the result instead: every element as its tag, the props React takes for its + * attributes, and its children. Server and browser render the one tree, so + * hydration has nothing to disagree about. + * + * html-react-parser does the conversion — attribute names, inline styles, + * whitespace React rejects inside tables — with JSON standing in for React + * elements, so the tree is exactly the elements it would have built. + * src/components/markdown.tsx walks it. + */ + +export type HtmlProps = Record; + +/** Text, or `[tag, props, children]`. */ +export type HtmlNode = string | HtmlElement; + +export type HtmlElement = [tag: string, props: HtmlProps, children: HtmlNode[]]; + +const toChildren = (children: unknown): HtmlNode[] => + children === undefined || children === null + ? [] + : Array.isArray(children) && !isElement(children) + ? (children as HtmlNode[]) + : [children as HtmlNode]; + +const isElement = (value: unknown): value is HtmlElement => + Array.isArray(value) && + typeof value[0] === 'string' && + typeof value[1] === 'object' && + Array.isArray(value[2]); + +/** + * React's element API, writing JSON. The `key` html-react-parser numbers + * siblings with is dropped: the walker keys each element by its position as + * it draws it, so the tree need not carry one on every node. + */ +const library = { + createElement: ( + tag: string, + { key: _key, ...props }: HtmlProps = {}, + children?: unknown, + ) => [tag, props, toChildren(children)] satisfies HtmlElement, + cloneElement: (element: HtmlElement) => element, + isValidElement: isElement, +}; + +const hasClass = (element: DomElement, name: string) => + (element.attribs.class ?? '').split(/\s+/).includes(name); + +/** + * Elements whose contents React draws once and never touches again: a demo's + * live preview, which keyrove and the demo's own script rearrange as the + * reader presses keys, and the readouts they write to. They are kept as HTML, + * for `dangerouslySetInnerHTML`, rather than as a tree React would reconcile. + */ +const isOpaque = (element: DomElement) => + hasClass(element, 'demo-preview') || + hasClass(element, 'demo-panel-preview') || + 'data-demo-readout' in element.attribs || + 'data-hero-readout' in element.attribs; + +export const toHtmlTree = (html: string): HtmlNode[] => { + const options = { + library, + replace: (node: DOMNode) => { + if (node.type !== 'tag' || !isOpaque(node as DomElement)) return; + const element = node as DomElement; + + return library.createElement(element.name, { + ...attributesToProps(element.attribs, element.name), + dangerouslySetInnerHTML: { __html: DomUtils.getInnerHTML(element) }, + }); + }, + }; + + const document = parseDocument(html, { lowerCaseAttributeNames: false }); + return toChildren( + domToReact(document.children as DOMNode[], options as never), + ); +}; diff --git a/packages/docs/build/icons.ts b/packages/docs/build/icons.ts index 293b77c..2ee1ae3 100644 --- a/packages/docs/build/icons.ts +++ b/packages/docs/build/icons.ts @@ -3,9 +3,6 @@ import { ArrowRight, ArrowRightToLine, Bold, - BookOpen, - Braces, - Check, Circle, CircleCheck, CircleDashed, @@ -13,7 +10,6 @@ import { CircleSlash, Code, Command, - Copy, FileCode, FileDown, FileJson, @@ -25,14 +21,10 @@ import { Hand, Inbox, Italic, - Keyboard, Layers, - LayoutGrid, Link, Mail, - Menu, MessageSquare, - Moon, MousePointer2, PenTool, Pin, @@ -40,76 +32,36 @@ import { RefreshCw, Reply, Scan, - Search, Send, ShieldAlert, SkipForward, Sparkles, Square, - Sun, TextCursorInput, Trash2, Type, Underline, - X, - type IconNode, } from 'lucide'; -/** - * Icons, rendered into the HTML at build time. - * - * They used to be `` placeholders that main.ts swapped for - * glyphs on load, which meant every navigation showed a row of empty boxes - * until the bundle ran. Serialising them here costs nothing at runtime — the - * icon data is plain arrays — and takes `lucide` out of the shipped bundle - * entirely. - */ +import { + CHROME_ICONS, + fromLucide, + LUCIDE_STROKE_WIDTH, + type IconDef, +} from '../src/icons.ts'; /** - * An icon's geometry, independent of the size it is drawn at. + * Icons, rendered into the page HTML at build time. * - * `strokeWidth` is in the icon's own viewBox units rather than pixels, so an - * icon drawn on a larger grid can still be given the same visual weight as the - * rest — which is the only reason this is per-icon and not a constant. A mark - * drawn as a fill rather than a stroke sets it to `null` and carries its own - * `fill` on the path. + * They used to be `` placeholders that the bundle swapped for + * glyphs on load, which meant every navigation showed a row of empty boxes + * until it ran. Serialising them here costs nothing at runtime — the icon data + * is plain arrays — and keeps all but the chrome's own icons (src/icons.ts) + * out of the shipped bundle. */ -type IconDef = { - viewBox: string; - strokeWidth: number | null; - body: string; -}; - -const renderChild = ([tag, attrs]: IconNode[number]) => { - const rendered = Object.entries(attrs) - .map(([key, value]) => `${key}="${String(value).replace(/"/g, '"')}"`) - .join(' '); - - return `<${tag} ${rendered} />`; -}; - -/** Lucide's default 2px stroke reads heavy next to 14px nav text. */ -const LUCIDE_STROKE_WIDTH = 1.75; - -/** Lucide ships every icon on a 24-unit grid at a nominal 2px stroke. */ -const fromLucide = (node: IconNode): IconDef => ({ - viewBox: '0 0 24 24', - strokeWidth: LUCIDE_STROKE_WIDTH, - body: node.map(renderChild).join(''), -}); const ICONS = { - search: fromLucide(Search), - close: fromLucide(X), - menu: fromLucide(Menu), - book: fromLucide(BookOpen), - keyboard: fromLucide(Keyboard), - grid: fromLucide(LayoutGrid), - braces: fromLucide(Braces), - copy: fromLucide(Copy), - check: fromLucide(Check), - sun: fromLucide(Sun), - moon: fromLucide(Moon), + ...CHROME_ICONS, 'arrow-right': fromLucide(ArrowRight), // The landing page's feature cards and next steps, placed from the markdown @@ -159,45 +111,6 @@ const ICONS = { 'circle-dot': fromLucide(CircleDot), 'circle-check': fromLucide(CircleCheck), archive: fromLucide(Archive), - - /* - * The markdown mark — a filled glyph on a 16-unit grid, so it opts out of the - * stroke attributes the rest of the set is drawn with. - */ - markdown: { - viewBox: '0 0 16 16', - strokeWidth: null, - body: '', - }, - - /* - * The npm mark, from simple-icons. A brand mark, so — like the GitHub one - * below — it is not from lucide, which dropped them in v1. - * - * Drawn as a fill rather than a stroke: the glyph is a solid square with the - * wordmark knocked out of it, which is what makes it read as npm at 16px. - */ - npm: { - viewBox: '0 0 24 24', - strokeWidth: null, - body: '', - }, - - /* - * The GitHub mark, from svgrepo.com/show/504388. Lucide dropped brand marks - * in v1, so this is the one icon on the site that is not from the set. - * - * Drawn on a 192-unit grid rather than 24, so its stroke is scaled to match: - * 14/192 is the same ratio as the 1.75/24 the lucide icons use, which is what - * keeps it from reading lighter than its neighbours in the header. The - * source's own `stroke="#000000"` is dropped — the wrapper below sets - * `currentColor`, without which the mark would stay black in dark mode. - */ - github: { - viewBox: '0 0 192 192', - strokeWidth: 14, - body: '', - }, } satisfies Record; export type IconName = keyof typeof ICONS; diff --git a/packages/docs/build/layout.ts b/packages/docs/build/layout.ts deleted file mode 100644 index 20a8ecc..0000000 --- a/packages/docs/build/layout.ts +++ /dev/null @@ -1,502 +0,0 @@ -import path from 'node:path'; - -import { CONTENT_DIR, type NavGroup, type Page } from './content.ts'; -import { icon } from './icons.ts'; -import type { Heading } from './markdown.ts'; -import { META } from './meta.ts'; -import { renderStructuredData } from './structured-data.ts'; - -/** - * The HTML around a rendered markdown body: header, sidebar, "On this page" - * rail, pager, footer. - * - * Every one of these is emitted at build time rather than assembled in the - * browser, so a page arrives complete — no nav flash, and the sidebar is real - * markup for crawlers and for anyone reading with scripting off. - */ - -/** Where the source markdown lives, for the "View source" link. */ -const SOURCE_BASE = `${META.repoUrl}/blob/main/packages/docs/content`; - -/** The site icon, emitted at the root beside `robots.txt`. */ -export const FAVICON_FILE = 'favicon.svg'; - -/** - * Placeholders the shell in index.html reserves. - * - * Vite builds that file once — bundling the entry, hashing the assets, and - * leaving these untouched — and every page is then stamped out of the result, - * so all pages share one script URL without this module having to know what - * it turned out to be. The stylesheet is the exception: which one a page links - * depends on the page, so its URL is passed in rather than read off the shell. - */ -const SLOTS = { - head: '', - styles: '', - body: '', -} as const; - -const escapeHtml = (value: string) => - value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - -/** - * Site-absolute hrefs, honouring the deploy base. - * - * Pages are served at extensionless URLs (`/docs/api`, from - * `dist/docs/api.html`) so that appending `.md` lands on the source - * beside it. Relative hrefs cannot be resolved consistently against a URL with - * no trailing slash, hence absolute paths and an explicit base. - */ -export const createHrefResolver = - (base: string) => - (href: string): string => - `${base}${href.replace(/^\//, '')}`; - -export type HrefResolver = ReturnType; - -/** `docs/examples/basic` → `/docs/examples/basic`; the landing page → `/`. */ -export const routeToPath = (route: string) => `/${route}`; - -/** The `.md` twin of a page: `docs/api` → `docs/api.md`, the landing page → `index.md`. */ -export const toMarkdownPath = (route: string) => - route === '' ? 'index.md' : `${route}.md`; - -const link = (href: string, label: string, className: string, extra = '') => - `${label}`; - -const renderHeader = (resolveHref: HrefResolver, page: Page) => { - // The sidebar toggle only has anything to open on a docs page. - const menuButton = - page.layout === 'docs' - ? `` - : ''; - - const rowClass = - page.layout === 'landing' ? 'header-row header-row-narrow' : 'header-row'; - - return ``; -}; - -const renderSearch = - () => ` -
-

Search documentation

-
- ${icon('search', 'size-5')} - - - -
- -
    -

    - -

    ↑ ↓ to selectEnter to openEsc to close

    -
    -
    `; - -const renderSidebar = ( - nav: NavGroup[], - current: Page, - resolveHref: HrefResolver, -) => { - const groups = nav - .map((group) => { - // Pages first, then whatever the group carries that is not one — the - // generated llms.txt, which has no route to mark as current. - const items = [ - ...group.pages.map((page) => - link( - resolveHref(routeToPath(page.route)), - escapeHtml(page.title), - 'sidebar-link', - page.route === current.route ? ' aria-current="page"' : '', - ), - ), - ...group.links.map((entry) => - link( - resolveHref(entry.href), - escapeHtml(entry.label), - 'sidebar-link', - ), - ), - ]; - - return ``; - }) - .join('\n '); - - return ` - `; -}; - -const renderToc = (headings: Heading[]) => { - // Nothing to list on a page with no headings, and the rail carries nothing - // else, so it is left out of the markup entirely rather than sitting empty. - if (headings.length === 0) return ''; - - return ``; -}; - -/** - * The page's own source, in three forms: the markdown twin, the same file on - * the clipboard, and the file in the repo. - * - * These sit between the title and the lead rather than at the foot of the "On - * this page" rail, which is hidden below `xl` — where they used to live, a - * narrow screen lost them along with the rail. - */ -const renderPageActions = (page: Page, resolveHref: HrefResolver) => { - const markdownHref = resolveHref(toMarkdownPath(page.route)); - // From the file rather than the route: `docs/examples/index.md` is served - // at `docs/examples`, and the route alone would name a file that is not there. - const sourceHref = `${SOURCE_BASE}/${path.relative(CONTENT_DIR, page.file)}`; - - // Every label is wrapped, icons are not: the underline is drawn on the span - // so it stops at the text instead of running under the glyph beside it. - return `
    - ${link(markdownHref, `${icon('markdown', 'size-3.5')}View as Markdown`, 'page-action')} - - ${link(sourceHref, `${icon('github', 'size-3.5')}View source`, 'page-action')} -
    `; -}; - -const renderPager = ( - prev: Page | undefined, - next: Page | undefined, - resolveHref: HrefResolver, -) => { - if (!prev && !next) return ''; - - const side = (page: Page | undefined, label: string, align: string) => - page - ? link( - resolveHref(routeToPath(page.route)), - `${label}${escapeHtml(page.title)}`, - `pager-link ${align}`, - ) - : ''; - - return ``; -}; - -/** - * The foot of every page: what the package is, and the places a reader is - * most likely to want next, including llms.txt, which is otherwise linked - * only from the sidebar. The row takes the header's width, so the two frame - * the same column. - */ -const renderFooter = (resolveHref: HrefResolver, page: Page) => { - const rowClass = - page.layout === 'landing' ? 'footer-row header-row-narrow' : 'footer-row'; - - const links = [ - link(resolveHref('/docs/introduction'), 'Docs', 'footer-link'), - link(resolveHref('/docs/examples'), 'Examples', 'footer-link'), - link(resolveHref('/docs/api'), 'API', 'footer-link'), - link(META.repoUrl, 'GitHub', 'footer-link'), - link(META.npmUrl, 'npm', 'footer-link'), - link(resolveHref('/llms.txt'), 'llms.txt', 'footer-link'), - ]; - - return `
    -
    - - -
    -
    `; -}; - -export type PageRender = { - page: Page; - html: string; - headings: Heading[]; - nav: NavGroup[]; - /** Sidebar order, flattened — the pager's prev/next come off this. */ - readingOrder: Page[]; - resolveHref: HrefResolver; - /** The stylesheet's final URL — hashed in a build. */ - stylesheet: string; -}; - -/** - * The stylesheet twice: a preload, then the link that applies it. - * - * The preload is for Cloudflare Pages rather than for the browser, which finds - * the link on its own. Pages turns a page's `` tags into - * `Link` headers and sends them as a 103 Early Hint, so the stylesheet is - * already downloading while the HTML is still on its way. - * - * Neither tag carries `crossorigin`, which Vite puts on the tags it injects. - * Pages skips any `` with an attribute beyond `rel`, `href` and `as`, and - * a preload only serves a request made in the same CORS mode, so the link has - * to match it. The sheet is same-origin, so the attribute bought nothing. - */ -const renderStylesheet = (href: string) => - [ - ``, - ``, - ].join('\n '); - -const renderDocsBody = ({ - page, - html, - headings, - nav, - readingOrder, - resolveHref, -}: PageRender) => { - const index = readingOrder.findIndex((entry) => entry.route === page.route); - // A page outside the sidebar — the 404 — has no siblings to page between, - // and index -1 would otherwise offer the first page as its "next". - const pager = - index === -1 - ? '' - : renderPager( - readingOrder[index - 1], - readingOrder[index + 1], - resolveHref, - ); - - return `${renderHeader(resolveHref, page)} -
    - ${renderSidebar(nav, page, resolveHref)} -
    - -
    -

    ${escapeHtml(page.title)}

    - ${renderPageActions(page, resolveHref)} - ${page.description ? `

    ${escapeHtml(page.description)}

    ` : ''} - ${html} -
    - ${pager} -
    - ${renderToc(headings)} -
    - ${renderFooter(resolveHref, page)}`; -}; - -const renderLandingBody = ({ page, html, resolveHref }: PageRender) => - `${renderHeader(resolveHref, page)} -
    - -
    - ${html} -
    -
    - ${renderFooter(resolveHref, page)}`; - -/** - * The social card, reused by every page: 1200x630, served from `public/`. - * - * One image rather than one per page — the card carries the library's name and - * what it does, which is what an unfurled link needs to say whichever page was - * shared. - */ -const OG_IMAGE = { - path: '/og.png', - width: 1200, - height: 630, - alt: 'keyrove — framework-agnostic keyboard navigation for lists, grids and trees.', -} as const; - -const meta = (attribute: 'name' | 'property', key: string, content: string) => - ``; - -/** - * What a crawler and a link unfurler need: which URL is the canonical one, and - * what to show when the page is shared. - * - * Every route has a `.md` twin at a sibling path, so which of the two is the - * indexable one has to be said outright rather than left to be guessed — and - * the twin is named as an alternate, so an agent holding the page finds the - * markdown without guessing at its URL. A page marked `noindex` skips all of it - * and says so instead — it is served at every dead URL, so it has no canonical - * URL of its own to claim. - */ -const renderIndexingTags = ( - { page, nav, resolveHref }: PageRender, - title: string, -) => { - if (page.noindex) return [meta('name', 'robots', 'noindex, follow')]; - - const toUrl = (route: string) => - `${META.siteUrl}${resolveHref(routeToPath(route))}`; - const url = toUrl(page.route); - const markdownUrl = `${META.siteUrl}${resolveHref(toMarkdownPath(page.route))}`; - const image = `${META.siteUrl}${resolveHref(OG_IMAGE.path)}`; - - return [ - ``, - // `type` names the format. The twin is served as `text/plain` so that a - // browser shows it rather than downloading it — see public/_headers. - ``, - meta( - 'property', - 'og:type', - page.layout === 'landing' ? 'website' : 'article', - ), - meta('property', 'og:site_name', 'keyrove'), - // The prose is American — "license", "color", "behavior". That is also - // what an absent og:locale is taken to mean, so this only says it outright. - meta('property', 'og:locale', 'en_US'), - meta('property', 'og:title', title), - meta('property', 'og:description', page.description), - meta('property', 'og:url', url), - meta('property', 'og:image', image), - meta('property', 'og:image:width', String(OG_IMAGE.width)), - meta('property', 'og:image:height', String(OG_IMAGE.height)), - meta('property', 'og:image:alt', OG_IMAGE.alt), - meta('name', 'twitter:card', 'summary_large_image'), - meta('name', 'twitter:title', title), - meta('name', 'twitter:description', page.description), - meta('name', 'twitter:image', image), - renderStructuredData({ page, nav, toUrl, imageUrl: image }), - ]; -}; - -/** - * Comments explain the templates to whoever edits them; the browser has no use - * for them. The shell in index.html and the header above carry ~1.5 KB of - * rationale between them, and it would otherwise be sent 20 times over — once - * per page — for about a tenth of each page's compressed weight. - * - * A regex is enough because nothing that reaches here opens a comment it does - * not close: a code block showing markup arrives from shiki with its `<` - * already escaped (`<!--`), so an example that documents a comment keeps - * it. Leading indentation and the trailing newline go too, so a comment that - * had a line to itself does not leave a blank one behind. - * - * Runs after the slots are filled — they are comments themselves. - */ -const stripComments = (html: string) => - html.replace(/[ \t]*\n?/g, ''); - -/** Stamps one page out of the built shell. */ -export const renderPage = (template: string, render: PageRender) => { - for (const slot of [SLOTS.styles, SLOTS.body]) { - if (!template.includes(slot)) { - throw new Error(`[docs] index.html is missing the ${slot} placeholder.`); - } - } - - const { page } = render; - const body = - page.layout === 'landing' - ? renderLandingBody(render) - : renderDocsBody(render); - - // Every page hangs its own name off the wordmark, except the landing page, - // whose name is the wordmark — leaving it to describe itself at a length no - // search result or link unfurl will show. `titleTag` in frontmatter is how - // either falls back to a line written for the slot. - const title = - page.titleTag ?? - (page.layout === 'landing' - ? `keyrove — ${page.description}` - : `${page.title} — keyrove`); - - const head = [ - `${escapeHtml(title)}`, - ``, - ``, - ...renderIndexingTags(render, title), - ].join('\n '); - - return stripComments( - template - .replace(SLOTS.head, head) - .replace(SLOTS.styles, renderStylesheet(render.stylesheet)) - .replace(SLOTS.body, `${body}${renderSearch()}`), - ); -}; diff --git a/packages/docs/build/markdown.ts b/packages/docs/build/markdown.ts index 7938e2d..6b83346 100644 --- a/packages/docs/build/markdown.ts +++ b/packages/docs/build/markdown.ts @@ -90,8 +90,9 @@ const wantsCopy = (info: string) => hasFlag(info, 'copy'); /** * The copy button a code panel carries, shared with the demos' source blocks. - * It copies the visible block beside it in the same container, which - * src/copy-code.ts looks up on the click. + * It copies the visible block beside it in the same container. The markup + * marks where it goes and what it is called; src/components/markdown.tsx swaps + * in the component that copies. */ export const copyButton = (label: string) => [ @@ -244,11 +245,12 @@ const createRenderer = async () => { * package managers. Nothing marks a group but the adjacency itself — any * prose between two blocks keeps them apart. * - * The tabs are stamped here, so the panel arrives finished and - * src/code-tabs.ts only has to switch it. The first tab is the one selected - * unless a later fence is flagged `selected` — the landing page's point is - * the script, and the markup is only what it runs on. Ids are numbered per - * page through `env`, which a render gets fresh. + * The tabs are stamped here as ARIA markup — which tab is selected, what + * each is called — and src/components/markdown.tsx hands that to Base UI's + * tabs, which switch them and own their arrow keys. The first tab is the + * one selected unless a later fence is flagged `selected` — the landing + * page's point is the script, and the markup is only what it runs on. Ids + * are numbered per page through `env`, which a render gets fresh. * * A fence flagged `copy` gets a copy button in the top corner, over the block * or at the end of the tab strip. One flagged block is enough to give its diff --git a/packages/docs/build/paths.ts b/packages/docs/build/paths.ts new file mode 100644 index 0000000..292ad69 --- /dev/null +++ b/packages/docs/build/paths.ts @@ -0,0 +1,28 @@ +/** + * How a page's route turns into the URLs the site serves it at. + * + * Pages are served at extensionless URLs (`/docs/api`, from + * `dist/client/docs/api.html`) so that appending `.md` lands on the source + * beside it. Relative hrefs cannot be resolved consistently against a URL with + * no trailing slash, hence absolute paths and an explicit base. + * + * Nothing here touches the filesystem, so the browser bundle imports it too. + */ + +/** The site icon, emitted at the root beside `robots.txt`. */ +export const FAVICON_FILE = 'favicon.svg'; + +/** Site-absolute hrefs, honouring the deploy base. */ +export const createHrefResolver = + (base: string) => + (href: string): string => + `${base}${href.replace(/^\//, '')}`; + +export type HrefResolver = ReturnType; + +/** `docs/examples/basic` → `/docs/examples/basic`; the landing page → `/`. */ +export const routeToPath = (route: string) => `/${route}`; + +/** The `.md` twin of a page: `docs/api` → `docs/api.md`, the landing page → `index.md`. */ +export const toMarkdownPath = (route: string) => + route === '' ? 'index.md' : `${route}.md`; diff --git a/packages/docs/build/plaintext.ts b/packages/docs/build/plaintext.ts index 395b0ff..7350940 100644 --- a/packages/docs/build/plaintext.ts +++ b/packages/docs/build/plaintext.ts @@ -1,7 +1,7 @@ import type { NavGroup, Page } from './content.ts'; import { expandDemos, type Demos } from './demos.ts'; import { stripIcons } from './icons.ts'; -import { routeToPath, toMarkdownPath } from './layout.ts'; +import { routeToPath, toMarkdownPath } from './paths.ts'; import { expandMeta, META } from './meta.ts'; /** diff --git a/packages/docs/build/structured-data.ts b/packages/docs/build/structured-data.ts index d3fc30c..c206878 100644 --- a/packages/docs/build/structured-data.ts +++ b/packages/docs/build/structured-data.ts @@ -131,25 +131,20 @@ const breadcrumbList = ({ page, nav, toUrl }: Context) => { }; /** - * The page's nodes as one ``, not for HTML - // entities, so the JSON is escaped at `<` alone. Running it through - // escapeHtml would turn every quote in it into an entity and break the parse. - const json = JSON.stringify(graph).replace(/${json}`; -}; +export const toStructuredData = ( + context: Context, +): Record => ({ + '@context': 'https://schema.org', + '@graph': + context.page.layout === 'landing' + ? [webSite(context), softwareSourceCode(context)] + : [techArticle(context), breadcrumbList(context)], +}); diff --git a/packages/docs/components.json b/packages/docs/components.json new file mode 100644 index 0000000..36ecc57 --- /dev/null +++ b/packages/docs/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/style.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/packages/docs/index.html b/packages/docs/index.html deleted file mode 100644 index f7dd679..0000000 --- a/packages/docs/index.html +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/packages/docs/package.json b/packages/docs/package.json index 6d07657..c3ab50c 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -1,6 +1,6 @@ { "name": "@mixedrays/keyrove/docs", - "version": "2.11.0", + "version": "3.0.0", "private": true, "description": "Documentation site and landing page for keyrove.", "license": "MIT", @@ -13,15 +13,27 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@base-ui/react": "^1.8.0", "@mixedrays/keyrove": "workspace:*", - "minisearch": "^7.2.0" + "@tanstack/react-router": "^1.170.41", + "@tanstack/react-start": "^1.168.60", + "class-variance-authority": "^0.7.1", + "cn": "^0.4.0", + "lucide": "^1.34.0", + "minisearch": "^7.2.0", + "react": "^19.3.0", + "react-dom": "^19.3.0" }, "devDependencies": { "@shikijs/markdown-it": "^4.4.3", "@tailwindcss/vite": "^4.3.3", "@types/node": "^26.3.0", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.2", "gray-matter": "^4.0.3", - "lucide": "^1.34.0", + "html-react-parser": "^6.1.8", + "htmlparser2": "^12.0.0", "markdown-it": "^15.0.0", "shiki": "^4.4.3", "tailwindcss": "^4.3.3", diff --git a/packages/docs/src/app.css b/packages/docs/src/app.css new file mode 100644 index 0000000..983c2a7 --- /dev/null +++ b/packages/docs/src/app.css @@ -0,0 +1,11 @@ +/* + * The site's one stylesheet: the chrome and prose, then the demos. + * + * Pages used to link one of two sheets — this one on a page with a demo, the + * site alone on a page without — because every navigation was a full page + * load and the demo styles are most of the site's CSS. Navigation is client + * side now, and a sheet picked per page would have to be swapped mid-session, + * so every page links the whole of it once and keeps it. + */ +@import './style.css'; +@import './demos.css'; diff --git a/packages/docs/src/code-tabs.ts b/packages/docs/src/code-tabs.ts deleted file mode 100644 index 7d5fcfa..0000000 --- a/packages/docs/src/code-tabs.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { keyRove } from '@mixedrays/keyrove'; - -/** - * Code panels with a tab per block. - * - * The tabs are markup already — build/markdown.ts stamps the tab list and the - * panels, one of them selected — so this only switches between them. The - * landing page's layout switcher is written in the same markup by hand, a - * live demo per panel, and is switched here too. - * - * The arrows are keyrove's own: the tab list is a horizontal group with a tab - * per item, and a move selects the tab it lands on, which is the selection - * following focus that the ARIA tabs pattern describes. The one tab stop moves - * with the selection, so Tab leaves the list for the panel it shows. - */ -export const mountCodeTabs = () => { - const groups = document.querySelectorAll('[data-code-tabs]'); - - for (const group of groups) { - const list = group.querySelector(':scope > [role="tablist"]'); - if (!list) continue; - - const tabs = Array.from( - list.querySelectorAll(':scope > [role="tab"]'), - ); - - const select = (selected: Element) => { - for (const tab of tabs) { - const isSelected = tab === selected; - tab.setAttribute('aria-selected', String(isSelected)); - tab.tabIndex = isSelected ? 0 : -1; - - const panel = document.getElementById( - tab.getAttribute('aria-controls') ?? '', - ); - panel?.toggleAttribute('hidden', !isSelected); - } - }; - - list.addEventListener('keydown', (e) => - keyRove(e, { onMove: ({ to }) => select(to) }), - ); - - list.addEventListener('click', (e) => { - const tab = (e.target as Element).closest('[role="tab"]'); - if (tab) select(tab); - }); - } -}; diff --git a/packages/docs/src/components/copy-button.tsx b/packages/docs/src/components/copy-button.tsx new file mode 100644 index 0000000..fea1f1f --- /dev/null +++ b/packages/docs/src/components/copy-button.tsx @@ -0,0 +1,98 @@ +import type { MouseEvent, ReactNode } from 'react'; + +import { Icon } from '@/components/icon.tsx'; +import { Button } from '@/components/ui/button.tsx'; +import { useFlash } from '@/lib/utils.ts'; + +/** The copy glyph and the tick it turns into; style.css shows one at a time. */ +const Glyphs = () => ( + <> + + + +); + +/** + * "Copy code" — the button on a demo's source and on any block whose fence + * asks for one (see build/markdown.ts). + * + * It copies the block's own text, rather than a second copy of it held in a + * prop, so what lands on the clipboard is what is on screen. The button sits + * beside the code in one container; where that container is a tabbed panel, + * the block is whichever tab is showing, so it is looked up on the click + * rather than once. + */ +export function CopyCodeButton({ label }: { label: string }) { + const [copied, flash] = useFlash(false); + + const copy = async (e: MouseEvent) => { + const code = Array.from( + e.currentTarget.parentElement?.querySelectorAll('pre') ?? [], + ).find((pre) => !pre.closest('[hidden]')); + if (!code) return; + + try { + await navigator.clipboard.writeText(code.textContent ?? ''); + } catch { + // Clipboard access can be refused outright; the code is on the page + // either way, so there is nothing to fall back to. + return; + } + + flash(true); + }; + + return ( + + ); +} + +/** + * A button that puts a command on the clipboard — the install line on the + * landing page. + * + * The command is written out beside the button, so this only saves the + * selecting: with scripting off, or the clipboard refused, it can still be + * copied by hand. It is drawn as the code blocks' copy button is, a copy + * glyph that turns into a tick, so the page has one way of saying "copied". + */ +export function CopyCommandButton({ + command, + label, + children, +}: { + command: string; + label: string; + children: ReactNode; +}) { + const [copied, flash] = useFlash(false); + + const copy = async () => { + try { + await navigator.clipboard.writeText(command); + } catch { + // Clipboard access can be refused outright; the command is on the + // page either way, so there is nothing to fall back to. + return; + } + + flash(true); + }; + + return ( + + ); +} diff --git a/packages/docs/src/components/docs-layout.tsx b/packages/docs/src/components/docs-layout.tsx new file mode 100644 index 0000000..3c9b9ff --- /dev/null +++ b/packages/docs/src/components/docs-layout.tsx @@ -0,0 +1,57 @@ +import { useRouter } from '@tanstack/react-router'; +import { useCallback, useEffect, useState, type ReactNode } from 'react'; + +import { useSearchOpen } from '@/components/search.tsx'; +import { + Sidebar, + SidebarDrawer, + SidebarToggle, +} from '@/components/sidebar.tsx'; +import { SiteFooter, SiteHeader } from '@/components/site-chrome.tsx'; +import { Sheet } from '@/components/ui/sheet.tsx'; + +/** + * Sidebar, content, rail — and the header and footer around them. Every docs + * page and the 404 render in here. + */ +export function DocsLayout({ children }: { children: ReactNode }) { + const [drawerOpen, setDrawerOpen] = useState(false); + const closeDrawer = useCallback(() => setDrawerOpen(false), []); + const searching = useSearchOpen(); + const router = useRouter(); + + // Search opens over everything; two modal layers would each try to keep + // focus, so the drawer steps aside for it. + useEffect(() => { + if (searching) setDrawerOpen(false); + }, [searching]); + + // A page loaded from its URL starts with focus on nothing, which is what + // lets its first demo take it. A client-side navigation would otherwise keep + // it on the sidebar link that was followed, so it is handed back as the + // navigation starts — before the new page renders and its demo mounts. + useEffect( + () => + router.subscribe('onBeforeNavigate', ({ pathChanged }) => { + if (pathChanged && document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + }), + [router], + ); + + return ( + + } /> + {/* The centre column is `minmax(0, 1fr)` rather than `1fr` so a wide + code block or table scrolls inside itself instead of stretching the + grid past the viewport. */} +
    + setDrawerOpen(true)} /> + + {children} +
    + +
    + ); +} diff --git a/packages/docs/src/components/docs-page.tsx b/packages/docs/src/components/docs-page.tsx new file mode 100644 index 0000000..94252a5 --- /dev/null +++ b/packages/docs/src/components/docs-page.tsx @@ -0,0 +1,49 @@ +import { Suspense } from 'react'; +import { pages } from 'virtual:docs/site'; + +import { Markdown } from '@/components/markdown.tsx'; +import { PageActions } from '@/components/page-actions.tsx'; +import { Pager } from '@/components/pager.tsx'; +import { Toc } from '@/components/toc.tsx'; +import { usePage } from '@/content.ts'; + +function DocsPageContent({ route }: { route: string }) { + const { tree, headings } = usePage(route); + const page = pages[route]; + + return ( + <> +
    + {/* The page itself, title to last line; the pager after it is + navigation between pages, not part of this one. */} +
    +

    {page.title}

    + + {page.description ?

    {page.description}

    : null} + +
    + +
    + + + ); +} + +/** + * One docs page, between the sidebar and the edge of the layout. + * + * Suspended on its own, so that on the first paint the chrome around it + * hydrates while the page's chunk is still on its way — see src/content.ts. + * + * Keyed by its route, so moving to another page draws it afresh. Two pages + * have demos at the same places in their trees, and React would otherwise + * keep one page's demo components for the next — swapping in the new demo's + * markup without ever mounting its behaviour. + */ +export function DocsPage({ route }: { route: string }) { + return ( + + + + ); +} diff --git a/packages/docs/src/components/icon.tsx b/packages/docs/src/components/icon.tsx new file mode 100644 index 0000000..368c4b4 --- /dev/null +++ b/packages/docs/src/components/icon.tsx @@ -0,0 +1,39 @@ +import { CHROME_ICONS, type ChromeIconName } from '@/icons.ts'; + +/** + * One of the chrome's icons, drawn as build/icons.ts draws the content's: + * `className` carries the size, and the glyph is decorative. + */ +export function Icon({ + name, + className, +}: { + name: ChromeIconName; + className?: string; +}) { + const { viewBox, strokeWidth, body } = CHROME_ICONS[name]; + + // A filled mark carries `fill` on its own path; a stroked one is drawn by + // the wrapper, so the two need different attribute sets. + const stroke = + strokeWidth === null + ? {} + : { + stroke: 'currentColor', + strokeWidth, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, + }; + + return ( + + ); +} diff --git a/packages/docs/src/components/markdown.tsx b/packages/docs/src/components/markdown.tsx new file mode 100644 index 0000000..52fceb3 --- /dev/null +++ b/packages/docs/src/components/markdown.tsx @@ -0,0 +1,236 @@ +import { keyRove } from '@mixedrays/keyrove'; +import { + createElement, + useEffect, + useMemo, + useRef, + type ReactNode, +} from 'react'; + +import type { + HtmlElement, + HtmlNode, + HtmlProps, +} from '../../build/html-tree.ts'; +import { + CopyCodeButton, + CopyCommandButton, +} from '@/components/copy-button.tsx'; +import { SiteLink } from '@/components/site-link.tsx'; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@/components/ui/tabs.tsx'; +import { mountDemo } from '@/demos.ts'; +import { mountHero } from '@/hero.ts'; + +/** + * A page's rendered markdown, as React. + * + * build/markdown.ts renders the HTML and build/html-tree.ts turns it into a + * tree of elements at build time; this draws the tree, swapping in a component + * wherever the markup stands for one: a link to another page becomes a router + * link, a group of code blocks becomes Base UI tabs, a copy button gets its + * click, and a demo gets its behaviour. + */ + +const classes = (props: HtmlProps) => + typeof props.className === 'string' ? props.className.split(/\s+/) : []; + +const hasClass = (props: HtmlProps, name: string) => + classes(props).includes(name); + +const elements = (children: HtmlNode[]) => + children.filter((child): child is HtmlElement => typeof child !== 'string'); + +const textOf = (nodes: HtmlNode[]): string => + nodes + .map((node) => (typeof node === 'string' ? node : textOf(node[2]))) + .join(''); + +const str = (value: unknown) => (typeof value === 'string' ? value : ''); + +/** + * Code blocks written back to back, as build/markdown.ts stamps them — or the + * landing page's pattern tabs, written in the same markup by hand. The markup + * says which tab starts selected and what each is called; Base UI's tabs take + * it from there. + */ +function CodeTabs({ + props, + children, +}: { + props: HtmlProps; + children: HtmlNode[]; +}) { + const parts = elements(children); + const list = parts.find(([, child]) => child.role === 'tablist'); + if (!list) return createElement('div', props, ...render(children)); + + const tabs = elements(list[2]).filter(([, tab]) => tab.role === 'tab'); + const panels = parts.filter(([, child]) => child.role === 'tabpanel'); + const copy = parts.find( + ([tag, child]) => tag === 'button' && hasClass(child, 'code-copy'), + ); + const selected = Math.max( + 0, + tabs.findIndex(([, tab]) => tab['aria-selected'] === 'true'), + ); + + return ( + + {copy && } + + {tabs.map(([, tab, label], index) => ( + + {textOf(label)} + + ))} + + {panels.map(([, panel, content], index) => ( + // Not a tab stop of its own: the code block inside already is, and a + // demo's items are. + + {render(content)} + + ))} + + ); +} + +/** + * Runs `mount` on the element once React has drawn it, and undoes it when the + * element goes — on navigation, or React's second mount in development. + */ +const useMount = ( + mount: (element: HTMLElement, signal: AbortSignal) => void, +) => { + const ref = useRef(null); + + useEffect(() => { + const controller = new AbortController(); + if (ref.current) mount(ref.current, controller.signal); + return () => controller.abort(); + // Mounted once per element; `mount` is a module function. + }, []); + + return ref; +}; + +/** A demo build/demos.ts stamped: the panel, its live preview, its source. */ +function Demo({ props, children }: { props: HtmlProps; children: HtmlNode[] }) { + const ref = useMount(mountDemo); + + return ( +
    + {render(children)} +
    + ); +} + +/** + * The landing page's hero demo. Its effect runs before any demo's, since React + * reaches it first — which is what lets it take the page's first focus. + */ +function Hero({ props, children }: { props: HtmlProps; children: HtmlNode[] }) { + const ref = useMount(mountHero); + + return ( +
    + {render(children)} +
    + ); +} + +/** + * The component an element stands for, or undefined for a plain element. + * `key` is the element's place among its siblings. + */ +const replace = ( + [tag, props, children]: HtmlElement, + key: number, +): ReactNode => { + const href = str(props.href); + + if (tag === 'a' && href.startsWith('/')) { + return ( + + {render(children)} + + ); + } + + if ('data-code-tabs' in props) { + return ; + } + + if ('data-demo' in props) { + return ; + } + + if (hasClass(props, 'hero-demo')) { + return ; + } + + if (tag === 'button' && hasClass(props, 'code-copy')) { + return ; + } + + if (tag === 'button' && 'data-copy-command' in props) { + return ( + + {render(children)} + + ); + } + + // The 404's list of places to go instead, on the arrows like everything + // else. It is written in markdown, which cannot mark its links as items, so + // `items` names them instead. Nothing takes focus on arrival: the heading + // saying the page is missing is what a screen reader should reach first, so + // the hint under the list says how to get in. + if ('data-not-found-links' in props) { + return ( +
    keyRove(event, { items: 'a' })} + > + {render(children)} +
    + ); + } + + return undefined; +}; + +const render = (nodes: HtmlNode[]): ReactNode[] => + nodes.map((node, key) => { + if (typeof node === 'string') return node; + + const [tag, props, children] = node; + return ( + replace(node, key) ?? + createElement(tag, { ...props, key }, ...render(children)) + ); + }); + +export function Markdown({ tree }: { tree: HtmlNode[] }) { + const content = useMemo(() => render(tree), [tree]); + + return <>{content}; +} diff --git a/packages/docs/src/components/nav-keys.tsx b/packages/docs/src/components/nav-keys.tsx new file mode 100644 index 0000000..7d6458a --- /dev/null +++ b/packages/docs/src/components/nav-keys.tsx @@ -0,0 +1,123 @@ +import { + type KeyCombo, + type KeyRoveOptions, + type RovingTabindexOptions, + initRovingTabindex, + keyRove, + matchesCombo, +} from '@mixedrays/keyrove'; +import { useHydrated } from '@tanstack/react-router'; +import { useEffect, type RefObject } from 'react'; + +import { isApple, isShown } from '@/lib/utils.ts'; + +/** + * Keyboard navigation for the docs sidebar and the "On this page" rail, which + * have no Base UI component to give them any: keyrove's arrows within each, + * and an Alt+Shift chord to each one's tab stop from anywhere on the page. + * + * The chords are Alt+Shift because browsers claim most Ctrl+Shift letters — + * Ctrl+Shift+I is DevTools — and Firefox gives Alt+Shift to the page's own + * access keys. + */ + +export type NavShortcut = { combo: KeyCombo; label: string }; + +/** A sidebar's group: its links, looping, with one tab stop. */ +export const navGroup = (items: string): KeyRoveOptions => ({ + items, + loop: true, + rovingTabindex: true, +}); + +/** + * Moves the group's tab stop to `initial` — the current page, the section in + * view — whenever `key` changes, so Tab and the chord land where the reader + * is. Left alone while focus is inside, where the stop is the reader's. + */ +export const useRovingStop = ( + root: RefObject, + group: RovingTabindexOptions, + initial: string, + key: unknown, +) => { + useEffect(() => { + const element = root.current; + if (!element || element.contains(document.activeElement)) return; + initRovingTabindex(element, { + ...group, + initial: element.querySelector(initial), + }); + // `group` is a constant per caller; `key` is what moves the stop. + }, [root, initial, key]); +}; + +/** + * The chord that jumps to a group's tab stop. `reveal` runs first, for a + * group that is off-screen until something opens it — the mobile drawer opens + * and focuses its own current link, so it returns `true` to say it is handled. + */ +export const useNavShortcut = ( + root: RefObject, + group: KeyRoveOptions, + shortcut: NavShortcut, + reveal?: () => boolean, +) => { + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if ( + event.defaultPrevented || + event.isComposing || + !matchesCombo(event, shortcut.combo) || + document.getElementById('docs-search') + ) + return; + + if (reveal?.()) { + event.preventDefault(); + return; + } + + const element = root.current; + if (!element || !isShown(element)) return; + const target = element.querySelector('[tabindex="0"]'); + if (target) { + keyRove(event, { ...group, focusKeys: { [shortcut.combo]: target } }); + } + }; + + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [root, group, shortcut, reveal]); +}; + +const APPLE_KEYS = new Map([ + ['Alt', '⌥'], + ['Shift', '⇧'], +]); + +/** + * The hint beside a sidebar's first heading: its chord, and the arrows that + * move within it. Printed as the keycaps are — `⌥ ⇧ E` on Apple keyboards, + * `Alt Shift E` elsewhere — the way the search chip is. The stylesheet decides + * when it shows; screen readers get `aria-keyshortcuts` instead. + * + * Drawn only once the bundle has run, so nothing advertises a chord without a + * handler behind it. + */ +export function KeysHint({ label }: { label: string }) { + const hydrated = useHydrated(); + if (!hydrated) return null; + + const apple = isApple(); + const keys = label + .split('+') + .map((key) => (apple && APPLE_KEYS.get(key)) || key); + + return ( + + ); +} diff --git a/packages/docs/src/components/page-actions.tsx b/packages/docs/src/components/page-actions.tsx new file mode 100644 index 0000000..7a44ff4 --- /dev/null +++ b/packages/docs/src/components/page-actions.tsx @@ -0,0 +1,75 @@ +import type { PageInfo } from 'virtual:docs/site'; + +import { createHrefResolver, toMarkdownPath } from '../../build/paths.ts'; +import { Icon } from '@/components/icon.tsx'; +import { SiteLink } from '@/components/site-link.tsx'; +import { Button } from '@/components/ui/button.tsx'; +import { useFlash } from '@/lib/utils.ts'; + +const resolveHref = createHrefResolver(import.meta.env.BASE_URL); + +/** + * "Copy page" — fetches the page's own `.md` twin and puts it on the clipboard. + * + * Fetching rather than serialising the DOM means what lands on the clipboard is + * the same document an agent would get from the URL, not a reconstruction of it. + */ +function CopyPageButton({ href }: { href: string }) { + const [label, flash] = useFlash('Copy page'); + + const copy = async () => { + try { + const response = await fetch(resolveHref(href)); + if (!response.ok) throw new Error(String(response.status)); + + await navigator.clipboard.writeText(await response.text()); + flash('Copied'); + } catch { + // Clipboard access can be refused outright; the link beside this button + // still gets the reader to the same file. + flash('Copy failed'); + } + }; + + // Both glyphs are in the button; `data-copied` is what picks between them. + return ( + + ); +} + +/** + * The page's own source, in three forms: the markdown twin, the same file on + * the clipboard, and the file in the repo. + * + * These sit between the title and the lead rather than at the foot of the "On + * this page" rail, which is hidden below `xl` — where they used to live, a + * narrow screen lost them along with the rail. + * + * Every label is wrapped, icons are not: the underline is drawn on the span so + * it stops at the text instead of running under the glyph beside it. + */ +export function PageActions({ page }: { page: PageInfo }) { + const markdownHref = `/${toMarkdownPath(page.route)}`; + + return ( +
    + + + View as Markdown + + + + + View source + +
    + ); +} diff --git a/packages/docs/src/components/pager.tsx b/packages/docs/src/components/pager.tsx new file mode 100644 index 0000000..9c6fecd --- /dev/null +++ b/packages/docs/src/components/pager.tsx @@ -0,0 +1,41 @@ +import { pages, readingOrder } from 'virtual:docs/site'; + +import { SiteLink } from '@/components/site-link.tsx'; + +function Side({ + route, + label, + align, +}: { + route: string | undefined; + label: string; + align: string; +}) { + if (route === undefined) return ; + + return ( + + {label} + {pages[route].title} + + ); +} + +/** Previous and next, in sidebar order. */ +export function Pager({ route }: { route: string }) { + const index = readingOrder.indexOf(route); + // A page outside the sidebar — the 404 — has no siblings to page between, + // and index -1 would otherwise offer the first page as its "next". + if (index === -1) return null; + + const prev = readingOrder[index - 1]; + const next = readingOrder[index + 1]; + if (prev === undefined && next === undefined) return null; + + return ( + + ); +} diff --git a/packages/docs/src/components/search-dialog.tsx b/packages/docs/src/components/search-dialog.tsx new file mode 100644 index 0000000..8c4ad5b --- /dev/null +++ b/packages/docs/src/components/search-dialog.tsx @@ -0,0 +1,260 @@ +import { + useEffect, + useRef, + useState, + type MouseEvent, + type RefObject, +} from 'react'; + +import { Icon } from '@/components/icon.tsx'; +import { SiteLink } from '@/components/site-link.tsx'; +import { + Autocomplete, + AutocompleteInput, + AutocompleteItem, + AutocompleteList, + AutocompleteStatus, +} from '@/components/ui/autocomplete.tsx'; +import { Button } from '@/components/ui/button.tsx'; +import { + Dialog, + DialogClose, + DialogContent, + DialogTitle, +} from '@/components/ui/dialog.tsx'; +import type { SearchDocument } from '@/search-model.ts'; + +/** + * The search dialog itself, in a chunk of its own: src/components/search.tsx + * loads it the first time search is opened, or a pointer settles on the + * button that opens it. + * + * The engine and `search-index.json` load the first time it opens, too. Base + * UI's autocomplete owns the keys inside: focus stays in the input, the arrows + * move a highlight through the results, and Enter follows the highlighted one + * — the first, until the reader moves it. + */ + +type Find = (query: string) => SearchDocument[]; + +let engine: Promise | undefined; + +/** Loads once and is shared; a failure is forgotten so Retry can try again. */ +const loadEngine = () => { + engine ??= Promise.all([ + import('@/search-engine.ts'), + fetch(`${import.meta.env.BASE_URL}search-index.json`).then((response) => { + if (!response.ok) throw new Error(`Search index: ${response.status}`); + return response.text(); + }), + ]) + .then(([module, serialized]) => module.loadSearch(serialized)) + .catch((error) => { + engine = undefined; + throw error; + }); + return engine; +}; + +/** Show the words around a match. */ +const excerpt = (text: string, query: string) => { + const terms = query.toLowerCase().split(/\s+/).filter(Boolean); + const positions = terms + .map((term) => text.toLowerCase().indexOf(term)) + .filter((position) => position >= 0); + const start = Math.max( + 0, + (positions.length ? Math.min(...positions) : 0) - 50, + ); + const end = Math.min(text.length, start + 180); + return `${start ? '…' : ''}${text.slice(start, end)}${end < text.length ? '…' : ''}`; +}; + +type Results = + | { status: 'loading' } + | { status: 'error' } + | { status: 'ready'; query: string; found: SearchDocument[] }; + +const statusText = (results: Results) => { + if (results.status === 'loading') return 'Loading search…'; + if (results.status === 'error') { + return 'Search could not load. Check your connection and try again.'; + } + + const { query, found } = results; + if (!query) return 'Search pages, API methods, and examples.'; + if (found.length === 0) return `No results for “${query}”. Try another term.`; + + return found.length === 12 + ? 'Showing the top 12 results.' + : `${found.length} result${found.length === 1 ? '' : 's'}.`; +}; + +export default function SearchDialog({ + open, + onOpenChange, + opener, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Where focus goes back to when the dialog closes. */ + opener: RefObject; +}) { + const [query, setQuery] = useState(''); + const [results, setResults] = useState({ status: 'loading' }); + const [attempt, setAttempt] = useState(0); + const input = useRef(null); + // Following a result moves to another page, where the opener is gone. + const followed = useRef(false); + + useEffect(() => { + if (!open) return; + + let current = true; + const trimmed = query.trim(); + loadEngine() + .then((find) => { + if (!current) return; + setResults({ + status: 'ready', + query: trimmed, + found: trimmed ? find(trimmed) : [], + }); + }) + .catch(() => { + if (current) setResults({ status: 'error' }); + }); + + return () => { + current = false; + }; + }, [open, query, attempt]); + + const found = results.status === 'ready' ? results.found : []; + + // Leave modified clicks to the browser (for example, opening a new tab). + const follow = (event: MouseEvent) => { + if ( + event.button !== 0 || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey + ) + return; + followed.current = true; + onOpenChange(false); + }; + + return ( + + { + input.current?.select(); + return input.current; + }} + finalFocus={() => { + const left = followed.current; + followed.current = false; + return left ? false : (opener.current ?? true); + }} + > + + result.heading || result.title + } + > +
    + } className="sr-only"> + Search documentation + +
    + + + + } + > + + +
    + + {(result: SearchDocument) => ( + } + onClick={follow} + > + + {result.heading ? result.title : 'Documentation'} + + + {result.heading || result.title} + + + {excerpt(result.text, query)} + + + )} + + } + className="search-status" + aria-atomic="true" + > + {statusText(results)} + + +

    + + ↑ ↓ to + select + + + Enter to open + + + Esc to close + +

    +
    +
    +
    +
    + ); +} diff --git a/packages/docs/src/components/search.tsx b/packages/docs/src/components/search.tsx new file mode 100644 index 0000000..619888b --- /dev/null +++ b/packages/docs/src/components/search.tsx @@ -0,0 +1,114 @@ +import { useHydrated } from '@tanstack/react-router'; +import { + Suspense, + createContext, + lazy, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from 'react'; + +import { Icon } from '@/components/icon.tsx'; +import { Button } from '@/components/ui/button.tsx'; +import { isApple } from '@/lib/utils.ts'; + +/** + * The documentation search: a dialog over the page, opened from the header or + * with ⌘K / Ctrl+K anywhere. The dialog, its engine and its index all load on + * first use rather than with the page — see src/components/search-dialog.tsx. + */ + +const loadDialog = () => import('@/components/search-dialog.tsx'); +const SearchDialog = lazy(loadDialog); + +const SearchContext = createContext<{ open: boolean; show: () => void }>({ + open: false, + show: () => {}, +}); + +/** Holds whether search is open, and opens it on ⌘K / Ctrl+K. */ +export function SearchProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + // Mounted from the first open on, so that closing it keeps the query and + // reopening it is instant. + const [mounted, setMounted] = useState(false); + // Where focus goes back to on close: wherever it was when search opened. + const opener = useRef(null); + + const show = () => { + if (document.activeElement instanceof HTMLElement) { + opener.current = document.activeElement; + } + setMounted(true); + setOpen(true); + }; + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if ( + event.defaultPrevented || + event.isComposing || + event.altKey || + event.shiftKey || + !(event.metaKey || event.ctrlKey) || + event.key.toLowerCase() !== 'k' + ) + return; + event.preventDefault(); + if (!event.repeat) show(); + }; + + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, []); + + return ( + + {children} + {mounted && ( + + + + )} + + ); +} + +/** Whether search is open, for whatever has to step aside for it. */ +export const useSearchOpen = () => useContext(SearchContext).open; + +/** + * The header's search button. Hidden until the bundle has run, since there is + * no search without it; the chip prints the shortcut as the keycaps do. + */ +export function SearchTrigger() { + const { open, show } = useContext(SearchContext); + const hydrated = useHydrated(); + const [shortcut, setShortcut] = useState('Ctrl K'); + + useEffect(() => { + if (isApple()) setShortcut('⌘ K'); + }, []); + + return ( + + ); +} diff --git a/packages/docs/src/components/sidebar.tsx b/packages/docs/src/components/sidebar.tsx new file mode 100644 index 0000000..6eefdfb --- /dev/null +++ b/packages/docs/src/components/sidebar.tsx @@ -0,0 +1,158 @@ +import { followFocus, initRovingTabindex, keyRove } from '@mixedrays/keyrove'; +import { useLocation } from '@tanstack/react-router'; +import { useCallback, useEffect, useRef } from 'react'; +import { nav, pages } from 'virtual:docs/site'; + +import { Icon } from '@/components/icon.tsx'; +import { + KeysHint, + navGroup, + useNavShortcut, + useRovingStop, + type NavShortcut, +} from '@/components/nav-keys.tsx'; +import { SiteLink } from '@/components/site-link.tsx'; +import { Button } from '@/components/ui/button.tsx'; +import { SheetContent, SheetTrigger } from '@/components/ui/sheet.tsx'; + +/** + * The docs navigation: a sticky column from `lg` up, and below it a drawer the + * header's menu button slides in. Both draw the same groups — derived from + * the content, so adding a page adds its link — and both move on keyrove's + * arrows with one tab stop, which starts on the current page. + */ + +const SHORTCUT: NavShortcut = { combo: 'alt+shift+KeyE', label: 'Alt+Shift+E' }; + +const group = navGroup('.sidebar-link'); + +/** Tailwind's `lg`, where the column replaces the drawer. */ +const DESKTOP = '(min-width: 64rem)'; + +function SidebarNav() { + return ( + + ); +} + +/** The column, from `lg` up. Hidden below it, where the drawer stands in. */ +export function Sidebar({ onReveal }: { onReveal: () => void }) { + const root = useRef(null); + const pathname = useLocation({ select: (location) => location.pathname }); + + useRovingStop(root, group, '[aria-current="page"]', pathname); + useNavShortcut(root, group, SHORTCUT, () => { + if (matchMedia(DESKTOP).matches) return false; + onReveal(); + return true; + }); + + return ( + + ); +} + +/** + * The drawer, below `lg`. Base UI's sheet traps focus while it is open, closes + * on Escape and on the backdrop, and hands focus back to the menu button; it + * opens on the current page's link. + */ +export function SidebarDrawer({ onClose }: { onClose: () => void }) { + const root = useRef(null); + + // The drawer's links exist only while it is open, so its tab stop is set + // as they arrive rather than on navigation. + const attach = useCallback((element: HTMLDivElement | null) => { + root.current = element; + if (element) { + initRovingTabindex(element, { + ...group, + initial: element.querySelector('[aria-current="page"]'), + }); + } + }, []); + + // The drawer is the narrow layout's alone; widening the window past `lg` + // with it open would leave a backdrop over the column that replaced it. + useEffect(() => { + const query = matchMedia(DESKTOP); + const onChange = () => query.matches && onClose(); + query.addEventListener('change', onChange); + return () => query.removeEventListener('change', onChange); + }, [onClose]); + + return ( + + root.current?.querySelector('[aria-current="page"]') ?? + true + } + onKeyDown={(event) => keyRove(event, group)} + onFocus={(event) => followFocus(event, group)} + // Navigating within the drawer should not leave it covering the page it + // just moved to. + onClick={(event) => { + if ((event.target as Element).closest('a')) onClose(); + }} + > + + + ); +} + +/** The header's menu button, which only has a drawer to open below `lg`. */ +export function SidebarToggle({ open }: { open: boolean }) { + return ( + + } + > + + + ); +} diff --git a/packages/docs/src/components/site-chrome.tsx b/packages/docs/src/components/site-chrome.tsx new file mode 100644 index 0000000..f068038 --- /dev/null +++ b/packages/docs/src/components/site-chrome.tsx @@ -0,0 +1,133 @@ +import type { ReactNode } from 'react'; +import { meta } from 'virtual:docs/site'; + +import { Icon } from '@/components/icon.tsx'; +import { SearchTrigger } from '@/components/search.tsx'; +import { SiteLink } from '@/components/site-link.tsx'; +import { ThemeToggle } from '@/components/theme-toggle.tsx'; + +/** + * The header and footer every page shares. + * + * The landing page's content is narrower than the docs grid; the header and + * footer rows track whichever the page between them uses. + */ + +type Layout = 'docs' | 'landing'; + +export function SiteHeader({ + layout, + menu, +}: { + layout: Layout; + /** The drawer toggle, on a docs page. */ + menu?: ReactNode; +}) { + return ( +
    +
    +
    + {menu} + + + keyrove + +
    +
    + + + {/* The nav is hidden on narrow screens, so the repo keeps an + icon-only stop in the header there rather than dropping out of + it entirely. */} + + + + +
    +
    +
    + ); +} + +/** + * The foot of every page: what the package is, and the places a reader is + * most likely to want next, including llms.txt, which is otherwise linked + * only from the sidebar. The row takes the header's width, so the two frame + * the same column. + */ +export function SiteFooter({ layout }: { layout: Layout }) { + return ( +
    +
    +

    + + + keyrove + + + v{meta.packageVersion} ·{' '} + + MIT licensed + + +

    + +
    +
    + ); +} diff --git a/packages/docs/src/components/site-link.tsx b/packages/docs/src/components/site-link.tsx new file mode 100644 index 0000000..1041ca0 --- /dev/null +++ b/packages/docs/src/components/site-link.tsx @@ -0,0 +1,48 @@ +import { Link } from '@tanstack/react-router'; +import type { ComponentProps } from 'react'; + +import { createHrefResolver } from '../../build/paths.ts'; + +const resolveHref = createHrefResolver(import.meta.env.BASE_URL); + +/** + * A file the site serves rather than a page — `/llms.txt`, `/docs/api.md` — + * which the router has nothing to render for. + */ +const isFile = (path: string) => /\.\w+$/.test(path); + +/** + * A site-absolute link, the way content and chrome both write them: + * `/docs/api#followfocus`. + * + * A page goes through the router, so following it swaps the page in place and + * hovering it loads the page ahead of the click. A file stays a plain link the + * browser fetches. Either way the deploy base is prefixed here, once. + */ +export function SiteLink({ + href, + ...props +}: { href: string } & Omit, 'href'>) { + const [path, hash] = href.split('#'); + + if (!href.startsWith('/') || isFile(path)) { + return ( + + ); + } + + return ( + + ); +} diff --git a/packages/docs/src/components/theme-toggle.tsx b/packages/docs/src/components/theme-toggle.tsx new file mode 100644 index 0000000..57f274c --- /dev/null +++ b/packages/docs/src/components/theme-toggle.tsx @@ -0,0 +1,28 @@ +import { useState } from 'react'; + +import { Icon } from '@/components/icon.tsx'; +import { Button } from '@/components/ui/button.tsx'; +import { themeLabel, toggleTheme } from '@/theme.ts'; + +/** + * The header theme toggle. The inline script in the root route applies the + * stored choice before the page paints (see src/theme.ts); this handles + * clicks. + */ +export function ThemeToggle() { + const [label, setLabel] = useState('Switch between light and dark theme'); + + // Both glyphs ship; style.css shows one per theme. Picking in script would + // mean an empty button until the bundle ran, and the theme is not known + // until the inline head script has run anyway. + return ( + + ); +} diff --git a/packages/docs/src/components/toc.tsx b/packages/docs/src/components/toc.tsx new file mode 100644 index 0000000..49fbfc9 --- /dev/null +++ b/packages/docs/src/components/toc.tsx @@ -0,0 +1,117 @@ +import { followFocus, keyRove } from '@mixedrays/keyrove'; +import { useEffect, useRef, useState } from 'react'; + +import type { Heading } from '../../build/markdown.ts'; +import { + KeysHint, + navGroup, + useNavShortcut, + useRovingStop, + type NavShortcut, +} from '@/components/nav-keys.tsx'; + +const SHORTCUT: NavShortcut = { combo: 'alt+shift+KeyO', label: 'Alt+Shift+O' }; + +const group = navGroup('.toc-link'); + +/** + * Clears the sticky header plus a little breathing room, so a heading counts + * as current from the moment it settles under the bar. + */ +const OFFSET = 96; + +/** + * The "On this page" rail, which highlights the section currently under the + * header. + * + * A scroll position read beats `IntersectionObserver` here: the answer is + * "which heading did we last pass", and short trailing sections never grow tall + * enough to satisfy an observer threshold at all. + */ +export function Toc({ headings }: { headings: Heading[] }) { + const root = useRef(null); + const [active, setActive] = useState(); + + useEffect(() => { + const targets = headings + .map((heading) => document.getElementById(heading.id)) + .filter((element): element is HTMLElement => element !== null); + if (targets.length === 0) return; + + const update = () => { + const scrolledToBottom = + window.innerHeight + window.scrollY >= document.body.scrollHeight - 2; + + // The last section can be too short to ever reach the offset, so the + // bottom of the page selects it outright. + const current = scrolledToBottom + ? targets[targets.length - 1] + : ([...targets] + .reverse() + .find((target) => target.getBoundingClientRect().top <= OFFSET) ?? + targets[0]); + + setActive(current.id); + }; + + let queued = false; + const onScroll = () => { + if (queued) return; + queued = true; + requestAnimationFrame(() => { + queued = false; + update(); + }); + }; + + addEventListener('scroll', onScroll, { passive: true }); + addEventListener('resize', onScroll, { passive: true }); + update(); + + return () => { + removeEventListener('scroll', onScroll); + removeEventListener('resize', onScroll); + }; + }, [headings]); + + // The tab stop follows the highlight, so Tab and the chord land on the + // section in view — until focus is in the rail, where it is the reader's. + useRovingStop(root, group, '[data-active]', active); + useNavShortcut(root, group, SHORTCUT); + + // Nothing to list on a page with no headings, and the rail carries nothing + // else, so it is left out entirely rather than sitting empty. + if (headings.length === 0) return null; + + return ( + + ); +} diff --git a/packages/docs/src/components/ui/autocomplete.tsx b/packages/docs/src/components/ui/autocomplete.tsx new file mode 100644 index 0000000..d769933 --- /dev/null +++ b/packages/docs/src/components/ui/autocomplete.tsx @@ -0,0 +1,45 @@ +import { Autocomplete as AutocompletePrimitive } from '@base-ui/react/autocomplete'; + +/** + * A text input with a list of suggestions, on Base UI's autocomplete — shadcn + * has no component for it, so this follows the shape of the ones it does. + * + * Base UI owns the keys: focus stays in the input while the arrows move a + * highlight through the list, and Enter activates the highlighted item. + * Unstyled here; the search passes the classes style.css draws it with. + */ + +/** Context only — it renders no element, so there is nothing to mark. */ +const Autocomplete = AutocompletePrimitive.Root; + +function AutocompleteInput(props: AutocompletePrimitive.Input.Props) { + return ( + + ); +} + +function AutocompleteList(props: AutocompletePrimitive.List.Props) { + return ( + + ); +} + +function AutocompleteItem(props: AutocompletePrimitive.Item.Props) { + return ( + + ); +} + +function AutocompleteStatus(props: AutocompletePrimitive.Status.Props) { + return ( + + ); +} + +export { + Autocomplete, + AutocompleteInput, + AutocompleteItem, + AutocompleteList, + AutocompleteStatus, +}; diff --git a/packages/docs/src/components/ui/button.tsx b/packages/docs/src/components/ui/button.tsx new file mode 100644 index 0000000..43bf33c --- /dev/null +++ b/packages/docs/src/components/ui/button.tsx @@ -0,0 +1,43 @@ +import { Button as ButtonPrimitive } from '@base-ui/react/button'; +import { cva, type VariantProps } from 'class-variance-authority'; + +/** + * The site's buttons, on Base UI's. + * + * The variants name the classes style.css already draws these buttons with, + * rather than restating them as utilities here: a utility outranks every rule + * in the components layer, so shadcn's default classes would have repainted + * the header and the code panels. With no utilities to reconcile, the classes + * are joined rather than merged, which keeps tailwind-merge out of the bundle. + */ +const buttonVariants = cva('', { + variants: { + variant: { + /** No look of its own: the class the caller passes carries it. */ + bare: '', + /** The square, bordered buttons in the header and the search panel. */ + icon: 'icon-button', + /** The copy button in a code panel's corner. */ + copy: 'code-copy', + }, + }, + defaultVariants: { + variant: 'bare', + }, +}); + +function Button({ + className, + variant, + ...props +}: ButtonPrimitive.Props & VariantProps) { + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/packages/docs/src/components/ui/dialog.tsx b/packages/docs/src/components/ui/dialog.tsx new file mode 100644 index 0000000..ab87852 --- /dev/null +++ b/packages/docs/src/components/ui/dialog.tsx @@ -0,0 +1,38 @@ +import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'; + +/** + * A modal on Base UI's dialog, which handles what the native `` used + * to: focus moves in and is trapped, Escape and the backdrop close it, focus + * goes back to whatever opened it, and the page behind stops scrolling. + * + * Unstyled here; the search passes the classes style.css draws it with. + */ + +function Dialog(props: DialogPrimitive.Root.Props) { + return ; +} + +function DialogClose(props: DialogPrimitive.Close.Props) { + return ; +} + +function DialogTitle(props: DialogPrimitive.Title.Props) { + return ; +} + +function DialogContent({ + overlayClassName, + ...props +}: DialogPrimitive.Popup.Props & { overlayClassName?: string }) { + return ( + + + + + ); +} + +export { Dialog, DialogClose, DialogContent, DialogTitle }; diff --git a/packages/docs/src/components/ui/sheet.tsx b/packages/docs/src/components/ui/sheet.tsx new file mode 100644 index 0000000..926b4f4 --- /dev/null +++ b/packages/docs/src/components/ui/sheet.tsx @@ -0,0 +1,44 @@ +import { Dialog as SheetPrimitive } from '@base-ui/react/dialog'; + +/** + * A panel that slides in from an edge, on Base UI's dialog: the mobile docs + * navigation. Focus is trapped while it is open, Escape and the backdrop + * close it, and focus returns to the button that opened it. + * + * Unstyled here. The slide is the caller's stylesheet, keyed off the + * `data-starting-style` and `data-ending-style` attributes Base UI sets while + * the panel enters and leaves. + */ + +function Sheet(props: SheetPrimitive.Root.Props) { + return ; +} + +function SheetTrigger(props: SheetPrimitive.Trigger.Props) { + return ; +} + +function SheetContent({ + overlayClassName, + side = 'left', + ...props +}: SheetPrimitive.Popup.Props & { + overlayClassName?: string; + side?: 'top' | 'right' | 'bottom' | 'left'; +}) { + return ( + + + + + ); +} + +export { Sheet, SheetContent, SheetTrigger }; diff --git a/packages/docs/src/components/ui/tabs.tsx b/packages/docs/src/components/ui/tabs.tsx new file mode 100644 index 0000000..13dea77 --- /dev/null +++ b/packages/docs/src/components/ui/tabs.tsx @@ -0,0 +1,54 @@ +import { Tabs as TabsPrimitive } from '@base-ui/react/tabs'; + +/** + * Tabs on Base UI's, which owns the arrow keys: the list is one tab stop, and + * the arrows move between its tabs. + * + * Unstyled here — the code panels and the landing page's pattern tabs each + * pass the classes style.css draws them with. + */ + +function Tabs(props: TabsPrimitive.Root.Props) { + return ; +} + +/** + * Selection follows focus, as the ARIA tabs pattern describes for panels that + * are already rendered: arrowing onto a tab shows its panel. + */ +function TabsList({ + activateOnFocus = true, + ...props +}: TabsPrimitive.List.Props) { + return ( + + ); +} + +function TabsTrigger(props: TabsPrimitive.Tab.Props) { + return ; +} + +/** + * Every panel stays in the document while hidden — a demo in a hidden tab + * keeps its listeners and its log, and the prerendered page carries all of + * them for anyone reading without scripts. + */ +function TabsContent({ + keepMounted = true, + ...props +}: TabsPrimitive.Panel.Props) { + return ( + + ); +} + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/packages/docs/src/content.ts b/packages/docs/src/content.ts new file mode 100644 index 0000000..d5820e5 --- /dev/null +++ b/packages/docs/src/content.ts @@ -0,0 +1,35 @@ +import { use } from 'react'; +import { loaders, type PageContent } from 'virtual:docs/site'; + +/** + * A page's body, loaded once and then read synchronously. + * + * The body is a chunk of its own rather than loader data, so it is never + * serialised into the prerendered HTML beside the markup it already rendered — + * that would send every page twice. A route's loader awaits `loadPage`, which + * leaves the module here for the component to read; on the first paint, where + * the browser hydrates without running loaders, `usePage` suspends on the same + * promise and React keeps the server's markup until the chunk arrives. + */ + +const cache = new Map>(); +const loaded = new Map(); + +/** Whether `route` is a page the content model knows. */ +export const hasPage = (route: string) => + Object.prototype.hasOwnProperty.call(loaders, route); + +export const loadPage = (route: string): Promise => { + let pending = cache.get(route); + if (!pending) { + pending = loaders[route]().then(({ default: content }) => { + loaded.set(route, content); + return content; + }); + cache.set(route, pending); + } + return pending; +}; + +export const usePage = (route: string): PageContent => + loaded.get(route) ?? use(loadPage(route)); diff --git a/packages/docs/src/copy-code.ts b/packages/docs/src/copy-code.ts deleted file mode 100644 index 2821e04..0000000 --- a/packages/docs/src/copy-code.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * "Copy code" — the button on a demo's source and on any block whose fence - * asks for one (see build/markdown.ts). - * - * It copies the block's own text, rather than a second copy of it held in an - * attribute, so what lands on the clipboard is what is on screen. The button - * sits beside the code in one container; where that container is a tabbed - * panel, the block is whichever tab is showing, so it is looked up on the - * click rather than once. - */ -export const mountCopyCode = () => { - const buttons = - document.querySelectorAll('[data-copy-code]'); - - for (const button of buttons) { - let resetTimer: ReturnType | undefined; - - button.addEventListener('click', async () => { - const code = Array.from( - button.parentElement?.querySelectorAll('pre') ?? [], - ).find((pre) => !pre.closest('[hidden]')); - if (!code) return; - - try { - await navigator.clipboard.writeText(code.textContent ?? ''); - } catch { - // Clipboard access can be refused outright; the code is on the page - // either way, so there is nothing to fall back to. - return; - } - - // Both glyphs are already in the button; `data-copied` is what picks - // between them, so confirming a copy costs no DOM construction. - button.toggleAttribute('data-copied', true); - clearTimeout(resetTimer); - resetTimer = setTimeout( - () => button.removeAttribute('data-copied'), - 2000, - ); - }); - } -}; diff --git a/packages/docs/src/copy-command.ts b/packages/docs/src/copy-command.ts deleted file mode 100644 index ba3f4d9..0000000 --- a/packages/docs/src/copy-command.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Buttons that put a command on the clipboard — the install line on the - * landing page. - * - * The command is written out beside the button, so this only saves the - * selecting: with scripting off, or the clipboard refused, it can still be - * copied by hand. - * - * The button is drawn as the code blocks' copy button is, a copy glyph that - * turns into a tick, so the page has one way of saying "copied" rather than - * two. Both glyphs are already in the markup and `data-copied` picks between - * them, exactly as src/copy-code.ts does. - */ -export const mountCopyCommands = () => { - const buttons = document.querySelectorAll( - '[data-copy-command]', - ); - - for (const button of buttons) { - const command = button.dataset.copyCommand ?? ''; - let resetTimer: ReturnType | undefined; - - button.addEventListener('click', async () => { - try { - await navigator.clipboard.writeText(command); - } catch { - // Clipboard access can be refused outright; the command is on the - // page either way, so there is nothing to fall back to. - return; - } - - button.toggleAttribute('data-copied', true); - clearTimeout(resetTimer); - resetTimer = setTimeout( - () => button.removeAttribute('data-copied'), - 2000, - ); - }); - } -}; diff --git a/packages/docs/src/demos.ts b/packages/docs/src/demos.ts index 30c7b95..845f03b 100644 --- a/packages/docs/src/demos.ts +++ b/packages/docs/src/demos.ts @@ -18,7 +18,10 @@ import { * Nothing here builds DOM. Every demo's markup is stamped into the page at * build time from the file under `content/_demos` that the source block below * it shows (see build/demos.ts), so this only wires the behaviour markup cannot - * carry: the keydown listener, the log, and the copy button. + * carry: the keydown listener and the log. src/components/demo.tsx mounts it + * on each demo once React has rendered the page, and leaves the demo's markup + * to it — React draws the panel around the demo and its code tabs, never the + * demo itself. */ /** A keydown handler with keyrove's contract: truthy when it claimed the key. */ @@ -126,7 +129,11 @@ const isMoveResult = (value: unknown): value is MoveResult => typeof value === 'object' && value !== null && 'action' in value; /** Compact demo panels report one key at a time. */ -const createReadout = (surface: HTMLElement, line: HTMLElement): Log => { +const createReadout = ( + surface: HTMLElement, + line: HTMLElement, + signal: AbortSignal, +): Log => { let movement: { action: string; to: Element } | null = null; let widget: { target: Element; phrase: string } | null = null; @@ -149,16 +156,24 @@ const createReadout = (surface: HTMLElement, line: HTMLElement): Log => { line.classList.add('demo-readout-fresh'); }; - surface.addEventListener('focusin', (e) => { - if (!surface.contains(e.relatedTarget as Node | null)) { - show('listening', '', 'listening…'); - } - }); - surface.addEventListener('focusout', (e) => { - if (!surface.contains(e.relatedTarget as Node | null)) { - show('blurred', '', 'click to focus'); - } - }); + surface.addEventListener( + 'focusin', + (e) => { + if (!surface.contains(e.relatedTarget as Node | null)) { + show('listening', '', 'listening…'); + } + }, + { signal }, + ); + surface.addEventListener( + 'focusout', + (e) => { + if (!surface.contains(e.relatedTarget as Node | null)) { + show('blurred', '', 'click to focus'); + } + }, + { signal }, + ); return { move: (move) => { @@ -203,7 +218,7 @@ const createReadout = (surface: HTMLElement, line: HTMLElement): Log => { * Rows are cloned from the