Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ dist/

.tmp
.pnpm-store/

# TanStack Router writes its generator scratch files here during dev and build.
.tanstack/
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
98 changes: 67 additions & 31 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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:

Expand All @@ -207,18 +210,51 @@ are required. Code-only examples can use a
`<div class="demo-panel demo-panel--code">` 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 <component>`
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
Expand Down
2 changes: 1 addition & 1 deletion packages/bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions packages/bench/src/main.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -81,6 +81,7 @@ const $ = <T extends Element = HTMLElement>(selector: string) =>
document.querySelector<T>(selector)!;

const ui = {
themeToggle: $<HTMLButtonElement>('[data-theme-toggle]'),
form: $<HTMLFormElement>('[data-run-form]'),
run: $<HTMLButtonElement>('[data-run]'),
stop: $<HTMLButtonElement>('[data-stop]'),
Expand Down Expand Up @@ -573,7 +574,6 @@ const startRun = async () => {

/* ---------- wiring ---------- */

mountThemeToggle();
renderChecks();
fillSettings(DEFAULT_OPTIONS);
for (const element of document.querySelectorAll('[data-case-count]')) {
Expand All @@ -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'),
);
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/build/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
122 changes: 122 additions & 0 deletions packages/docs/build/head.ts
Original file line number Diff line number Diff line change
@@ -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 `<head>`, 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<string, unknown> };

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))}`,
},
],
};
};
Loading
Loading