diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 9c407c6..030f129 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -53,6 +53,6 @@ jobs: # 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/client + pages deploy apps/docs/dist/client --project-name=keyrove --branch=main diff --git a/.prettierignore b/.prettierignore index 7438261..5edf3fa 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,4 +6,4 @@ pnpm-lock.yaml CHANGELOG.md # Generated by TanStack Router on every dev start and build. -packages/docs/src/routeTree.gen.ts +apps/docs/src/routeTree.gen.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e690d34..09b0767 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,22 @@ pnpm install pnpm dev ``` +## Branches + +Work happens on `develop`; `main` only receives merges from `develop`. Start +every change from an up-to-date `develop`: + +```sh +git switch develop +git pull +git switch -c feat/short-description +``` + +Prefix the branch name with the type of change, as in +[commit messages](#commit-messages): `feat/`, `fix/`, `refactor/`, `docs/`, +`chore/`. When the change is ready, push the branch and open a pull request into +`develop`, not `main`. + ## Before opening a pull request Run the same checks CI runs: @@ -33,7 +49,7 @@ pnpm build There are two READMEs. The root `README.md` is a short entry point that links to the docs site; `packages/keyrove/README.md` is the detailed one published to npm. A change to the public API usually touches the package README and the docs -under `packages/docs/content`, and the root README only if the basic example +under `apps/docs/content`, and the root README only if the basic example changes. ## Commit messages @@ -51,7 +67,7 @@ anything large. ## Still to be written -- Which branch pull requests should target, and how they are reviewed. +- How pull requests are reviewed. - Guidelines for adding examples and docs pages. ## License diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5b5a503..f22f470 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -5,11 +5,14 @@ framework-agnostic keyboard navigation library for lists, grids and trees. ## Packages +Published packages live under `packages/`; the docs site and the benchmark, +which are never published, live under `apps/`. + | Package | Path | Description | | -------------------------- | ------------------------------------ | --------------------------------------------------- | | `@mixedrays/keyrove` | [packages/keyrove](packages/keyrove) | The library. Published to npm. | -| `@mixedrays/keyrove/docs` | [packages/docs](packages/docs) | Documentation site and landing page. Not published. | -| `@mixedrays/keyrove/bench` | [packages/bench](packages/bench) | Browser benchmark and its UI. Not published. | +| `@mixedrays/keyrove-docs` | [apps/docs](apps/docs) | Documentation site and landing page. Not published. | +| `@mixedrays/keyrove-bench` | [apps/bench](apps/bench) | Browser benchmark and its UI. Not published. | ## Getting started @@ -39,7 +42,7 @@ Run from the repo root: | `pnpm publish:npm` | Publishes the library to npm. See [Releasing](#releasing). | The benchmark's method and recorded results are in -[packages/bench](packages/bench/README.md), with how to compare releases: +[apps/bench](apps/bench/README.md), with how to compare releases: `pnpm bench:headless -- --ref v2.1.0 --ref v2.2.0` benchmarks each tag and saves a report for it, and `pnpm bench` compares them side by side. @@ -47,7 +50,7 @@ Any script can be aimed at one package with a filter: ```sh pnpm --filter @mixedrays/keyrove test -pnpm --filter @mixedrays/keyrove/docs build +pnpm --filter @mixedrays/keyrove-docs build ``` ## Browser tests @@ -100,7 +103,7 @@ npm, it publishes nothing. ## How the docs site works Every page is one markdown file under -[packages/docs/content](packages/docs/content), and its path is its URL: +[apps/docs/content](apps/docs/content), and its path is its URL: `content/docs/examples/basic.md` is served at `/docs/examples/basic`. Adding a page means adding a file — the sidebar, the "On this page" rail, the prev/next pager, and `llms.txt` are all derived from what is on disk. @@ -147,7 +150,7 @@ 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`. +`pnpm --filter @mixedrays/keyrove-docs test`. ### Sidebar navigation @@ -166,26 +169,8 @@ keys as well while keyboard focus is inside it. ### Live demos -A content file embeds a demo with `
`. The markup -behind it is one file under -[packages/docs/content/_demos](packages/docs/content/_demos), named after the -demo. [packages/docs/build/demos.ts](packages/docs/build/demos.ts) stamps it -into the page twice, once as live elements and once as the source block under -them, so the two cannot drift. Repeated items wrapped in `` … -`` still run but collapse to one comment in the source block, -which keeps a 24-item list from costing 24 lines of a page that is also served -as markdown. - -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 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: +A content file embeds a demo with a placeholder that names it and labels its +panel: ````md
@@ -198,13 +183,37 @@ menu.addEventListener('keydown', (e) => keyRove(e)); ``` ```` +A demo is two things, both named after it: its markup, one file under +[apps/docs/content/_demos](apps/docs/content/_demos), and its entry in +[apps/docs/build/demo-definitions.ts](apps/docs/build/demo-definitions.ts), +which declares what it adds to navigation — group options, extra handlers +such as typeahead, and decorative icons. A fragment without an entry, or an +entry without a fragment, fails the docs tests. + +[apps/docs/build/demos.ts](apps/docs/build/demos.ts) stamps the +fragment into the page twice, once as live elements and once as the source +block under them, so the two cannot drift. Repeated items wrapped in +`` … `` still run but collapse to one comment in the +source block, which keeps a 24-item list from costing 24 lines of a page that +is also served as markdown. + The panel includes a live preview, a single-line readout, and tabs for the fragment's HTML and the adjacent code blocks. It uses the opposite theme to -the page and keeps long code blocks scrollable. Omit `data-demo-label` to use -the existing detailed demo presentation. `data-demo-class`, when needed, -goes before `data-demo-label`. +the page and keeps long code blocks scrollable. 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. The attributes go in any order; +a placeholder the build cannot read fails it. + +[apps/docs/src/demos.ts](apps/docs/src/demos.ts) wires the behaviour +markup cannot carry: the keydown listener and the extras the entry lists. +[apps/docs/src/readout.ts](apps/docs/src/readout.ts) writes the +readout, for the panels and the landing page's hero alike. 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. -[demo-panel.css](packages/docs/src/demo-panel.css) owns the shared presentation; +[demo-panel.css](apps/docs/src/demo-panel.css) owns the shared presentation; the embedding page owns column widths and spacing. No hero or landing classes are required. Code-only examples can use a `
` around adjacent fenced blocks, @@ -215,12 +224,12 @@ with blank lines between the wrapper and the fences. 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 +[apps/docs/src/components/ui](apps/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`. +works from `apps/docs`. 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 @@ -230,12 +239,12 @@ the landing hero and every demo. ### Build -[packages/docs/vite-plugin-content.ts](packages/docs/vite-plugin-content.ts) +[apps/docs/vite-plugin-content.ts](apps/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 +[apps/docs/src/routes](apps/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 @@ -257,7 +266,7 @@ 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 +DOCS_BASE=/keyrove/ pnpm --filter @mixedrays/keyrove-docs build ``` `DOCS_SITE_URL` sets the origin used for the links in `llms.txt`. @@ -265,7 +274,7 @@ DOCS_BASE=/keyrove/ pnpm --filter @mixedrays/keyrove/docs build ### Resolving the library The docs site aliases `@mixedrays/keyrove` to the library's TypeScript source -(see [packages/docs/vite.config.ts](packages/docs/vite.config.ts)), so +(see [apps/docs/vite.config.ts](apps/docs/vite.config.ts)), so `pnpm dev` hot-reloads library edits without a build step in between. The published package still ships `dist` — nothing about its `exports` changes. diff --git a/GLOSSARY.md b/GLOSSARY.md new file mode 100644 index 0000000..30fcabe --- /dev/null +++ b/GLOSSARY.md @@ -0,0 +1,232 @@ +# keyrove Glossary + +keyrove moves keyboard focus through lists, grids, and trees. These terms describe its groups and items, actions and keys, keypress outcomes, roving tab stops, and typeahead, and mark where the application's widget takes over. + +## Groups and items + +**Group**: +A root's item sequence together with the root's settings. Every group is a list or a grid. +In keyrove, a group is a navigation unit, not the ARIA `group` role; one widget can contain several groups. +_Avoid_: Collection, widget, root + +**Root**: +The element whose items and settings define a group. For a keypress, it is the nearest root at or above the event target, otherwise the listener. +_Avoid_: Container, wrapper + +**Listener**: +The element a keyrove handler is attached to, which can serve several roots. It is the fallback root and bounds the reach of focus keys and exit. +_Avoid_: Handler, root + +**Nested root**: +A root inside another root's group, whose own group has separate key bindings and a separate roving tab stop. The outer group's item sequence still runs through the nested root's items. +_Avoid_: Child group, subgroup, isolated group + +**Item**: +An element designated for a group's navigation. Designation alone does not make it navigable or able to receive focus. +In keyrove, an item is defined by designation, not by its ARIA role. +_Avoid_: Option, node, entry + +**Item sequence**: +A group's items in navigation order, with disabled items removed and skipped items kept in place. The order is document order unless a function supplies the items in another order. +In keyrove, the item sequence ignores visual layout, so CSS reordering does not change it. +_Avoid_: Tab order, visual order, item list + +**Own item**: +An item in a group's item sequence that does not belong to a nested root; an item that is itself a nested root belongs to the group around it. Roving tab stops and boundary actions use own items, while stride actions use the whole item sequence. +_Avoid_: Direct child, child item + +**Navigable item**: +An item that is neither skipped nor disabled, and so can be the destination of an action. Being navigable does not guarantee that the element can receive focus. +_Avoid_: Focusable item, enabled item + +**Skipped item**: +An item that keeps its place in the item sequence but is not navigable. Skipping alone does not take the item out of the Tab order. +_Avoid_: Disabled item, hidden item, removed item + +**Disabled item**: +An item carrying the `disabled` attribute, which removes it from the item sequence and from focus-key destinations. +In keyrove, `aria-disabled` alone does not make a disabled item. +_Avoid_: Skipped item, inactive item, unavailable item + +**Focused item**: +The first item in the item sequence that contains browser focus, on itself or on a descendant. Where items nest, the outer item is therefore the focused item. +In keyrove, the focused item differs from the focused element when focus is on a control inside an item. +_Avoid_: Current item, active item, selected item, position + +## Layout + +**List**: +A group with a column count of one, in either orientation. +_Avoid_: Vertical group, one-dimensional group + +**Grid**: +A group with a column count above one, whose item sequence fills consecutive rows of that many items. +In keyrove, "grid" means a column count above one, which a CSS grid can supply; neither the ARIA `grid` role nor on-screen geometry makes a group a grid. +_Avoid_: Matrix, table, spatial navigation + +**Column count**: +The number of items per row of a group's item sequence, either fixed or counted from the root's CSS grid columns. A column count of one makes a list. +_Avoid_: Row length, width + +**Orientation**: +The vertical or horizontal axis of a list's default bindings for next and previous item. +In keyrove, orientation applies only to lists and never reorders the item sequence; a grid's next and previous item actions always run along its rows. +_Avoid_: Direction, flow + +**Reading direction**: +The left-to-right or right-to-left text direction at a group's root, which decides the horizontal default bindings for next and previous item. +In keyrove, reading direction affects only default bindings; an explicit key binding keeps its literal key. +_Avoid_: Text direction, RTL mode + +**Page length**: +The distance of a page jump: that many items in a list, or rows in a grid. Skipped items count toward it, though a page jump never lands on one. +In keyrove, page length is a fixed count, not the number of items visible in a scrolled viewport. +_Avoid_: Page size, visible-page size + +**Looping**: +A list setting under which next and previous item actions continue past one end of the item sequence to the navigable item at the other end. +In keyrove, only lists loop, and only for next and previous item; "cycle" is reserved for typeahead's cycle mode. +_Avoid_: Wrapping, cycling, circular navigation + +## Actions and moves + +**Action**: +The navigation operation a keypress or a call from code resolves to: a stride action, a boundary action, a jump to a focus key's destination, or a typeahead match. +In keyrove, an action is what was requested; a move is what happened. +_Avoid_: Intent, command + +**Move**: +A transfer of browser focus, made by keyrove, that lands on or inside its destination. +In keyrove, "move" means focus actually transferred; an action that ends as a consumed no-op is not a move. +_Avoid_: Navigation, focus change + +**Stride action**: +An action through a group's item sequence: next or previous item, next or previous row, the ends of the group or row, or a page jump. Row actions exist only in grids and keep the column, while next and previous item actions continue across rows. +_Avoid_: Arrow action, step + +**Group entry**: +A move into a group while none of its items holds focus. By keypress, only next and previous item or row actions and focus keys can make one. +In keyrove, group entry is distinct from enter, the boundary action into a nested root. +_Avoid_: Enter, focus-in + +**Boundary action**: +An action between a nested root's group and the group around it: enter or exit. Neither has a default binding. +_Avoid_: Drill-down, tree expansion + +**Enter**: +The boundary action from the focused item into the first nested root inside it, landing on that group's roving tab stop when navigable, otherwise on its first navigable own item. +In keyrove, enter names this action, never the Enter key, which stays unbound for activation. +_Avoid_: Drill in, expand, open + +**Exit**: +The boundary action from a nested root to a navigable own item of the group around it: the item containing the nested root, otherwise the nearest one after it, otherwise the nearest one before it. Exit never leaves the listener. +In keyrove, exit names this action, never the Escape key or a loss of focus. +_Avoid_: Back out, collapse, escape + +**Programmatic action**: +A stride action requested by name from code rather than by a keypress, independent of key bindings. It acts from the focused item, otherwise from the roving tab stop, otherwise as a group entry. +In keyrove, app code that focuses an element directly makes programmatic focus, not a programmatic action. +_Avoid_: Programmatic move, synthetic keypress + +## Keys + +**Combo**: +A physical key code with an exact set of modifiers: declared modifiers must be held and undeclared ones must not. The platform modifier means Command on Apple platforms and Control elsewhere. +In keyrove, a combo names a physical key, never a typed character. +_Avoid_: Chord, keystroke, hotkey + +**Key binding**: +The combos a stride or boundary action of a group answers to: its explicit binding where one is set, otherwise its default binding. An explicit binding replaces the default rather than adding to it, and a disabled binding answers to no combo. +_Avoid_: Shortcut, keymap, key mapping + +**Default binding**: +The combo a stride action answers to without an explicit key binding, chosen by whether the group is a list or a grid, its orientation, and its reading direction. Boundary actions have no default binding. +_Avoid_: Built-in key, fallback key + +**Focus key**: +A combo assigned to one destination element, item or not, rather than to a stride or boundary action. It takes precedence over key bindings and reaches any root within the listener. +_Avoid_: Focus shortcut, access key, jump key + +**Editable target**: +An event target inside a text field, select, editable content, or another input whose native behavior uses the keys keyrove binds. From an editable target, only focus keys pressed with Control, Alt, or Meta act. +In keyrove, buttons, checkboxes, and similar inputs are not editable targets, so navigation works from them. +_Avoid_: Input, form field, text input + +## Keypress outcomes + +**Consumed key**: +A keypress keyrove claims by preventing its browser default, whether or not a move follows. +In keyrove, consuming a key never stops its propagation. +_Avoid_: Handled key, captured key, swallowed key + +**Consumed no-op**: +A consumed key that produces no move, such as an action at the end of a list or toward a destination that cannot receive focus. +_Avoid_: Failed move, ignored key + +**Untouched key**: +A keypress keyrove leaves to the browser and other handlers, including one whose default an earlier handler already prevented. +_Avoid_: Unhandled key, ignored key, passed-through key + +## Roving tabindex + +**Roving tabindex**: +A focus arrangement in which a group's roving items share one tab stop that travels with focus. Each nested root keeps its own roving tab stop. +_Avoid_: Roving focus, focus trap + +**Roving item**: +An item that takes part in roving tabindex, whether or not it holds the roving tab stop. Participation is set per item or for the whole group. +_Avoid_: Tab-stop holder, stop item + +**Roving tab stop**: +The one roving item among a group's own items that Tab reaches when moving into the group. It stays on that item when focus leaves the group, so Tab returns there. +_Avoid_: Tab entry point, anchor item + +## Typeahead + +**Typeahead**: +Navigation to a navigable item by typing the start of its typeahead label. Matching uses typed characters, not combos. +In keyrove, typeahead only moves focus; it never filters or hides items. +_Avoid_: Type-to-select, find-as-you-type, autocomplete, filtering + +**Typeahead label**: +The text an item is matched by during typeahead, set explicitly or taken from the item's text content. +In keyrove, the typeahead label need not match the item's accessible name. +_Avoid_: Accessible name, search text + +**Typeahead buffer**: +The typed prefix accumulated for the current typeahead match, cleared after a pause or when the root changes. An unmatched character stays in the buffer until it is cleared. +_Avoid_: Search query, search string + +**Prefix mode**: +The typeahead mode in which every character extends the typeahead buffer and the destination is the first matching navigable item in the item sequence. +_Avoid_: Search mode, substring matching + +**Cycle mode**: +The typeahead mode in which repeating a single character steps through matching navigable items after the focused item, continuing from the start of the item sequence. A different character extends the typeahead buffer as in prefix mode. +_Avoid_: Looping, repeat mode + +**Diacritic folding**: +Typeahead matching that ignores accents and other combining marks in both the typeahead label and the typeahead buffer. Letters without a decomposition, such as ø and ß, stay distinct. +In keyrove, case is always ignored; diacritic folding is a separate setting. +_Avoid_: Accent stripping, transliteration, case folding + +## Widgets + +**Widget**: +An application component, such as a menu, listbox, or tree, built on one or more groups. The widget owns selection, activation, and expansion; keyrove owns only focus and roving tab stops. +_Avoid_: Component, control + +**Selection**: +A widget's record of chosen items, separate from the focused item and the roving tab stop. +In keyrove, selection never follows focus on its own; a widget that wants it to updates selection after each move. +_Avoid_: Focus, highlight + +**Activation**: +A widget's response to the user acting on an item, such as opening a file or toggling a branch. +In keyrove, Enter and Space stay unbound, so activation keys belong to the widget. +_Avoid_: Selection, invocation + +**Tree navigation**: +List navigation through a tree widget's visible rows, with branch expansion owned by the widget. The widget marks rows inside collapsed branches as skipped items. +In keyrove, a tree is one list, not a nested root per branch. +_Avoid_: Hierarchical navigation, tree traversal diff --git a/packages/bench/README.md b/apps/bench/README.md similarity index 99% rename from packages/bench/README.md rename to apps/bench/README.md index 4e717d7..60f77c2 100644 --- a/packages/bench/README.md +++ b/apps/bench/README.md @@ -113,7 +113,7 @@ to see reports saved since. | `results/` | Saved reports. `recorded.json` is the run the Results below come 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. +`apps/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 not touch the page, so another package can import them to show the numbers, diff --git a/packages/bench/harness.html b/apps/bench/harness.html similarity index 100% rename from packages/bench/harness.html rename to apps/bench/harness.html diff --git a/packages/bench/index.html b/apps/bench/index.html similarity index 98% rename from packages/bench/index.html rename to apps/bench/index.html index 2c56e2e..95632f2 100644 --- a/packages/bench/index.html +++ b/apps/bench/index.html @@ -46,13 +46,13 @@ >Docs GitHub Results Reports

Tick one to read it, or several to compare them. Each file in - packages/bench/results is a report; + apps/bench/results is a report; pnpm bench:headless -- --ref v2.1.0 adds one for that version.

@@ -281,7 +281,7 @@

How it is measured

Findings, conclusion and limitations in the README

@@ -306,7 +306,7 @@

How it is measured

>API README diff --git a/packages/bench/package.json b/apps/bench/package.json similarity index 95% rename from packages/bench/package.json rename to apps/bench/package.json index 0e69c4c..1008134 100644 --- a/packages/bench/package.json +++ b/apps/bench/package.json @@ -1,5 +1,5 @@ { - "name": "@mixedrays/keyrove/bench", + "name": "@mixedrays/keyrove-bench", "version": "1.0.0", "private": true, "description": "Browser benchmark for keyrove's keydown handling, with a UI to run it and to compare runs across versions.", diff --git a/packages/bench/public/favicon.svg b/apps/bench/public/favicon.svg similarity index 100% rename from packages/bench/public/favicon.svg rename to apps/bench/public/favicon.svg diff --git a/packages/bench/results/recorded.json b/apps/bench/results/recorded.json similarity index 100% rename from packages/bench/results/recorded.json rename to apps/bench/results/recorded.json diff --git a/packages/bench/results/v2.1.0.json b/apps/bench/results/v2.1.0.json similarity index 100% rename from packages/bench/results/v2.1.0.json rename to apps/bench/results/v2.1.0.json diff --git a/packages/bench/results/v2.2.0.json b/apps/bench/results/v2.2.0.json similarity index 100% rename from packages/bench/results/v2.2.0.json rename to apps/bench/results/v2.2.0.json diff --git a/packages/bench/results/v2.3.0.json b/apps/bench/results/v2.3.0.json similarity index 100% rename from packages/bench/results/v2.3.0.json rename to apps/bench/results/v2.3.0.json diff --git a/packages/bench/results/v2.4.0.json b/apps/bench/results/v2.4.0.json similarity index 100% rename from packages/bench/results/v2.4.0.json rename to apps/bench/results/v2.4.0.json diff --git a/packages/bench/results/v2.5.0.json b/apps/bench/results/v2.5.0.json similarity index 100% rename from packages/bench/results/v2.5.0.json rename to apps/bench/results/v2.5.0.json diff --git a/packages/bench/scripts/library.ts b/apps/bench/scripts/library.ts similarity index 100% rename from packages/bench/scripts/library.ts rename to apps/bench/scripts/library.ts diff --git a/packages/bench/scripts/run.ts b/apps/bench/scripts/run.ts similarity index 100% rename from packages/bench/scripts/run.ts rename to apps/bench/scripts/run.ts diff --git a/packages/bench/src/harness.ts b/apps/bench/src/harness.ts similarity index 100% rename from packages/bench/src/harness.ts rename to apps/bench/src/harness.ts diff --git a/packages/bench/src/icons.ts b/apps/bench/src/icons.ts similarity index 95% rename from packages/bench/src/icons.ts rename to apps/bench/src/icons.ts index f2caef7..86cd4b1 100644 --- a/packages/bench/src/icons.ts +++ b/apps/bench/src/icons.ts @@ -6,7 +6,6 @@ import { Keyboard, Moon, Play, - RotateCcw, SlidersHorizontal, Square, Sun, @@ -16,7 +15,7 @@ import { /** * The bench's icons, drawn as the docs site draws its own (see - * packages/docs/build/icons.ts): lucide at a 1.75 stroke, inlined into the + * apps/docs/build/icons.ts): lucide at a 1.75 stroke, inlined into the * HTML at build time so none of them wait on the bundle. */ @@ -39,7 +38,6 @@ const ICONS = { keyboard: renderNode(Keyboard), moon: renderNode(Moon), play: renderNode(Play), - reset: renderNode(RotateCcw), settings: renderNode(SlidersHorizontal), stop: renderNode(Square), sun: renderNode(Sun), diff --git a/packages/bench/src/keydown.ts b/apps/bench/src/keydown.ts similarity index 100% rename from packages/bench/src/keydown.ts rename to apps/bench/src/keydown.ts diff --git a/packages/bench/src/main.ts b/apps/bench/src/main.ts similarity index 100% rename from packages/bench/src/main.ts rename to apps/bench/src/main.ts diff --git a/packages/bench/src/plan.ts b/apps/bench/src/plan.ts similarity index 100% rename from packages/bench/src/plan.ts rename to apps/bench/src/plan.ts diff --git a/packages/bench/src/report.ts b/apps/bench/src/report.ts similarity index 100% rename from packages/bench/src/report.ts rename to apps/bench/src/report.ts diff --git a/packages/bench/src/results.ts b/apps/bench/src/results.ts similarity index 98% rename from packages/bench/src/results.ts rename to apps/bench/src/results.ts index 4bab79d..94b65e2 100644 --- a/packages/bench/src/results.ts +++ b/apps/bench/src/results.ts @@ -12,7 +12,7 @@ import type { } from './types.ts'; /** A 60 Hz frame, in microseconds: the budget a keydown shares. */ -export const FRAME_US = 1_000_000 / 60; +const FRAME_US = 1_000_000 / 60; /** Section names in the order the run recorded them. */ export const sectionsOf = (results: BenchResults) => [ @@ -20,8 +20,7 @@ export const sectionsOf = (results: BenchResults) => [ ]; /** A case is its page and its label; the same pair names it in any run. */ -export const caseKey = (fixture: string, label: string) => - `${fixture}\u0000${label}`; +const caseKey = (fixture: string, label: string) => `${fixture}\u0000${label}`; export const indexCases = (results: BenchResults) => new Map( diff --git a/packages/bench/src/style.css b/apps/bench/src/style.css similarity index 100% rename from packages/bench/src/style.css rename to apps/bench/src/style.css diff --git a/packages/bench/src/types.ts b/apps/bench/src/types.ts similarity index 100% rename from packages/bench/src/types.ts rename to apps/bench/src/types.ts diff --git a/packages/bench/src/view.ts b/apps/bench/src/view.ts similarity index 100% rename from packages/bench/src/view.ts rename to apps/bench/src/view.ts diff --git a/packages/bench/tests/results.test.ts b/apps/bench/tests/results.test.ts similarity index 100% rename from packages/bench/tests/results.test.ts rename to apps/bench/tests/results.test.ts diff --git a/packages/bench/tsconfig.json b/apps/bench/tsconfig.json similarity index 80% rename from packages/bench/tsconfig.json rename to apps/bench/tsconfig.json index 3afecf8..309fd3a 100644 --- a/packages/bench/tsconfig.json +++ b/apps/bench/tsconfig.json @@ -6,7 +6,7 @@ "resolveJsonModule": true, "types": ["vite/client", "node"], "paths": { - "@mixedrays/keyrove": ["../keyrove/src/index.ts"] + "@mixedrays/keyrove": ["../../packages/keyrove/src/index.ts"] } }, "include": ["src", "scripts", "tests", "vite.config.ts"] diff --git a/packages/bench/vite.config.ts b/apps/bench/vite.config.ts similarity index 100% rename from packages/bench/vite.config.ts rename to apps/bench/vite.config.ts diff --git a/apps/docs/build/addresses.ts b/apps/docs/build/addresses.ts new file mode 100644 index 0000000..375213b --- /dev/null +++ b/apps/docs/build/addresses.ts @@ -0,0 +1,74 @@ +/** + * Every address the site has, worked out in one place. + * + * A page's route — `docs/api`, or `''` for the landing page — has a site path, + * `/docs/api`, which is how content, chrome, the router and the prerender all + * name it. Pages are served at extensionless URLs (`/docs/api`, from + * `dist/client/docs/api.html`) so that appending `.md` lands on the twin + * beside it. + * + * A deploy puts every site path under its base, e.g. `/keyrove/` for a GitHub + * Pages project site. Relative hrefs cannot be resolved consistently against a + * URL with no trailing slash, hence site paths and an explicit base. + * + * Nothing here touches the filesystem, so the browser bundle imports it too. + */ + +/** `docs/examples/basic` → `/docs/examples/basic`; the landing page → `/`. */ +export const pagePath = (route: string) => `/${route}`; + +/** The `.md` twin of a page: `docs/api` → `/docs/api.md`, the landing page → `/index.md`. */ +export const twinPath = (route: string) => + `/${route === '' ? 'index' : route}.md`; + +/** + * Every file the site generates besides the pages and their twins, declared + * once: the prerender lists these paths, vite-plugin-content.ts gives each its + * body, and src/server/files.ts serves it at its path. + * + * Each also needs a route file under `src/routes/`, which TanStack Start's + * file-based routing wants one of per URL. Those only hand the request to + * `serveFile`, and the prerender fails on any path that answers 404, so a + * missing route cannot go unnoticed. + */ +export const GENERATED_FILES = { + searchIndex: { path: '/search-index.json', type: 'application/json' }, + llmsTxt: { path: '/llms.txt', type: 'text/plain' }, + llmsFullTxt: { path: '/llms-full.txt', type: 'text/plain' }, + sitemap: { path: '/sitemap.xml', type: 'application/xml' }, + robotsTxt: { path: '/robots.txt', type: 'text/plain' }, + // The site icon, at the root beside robots.txt. + favicon: { path: '/favicon.svg', type: 'image/svg+xml' }, +} as const; + +export type GeneratedFile = keyof typeof GENERATED_FILES; + +export type Addresses = ReturnType; + +/** + * Site paths as one deploy serves them: under `base`, at `siteUrl`. + * + * Canonical tags, Open Graph, the sitemap and llms.txt need absolute URLs, and + * only the build knows the site URL. The browser bundle leaves it out and asks + * for hrefs alone, which resolve against whichever origin served the page. + */ +export const createAddresses = ({ + base, + siteUrl = '', +}: { + base: string; + siteUrl?: string; +}) => { + /** `/docs/api#followfocus` → `/keyrove/docs/api#followfocus`, under `/keyrove/`. */ + const href = (path: string) => `${base}${path.replace(/^\//, '')}`; + + /** `/docs/api` → `https://keyrove.pages.dev/docs/api`. */ + const url = (path: string) => `${siteUrl}${href(path)}`; + + return { + href, + url, + pageUrl: (route: string) => url(pagePath(route)), + twinUrl: (route: string) => url(twinPath(route)), + }; +}; diff --git a/packages/docs/build/content.ts b/apps/docs/build/content.ts similarity index 98% rename from packages/docs/build/content.ts rename to apps/docs/build/content.ts index 24ddddc..22115b2 100644 --- a/packages/docs/build/content.ts +++ b/apps/docs/build/content.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import matter from 'gray-matter'; +import { GENERATED_FILES } from './addresses.ts'; import { loadLastModified } from './git.ts'; /** @@ -83,7 +84,7 @@ export type NavGroup = { label: string; pages: Page[]; links: NavLink[] }; * the pager walks pages, and this is not one. */ const EXTRA_LINKS: Record = { - Guide: [{ label: 'llms.txt', href: '/llms.txt' }], + Guide: [{ label: 'llms.txt', href: GENERATED_FILES.llmsTxt.path }], }; /** Frontmatter is authored by hand, so treat every field as missing until proven otherwise. */ diff --git a/apps/docs/build/demo-definitions.ts b/apps/docs/build/demo-definitions.ts new file mode 100644 index 0000000..95f7b82 --- /dev/null +++ b/apps/docs/build/demo-definitions.ts @@ -0,0 +1,182 @@ +import type { GroupOptions } from '@mixedrays/keyrove'; + +import type { IconName } from './icons.ts'; + +/** + * What each docs demo is beyond its markup, in one entry per demo. + * + * A demo is a fragment under content/_demos and the entry here of the same + * name. The fragment is what a reader copies; the entry is everything a page + * adds to make it run and read well. build/demos.ts reads an entry to stamp + * the demo, and src/demos.ts reads it to wire one, so neither has a table of + * demo names of its own to keep in step with the other. + * + * Nothing here touches the filesystem, and the types are all it takes from + * keyrove and the icon set, so the browser bundle imports it too. + */ + +/** + * Behaviour a demo runs beyond navigation, chained after `keyRove` in the + * order listed, so a key one leaves alone falls through to the next exactly + * as the pages' own `||` chains do. src/demos.ts implements each one. + * + * - `typeahead` finds an item by its label, with the demo's `group`. + * - `selection` is the listbox's pick: Space, Enter or a click. + * - `folds` opens and closes the sidebar's folders. + * - `branches` opens, closes and steps through the tree's folders. + */ +export type DemoExtra = 'typeahead' | 'selection' | 'folds' | 'branches'; + +/** One icon, or a folder's pair, which `aria-expanded` picks between. */ +export type ItemIcon = IconName | { closed: IconName; open: IconName }; + +/** A live item, as an entry's `icons` sees it. */ +type DemoItem = { + /** The item's text, trimmed. */ + label: string; + /** Whether it carries `aria-expanded`: a folder rather than a file. */ + expandable: boolean; +}; + +export type DemoDefinition = { + /** + * The settings the group is described with, for a demo described in + * JavaScript rather than in markup. Handed to every handler exactly as the + * page's snippet hands it to them. Without one, the group is configured by + * its own attributes, which is what every other page teaches. + */ + group?: GroupOptions; + /** What runs after navigation, in order. Without any, navigation alone. */ + extras?: readonly DemoExtra[]; + /** + * Decorative icons for the live preview, by item label or worked out per + * item. Presentation only: the source block stays the fragment as written, + * and the icons are `aria-hidden`, so labels and typeahead are unchanged. + * Only items whose content is plain text are decorated. + */ + icons?: Readonly> | ((item: DemoItem) => ItemIcon); +}; + +const FOLDERS: Record = { + Inbox: 'inbox', + Drafts: 'drafts', + Sent: 'sent', +}; + +const TOOLS: Record = { + Move: 'pointer', + Frame: 'frame', + Rectangle: 'square', + Ellipse: 'circle', + Pen: 'pen', + Text: 'type', + Eyedropper: 'pipette', + Hand: 'hand', +}; + +/** + * Every demo, by the name of its file under content/_demos. + * + * Typeahead is opted into rather than given to every list: on a page that + * never mentions it, letters doing something would be a surprise. + */ +export const DEMO_DEFINITIONS: Readonly> = { + editable: {}, + grid: {}, + keys: { + icons: { + Bold: 'bold', + Italic: 'italic', + Underline: 'underline', + Link: 'link', + Code: 'code', + }, + }, + labels: { extras: ['typeahead'] }, + 'landing-grid': {}, + 'landing-horizontal': { icons: FOLDERS }, + 'landing-loop': { icons: FOLDERS }, + 'landing-roving': { icons: FOLDERS }, + 'landing-tools': { icons: TOOLS }, + list: {}, + listbox: { extras: ['typeahead', 'selection'] }, + loop: {}, + // Described in JavaScript, so its typeahead is handed the same object as + // navigation, which is the point the options page makes. + menu: { + group: { items: '[role="menuitem"]', loop: true, rovingTabindex: true }, + extras: ['typeahead'], + icons: { + 'Copy link': 'link', + 'Email a copy': 'mail', + 'Export as PDF': 'file-download', + 'Post to Slack': 'message', + 'Open in Figma': 'pen', + }, + }, + // The reaction buttons already have emoji; only the menu rows get icons. + nested: { + icons: { + Reply: 'reply', + 'Reply in thread': 'message', + 'Copy link': 'link', + 'Pin to channel': 'pin', + 'Delete message': 'trash', + }, + }, + orientation: {}, + panes: {}, + responsive: {}, + roving: { + icons: { + Backlog: 'circle-dashed', + 'To do': 'circle', + Blocked: 'circle-slash', + Review: 'circle-dot', + Done: 'circle-check', + Archived: 'archive', + }, + }, + rtl: {}, + sidebar: { extras: ['folds'] }, + skip: { + icons: { + 'quarterly-report.pdf': 'file-text', + 'budget-v3-final-FINAL.xlsx': 'file-spreadsheet', + 'roadmap.md': 'file-code', + 'design-review.fig': 'pen', + 'pension-fund.xls': 'file-spreadsheet', + }, + }, + tools: { icons: TOOLS }, + // A row inside a closed folder is still in the DOM, so it is still an item; + // `skip` is what keeps a move from landing on one nobody can see. + tree: { + group: { + items: '[role="treeitem"]', + skip: '[hidden] [role="treeitem"]', + rovingTabindex: true, + }, + extras: ['typeahead', 'branches'], + icons: ({ label, expandable }) => + expandable + ? { closed: 'folder', open: 'folder-open' } + : label.endsWith('.json') + ? 'file-json' + : /\.(tsx?|css)$/.test(label) + ? 'file-code' + : 'file-text', + }, + typeahead: { extras: ['typeahead'] }, +}; + +/** The entry for a demo, or a build-stopping error naming the one missing. */ +export const demoDefinition = (name: string): DemoDefinition => { + if (!Object.hasOwn(DEMO_DEFINITIONS, name)) { + throw new Error( + `[docs] no demo named "${name}" in build/demo-definitions.ts.`, + ); + } + + return DEMO_DEFINITIONS[name]; +}; diff --git a/apps/docs/build/demo-icons.ts b/apps/docs/build/demo-icons.ts new file mode 100644 index 0000000..b1ac4d2 --- /dev/null +++ b/apps/docs/build/demo-icons.ts @@ -0,0 +1,42 @@ +import type { DemoDefinition, ItemIcon } from './demo-definitions.ts'; +import { icon } from './icons.ts'; + +// Only plain-text demo items are decorated. The nested reaction buttons +// already have emoji; grids use their time and month labels on their own. +const ITEM = + /<(li|button|div)\b([^>]*\b(?:data-keyrove-item|role="(?:menuitem|treeitem)")[^>]*)>([^<]*)<\/\1>/g; + +const draw = (glyph: ItemIcon) => + typeof glyph === 'string' + ? icon(glyph, 'demo-item-icon') + : icon(glyph.closed, 'demo-item-icon demo-item-icon--closed') + + icon(glyph.open, 'demo-item-icon demo-item-icon--open'); + +/** + * Add a demo definition's decorative icons to the live preview at build time. + * Copied navigation examples stay minimal, and the aria-hidden SVGs leave + * labels and typeahead unchanged. + */ +export const decorateDemo = ( + markup: string, + icons: DemoDefinition['icons'], +): string => { + if (!icons) return markup; + + return markup.replace( + ITEM, + (original, tag: string, attrs: string, text: string) => { + const label = text.trim(); + const glyph = + typeof icons === 'function' + ? icons({ label, expandable: attrs.includes('aria-expanded=') }) + : Object.hasOwn(icons, label) + ? icons[label] + : undefined; + + return glyph + ? `<${tag}${attrs}>${draw(glyph)}${text}` + : original; + }, + ); +}; diff --git a/apps/docs/build/demos.ts b/apps/docs/build/demos.ts new file mode 100644 index 0000000..653b5d3 --- /dev/null +++ b/apps/docs/build/demos.ts @@ -0,0 +1,231 @@ +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { READOUT_HTML } from '../src/readout.ts'; +import { demoDefinition } from './demo-definitions.ts'; +import { decorateDemo } from './demo-icons.ts'; + +/** + * Live demos, built from the markup the page documents. + * + * One file under `content/_demos` is a demo, with an entry of the same name in + * build/demo-definitions.ts. It is stamped into the page twice — once as real + * elements a reader can arrow through, once as the source block under them — + * so the two cannot drift: what is on the clipboard is what was just pressed a + * key in. + * + * A content file writes only the placeholder, + * `
`, and the + * fragment carries no site classes: styling hangs off the same + * `data-keyrove-*` attributes the docs teach, which is what keeps a fragment + * markup a reader could paste as-is. + * + * The script a page writes straight under the placeholder joins the source + * block as a tab of its own (see build/markdown.ts), so the markup and the call + * that drives it read as the two files of one demo rather than as a demo and a + * stray block below it. + */ + +const DEMOS_DIR = fileURLToPath(new URL('../content/_demos/', import.meta.url)); + +/** Demo name — its filename without the extension — to its markup. */ +export type Demos = Map; + +/** What the reader gets: HTML for a page, a fenced block for the `.md` twin. */ +type DemoTarget = 'html' | 'markdown'; + +/** + * A folded region: markup that runs but is not worth reading. + * + * Both markers are needed rather than one cut point, so that what is shown + * stays balanced — a list truncated after its third item would be shown with no + * closing tag. The elided lines are always repetition of the lines above them. + */ +const FOLD = + /^([ \t]*)\n[\s\S]*?^[ \t]*[ \t]*\n/gm; + +const FOLD_MARKER = /^[ \t]*[ \t]*\n/gm; + +const FENCE = '```'; + +/** + * The placeholder a content file writes, alone on its line, and the fenced + * blocks directly under it. Any line opening a `
` with a `data-demo*` + * attribute is taken for one, so a placeholder written wrong fails the build + * (see `parsePlaceholder`) rather than reaching the page as an empty `
`. + * + * The blocks are the demo's other files, and they are taken with it so they + * can share its panel. HTML is left where it stands: a demo has one HTML file, + * its own, and an HTML block right under it is a closer look at that file — + * custom keys picks out the two attributes of its root — not a second one. + */ +const PLACEHOLDER = new RegExp( + String.raw`^(<\/output>$/gm; + +/** + * What a placeholder says, read off its attributes in whatever order. + * + * - `data-demo` names the demo. + * - `data-demo-label` names the panel's live preview; every demo has one. + * - `data-demo-class`, optional, is layout the demo needs but the library + * does not teach — the grid's columns, the long list's scroll box. It lands + * on the surface at render time rather than in the fragment, so it stays + * out of the source block. + */ +type Placeholder = { name: string; label: string; surfaceClass: string }; + +const PLACEHOLDER_ATTRIBUTES = new Set([ + 'data-demo', + 'data-demo-label', + 'data-demo-class', +]); + +const ATTRIBUTE = /\s+([\w-]+)="([^"]*)"/g; + +/** + * Reads a placeholder, or fails the build on one it cannot read: anything + * short of an exact placeholder would otherwise ship as an empty `
`, + * which is a demo gone missing without a word. + */ +const parsePlaceholder = (line: string): Placeholder => { + const fail: (reason: string) => never = (reason) => { + throw new Error(`[docs] ${reason} in the demo placeholder ${line}`); + }; + + const tag = /^<\/div>$/.exec(line); + if (!tag) fail('unreadable markup'); + + const attributes = new Map(); + for (const [, name, value] of tag[1].matchAll(ATTRIBUTE)) { + if (!PLACEHOLDER_ATTRIBUTES.has(name)) fail(`unknown attribute ${name}`); + if (attributes.has(name)) fail(`${name} twice`); + attributes.set(name, value); + } + + const name = attributes.get('data-demo'); + const label = attributes.get('data-demo-label'); + if (!name) fail('no data-demo'); + if (!label) fail('no data-demo-label to name the panel'); + + return { + name, + label, + surfaceClass: attributes.get('data-demo-class') ?? '', + }; +}; + +/** The excerpt a reader sees: each folded region collapses to one comment. */ +const toExcerpt = (markup: string) => markup.replace(FOLD, '$1\n'); + +/** + * The markup that actually runs: markers drop out, and so do blank lines — + * markdown-it ends a raw HTML block at the first one, which would close the + * demo's wrapper early. + */ +const toLive = (markup: string) => + markup.replace(FOLD_MARKER, '').replace(/^[ \t]*\n/gm, ''); + +/** Adds the site's classes to the fragment's root, which is the demo surface. */ +const withClass = (markup: string, className: string) => + markup.replace(/^<(\w+)/, `<$1 class="${className}"`); + +const escapeHtml = (text: string) => + text.replace(/&/g, '&').replace(//g, '>'); + +/** + * One demo's panel: a header naming it over the readout, the live markup, then + * the same markup as source. + * + * The demo's name rides on the panel as `data-demo`, so src/demos.ts can look + * up the behaviour its definition adds to navigation. + * + * The source is emitted as a fence rather than as pre-highlighted HTML so that + * it goes through the page's own Shiki pass and is themed like every other + * block on the site. That is why the panel is split around it — markdown-it + * needs the blank lines either side to see a fence at all. `files` are the + * blocks the placeholder took with it, still fenced and each led by its blank + * line, so they land beside the markup and share its panel. + */ +const renderPanel = ( + { name, label, surfaceClass }: Placeholder, + markup: string, + files: string, +) => { + const live = withClass( + toLive(markup), + ['demo-surface', surfaceClass].filter(Boolean).join(' '), + ); + + return `
+
+Live · ${escapeHtml(label)} +${READOUT_HTML} +
+
${decorateDemo(live, demoDefinition(name).icons)}
+ +${FENCE}html copy +${toExcerpt(markup)} +${FENCE}${files} + +
`; +}; + +export const loadDemos = async (): Promise => { + const files = await readdir(DEMOS_DIR); + + const entries = await Promise.all( + files + .filter((file) => file.endsWith('.html')) + .sort() + .map( + async (file) => + [ + file.replace(/\.html$/, ''), + (await readFile(path.join(DEMOS_DIR, file), 'utf8')).trim(), + ] as const, + ), + ); + + return new Map(entries); +}; + +/** + * Replaces every placeholder in a markdown body with the demo it names, and + * the hero's readout placeholder with the readout. + * + * This runs on the source rather than on rendered HTML, so the `.md` twin a + * reader (or an agent) fetches carries the markup too — it used to carry the + * bare placeholder, which said nothing at all. The blocks a placeholder took + * with it go back under the markup there, as the page wrote them. + */ +export const expandDemos = ( + body: string, + demos: Demos, + target: DemoTarget, +): string => + body + .replace(PLACEHOLDER, (_match, line: string, files: string) => { + const placeholder = parsePlaceholder(line); + const markup = demos.get(placeholder.name); + if (markup === undefined) { + throw new Error( + `[docs] no demo named "${placeholder.name}" in content/_demos.`, + ); + } + + return target === 'html' + ? renderPanel(placeholder, markup, files) + : `${FENCE}html\n${toExcerpt(markup)}\n${FENCE}${files}`; + }) + .replace(READOUT_PLACEHOLDER, READOUT_HTML); diff --git a/packages/docs/build/git.ts b/apps/docs/build/git.ts similarity index 100% rename from packages/docs/build/git.ts rename to apps/docs/build/git.ts diff --git a/packages/docs/build/head.ts b/apps/docs/build/head.ts similarity index 86% rename from packages/docs/build/head.ts rename to apps/docs/build/head.ts index 8482ec4..e939790 100644 --- a/packages/docs/build/head.ts +++ b/apps/docs/build/head.ts @@ -1,6 +1,5 @@ +import type { Addresses } from './addresses.ts'; 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'; /** @@ -11,13 +10,13 @@ import { toStructuredData } from './structured-data.ts'; * carries the manifests or the site's origin around just to rebuild it. */ -export type HeadMeta = +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 }; +type HeadLink = { rel: string; href: string; type?: string }; export type Head = { meta: HeadMeta[]; links: HeadLink[] }; @@ -57,7 +56,11 @@ const toTitle = (page: Page) => * 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 => { +export const toHead = ( + page: Page, + nav: NavGroup[], + addresses: Addresses, +): Head => { const title = toTitle(page); const meta: HeadMeta[] = [ { title }, @@ -71,11 +74,8 @@ export const toHead = (page: Page, nav: NavGroup[], base: string): Head => { }; } - 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)}`; + const url = addresses.pageUrl(page.route); + const image = addresses.url(OG_IMAGE.path); return { meta: [ @@ -103,7 +103,7 @@ export const toHead = (page: Page, nav: NavGroup[], base: string): Head => { 'script:ld+json': toStructuredData({ page, nav, - toUrl, + toUrl: addresses.pageUrl, imageUrl: image, }), }, @@ -115,7 +115,7 @@ export const toHead = (page: Page, nav: NavGroup[], base: string): Head => { { rel: 'alternate', type: 'text/markdown', - href: `${META.siteUrl}${resolveHref(toMarkdownPath(page.route))}`, + href: addresses.twinUrl(page.route), }, ], }; diff --git a/packages/docs/build/html-tree.ts b/apps/docs/build/html-tree.ts similarity index 78% rename from packages/docs/build/html-tree.ts rename to apps/docs/build/html-tree.ts index 11c703d..31538f8 100644 --- a/packages/docs/build/html-tree.ts +++ b/apps/docs/build/html-tree.ts @@ -6,6 +6,8 @@ import { } from 'html-react-parser'; import { DomUtils, parseDocument } from 'htmlparser2'; +import { isOpaque, placeholderOf } from './placeholders.ts'; + /** * A page's rendered HTML as a tree React can draw without parsing anything. * @@ -57,26 +59,26 @@ const library = { 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. + * Whether an element's contents are kept as HTML, for + * `dangerouslySetInnerHTML`, rather than as a tree React would reconcile: a + * placeholder build/placeholders.ts marks opaque, which React draws once and + * something else rearranges from then on. */ -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; +const hasOpaqueContents = (element: DomElement) => { + const name = placeholderOf( + element.name, + element.attribs.class ?? '', + (attribute) => attribute in element.attribs, + ); + return name !== undefined && isOpaque(name); +}; export const toHtmlTree = (html: string): HtmlNode[] => { const options = { library, replace: (node: DOMNode) => { - if (node.type !== 'tag' || !isOpaque(node as DomElement)) return; + if (node.type !== 'tag' || !hasOpaqueContents(node as DomElement)) return; const element = node as DomElement; return library.createElement(element.name, { diff --git a/packages/docs/build/icons.ts b/apps/docs/build/icons.ts similarity index 95% rename from packages/docs/build/icons.ts rename to apps/docs/build/icons.ts index 2ee1ae3..61ddd67 100644 --- a/packages/docs/build/icons.ts +++ b/apps/docs/build/icons.ts @@ -1,6 +1,5 @@ import { Archive, - ArrowRight, ArrowRightToLine, Bold, Circle, @@ -9,7 +8,6 @@ import { CircleDot, CircleSlash, Code, - Command, FileCode, FileDown, FileJson, @@ -29,7 +27,6 @@ import { PenTool, Pin, Pipette, - RefreshCw, Reply, Scan, Send, @@ -37,7 +34,6 @@ import { SkipForward, Sparkles, Square, - TextCursorInput, Trash2, Type, Underline, @@ -62,17 +58,13 @@ import { const ICONS = { ...CHROME_ICONS, - 'arrow-right': fromLucide(ArrowRight), - // The landing page's feature cards and next steps, placed from the markdown + // The landing page's illustrations and next steps, placed from the markdown // with the placeholder `expandIcons` reads. - command: fromLucide(Command), layers: fromLucide(Layers), - refresh: fromLucide(RefreshCw), skip: fromLucide(SkipForward), sparkles: fromLucide(Sparkles), tab: fromLucide(ArrowRightToLine), - 'text-cursor': fromLucide(TextCursorInput), // The mail folders in the landing page's hero demo. inbox: fromLucide(Inbox), diff --git a/packages/docs/build/markdown.ts b/apps/docs/build/markdown.ts similarity index 60% rename from packages/docs/build/markdown.ts rename to apps/docs/build/markdown.ts index 6b83346..d6d86b8 100644 --- a/packages/docs/build/markdown.ts +++ b/apps/docs/build/markdown.ts @@ -1,19 +1,16 @@ import Shiki from '@shikijs/markdown-it'; -import MarkdownIt, { - type MarkdownIt as MarkdownItInstance, - type Token, -} from 'markdown-it'; - -import { icon } from './icons.ts'; +import MarkdownIt, { type Token } from 'markdown-it'; /** - * Markdown → HTML, with the two bits of structure the docs layout needs: - * stable heading ids to link at, and the heading list the "On this page" rail - * is built from. + * Markdown → HTML, with the structure the site needs beside it: stable heading + * ids to link at, the heading list the "On this page" rail is built from, and + * the sections the search index is built from. * - * Both come out of a single parse. `md.render` would hide the token stream, so - * this parses and renders in two steps and walks the tokens in between — - * cheaper and less fragile than re-deriving headings from the rendered HTML. + * All of it comes out of a single parse. `md.render` would hide the token + * stream, so this parses and renders in two steps and walks the tokens in + * between — cheaper and less fragile than re-deriving headings from the + * rendered HTML, and the ids a search result links to are the ids the page + * renders, because they are the same tokens. */ /** @@ -36,14 +33,22 @@ export type Heading = { */ const ANCHORED_TAGS = new Set(['h2', 'h3']); -export type RenderContext = { - /** Turns a site-absolute href like `/docs/api` into one this page can use. */ - resolveHref: (href: string) => string; +/** + * A stretch of a page a search result can land on: the text under one h2 or + * h3, up to the next. The first section is what comes before any heading, and + * has no id of its own. + */ +export type Section = { + id: string; + heading: string; + text: string; }; -export type RenderedMarkdown = { - html: string; +type ParsedMarkdown = { headings: Heading[]; + sections: Section[]; + /** The HTML. The first call loads Shiki, which takes seconds. */ + render: () => Promise; }; /** What a tab calls a block when its fence gives no `title="…"`. */ @@ -89,19 +94,14 @@ const hasFlag = (info: string, flag: string) => 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. The markup - * marks where it goes and what it is called; src/components/markdown.tsx swaps - * in the component that copies. + * The copy button a code panel carries, the demos' source blocks among them. + * It copies the visible block beside it in the same container. The markup is + * a placeholder (see build/placeholders.ts) saying only where the button goes + * and what it is called: src/components/markdown.tsx swaps in the component, + * which draws its own glyphs. */ -export const copyButton = (label: string) => - [ - '', - ].join(' '); +const COPY_BUTTON = + ''; /** The visible text of a heading, with the markdown syntax dropped. */ const plainText = (token: Token): string => @@ -150,13 +150,13 @@ const collectHeadings = (tokens: Token[]): Heading[] => { return headings; }; -/** Search uses the same parser and ID allocator, without loading Shiki. */ -const searchParser = MarkdownIt({ html: true, linkify: true }); - -export const collectSearchSections = (source: string) => { - const tokens = searchParser.parse(source, {}); - collectHeadings(tokens); - const sections = [{ id: '', heading: '', text: '' }]; +/** + * Splits the page at its anchored headings, keeping the text a reader would + * search for: prose, inline code and code blocks. Runs after + * `collectHeadings`, whose ids it reads. + */ +const collectSections = (tokens: Token[]): Section[] => { + const sections: Section[] = [{ id: '', heading: '', text: '' }]; for (let index = 0; index < tokens.length; index++) { const token = tokens[index]; if (token.type === 'heading_open' && ANCHORED_TAGS.has(token.tag)) { @@ -179,17 +179,16 @@ export const collectSearchSections = (source: string) => { }; /** - * Rewrites the site-absolute links authors write in markdown. + * Opens off-site links in a new tab, with the usual `rel`. * - * Pages are linked as `/docs/api` in the source regardless of where they end up - * being served from; `resolveHref` is what turns that into a working href for - * the page currently being rendered. Off-site links are left alone but get the - * usual `rel`, since they open in a new tab. + * Links between pages are written site-absolute, as `/docs/api`, and stay that + * way: the router prefixes the deploy base when it renders them, and a second + * prefix here would double it. */ -const resolveLinks = (tokens: Token[], { resolveHref }: RenderContext) => { +const openExternalLinks = (tokens: Token[]) => { for (const token of tokens) { if (token.type === 'inline') { - resolveLinks(token.children ?? [], { resolveHref }); + openExternalLinks(token.children ?? []); continue; } @@ -198,11 +197,8 @@ const resolveLinks = (tokens: Token[], { resolveHref }: RenderContext) => { // markdown-it types attribute values as `string | number`; an href is // always the former, but the narrowing has to be written out. const href = String(token.attrGet('href') ?? ''); - if (href === '') continue; - if (href.startsWith('/')) { - token.attrSet('href', resolveHref(href)); - } else if (/^https?:\/\//.test(href)) { + if (/^https?:\/\//.test(href)) { token.attrSet('target', '_blank'); token.attrSet('rel', 'noreferrer'); } @@ -210,47 +206,51 @@ const resolveLinks = (tokens: Token[], { resolveHref }: RenderContext) => { }; /** - * Building the highlighter loads Shiki's grammars, so the instance is created - * once and shared. Rendering itself stays synchronous and per-page. + * Building the highlighter loads Shiki's grammars, which takes seconds, so it + * is loaded once, on the first render, and shared. Parsing does not wait for + * it: Shiki only sets the `highlight` option the fence rule calls, so the + * tokens are the same with it or without it. */ -let instance: Promise | undefined; +let highlighter: Promise | undefined; + +const loadHighlighter = () => + (highlighter ??= Shiki({ + themes: THEMES, + // Emit the CSS variables only — with no default colour baked in, the + // `dark` variant in style.css is what decides which theme applies. + defaultColor: false, + transformers: [ + { + pre(node) { + // Carry the site's panel styling over and drop Shiki's inline + // background, so blocks keep their border and spacing; only the + // tokens inside get themed. + node.properties.class = `code-block ${node.properties.class ?? ''}`; + node.properties['data-lang'] = this.options.lang; + delete node.properties.style; + }, + }, + ], + }).then((plugin) => { + md.use(plugin); + })); -const createRenderer = async () => { +const createRenderer = () => { const md = MarkdownIt({ html: true, linkify: true, typographer: false }); - md.use( - await Shiki({ - themes: THEMES, - // Emit the CSS variables only — with no default colour baked in, the - // `dark` variant in style.css is what decides which theme applies. - defaultColor: false, - transformers: [ - { - pre(node) { - // Carry the site's panel styling over and drop Shiki's inline - // background, so blocks keep their border and spacing; only the - // tokens inside get themed. - node.properties.class = `code-block ${node.properties.class ?? ''}`; - node.properties['data-lang'] = this.options.lang; - delete node.properties.style; - }, - }, - ], - }), - ); - /* * Blocks written back to back are one panel with a tab per block: the * markup and the script that drives it, or the same command for three * package managers. Nothing marks a group but the adjacency itself — any * prose between two blocks keeps them apart. * - * 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. + * The tabs are stamped here as a placeholder (see build/placeholders.ts) + * carrying what each tab is called and which one is selected, and + * src/components/markdown.tsx hands that to Base UI's tabs, which draw the + * rest of the ARIA wiring, switch the tabs 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. * * 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 @@ -258,7 +258,6 @@ const createRenderer = async () => { */ const renderFence = md.renderer.rules.fence!; const isFence = (token: Token | undefined) => token?.type === 'fence'; - const copy = copyButton('Copy to clipboard'); md.renderer.rules.fence = (tokens, index, options, env, self) => { const block = renderFence(tokens, index, options, env, self); @@ -266,55 +265,34 @@ const createRenderer = async () => { const isLast = !isFence(tokens[index + 1]); if (isFirst && isLast) { return wantsCopy(tokens[index].info) - ? `
${copy}${block}
\n` + ? `
${COPY_BUTTON}${block}
\n` : block; } - let start = index; - while (isFence(tokens[start - 1])) start--; - const position = index - start; - - // Worked out afresh for each block of the group, since each one renders - // on its own and has to know whether it is the panel left showing. - let chosen = start; - for (let i = start; isFence(tokens[i]); i++) { - if (hasFlag(tokens[i].info, 'selected')) { - chosen = i; - break; - } - } - - // renderMarkdown always passes one; the fallback only satisfies the type. - const page = (env ?? {}) as { codeTabs?: number }; - if (isFirst) page.codeTabs = (page.codeTabs ?? 0) + 1; - const id = `code-tabs-${page.codeTabs}`; - let open = ''; if (isFirst) { - const tabs: string[] = []; - let copyable = false; - for (let i = index; isFence(tokens[i]); i++) { - copyable ||= wantsCopy(tokens[i].info); - const selected = i === chosen; - tabs.push( - ``, - ); - } + // The first block opens the group, so it stamps every tab. + const fences: Token[] = []; + for (let i = index; isFence(tokens[i]); i++) fences.push(tokens[i]); + const chosen = Math.max( + 0, + fences.findIndex((fence) => hasFlag(fence.info, 'selected')), + ); + + const tabs = fences.map( + (fence, position) => + ``, + ); open = - (copyable - ? `
${copy}` + (fences.some((fence) => wantsCopy(fence.info)) + ? `
${COPY_BUTTON}` : '
') + - `
${tabs.join('')}
`; + `
${tabs.join('')}
`; } - const panel = - `
` + - `${block}
`; + const panel = `
${block}
`; return `${open}${panel}${isLast ? '
\n' : ''}`; }; @@ -375,16 +353,24 @@ const createRenderer = async () => { return md; }; -export const renderMarkdown = async ( - source: string, - context: RenderContext, -): Promise => { - instance ??= createRenderer(); - const md = await instance; +const md = createRenderer(); +/** + * Parses a page once. The headings and sections are read off the tokens + * straight away; `render` turns the same tokens into HTML, so every id either + * of them names is an id the HTML carries. + */ +export const parseMarkdown = (source: string): ParsedMarkdown => { const tokens = md.parse(source, {}); const headings = collectHeadings(tokens); - resolveLinks(tokens, context); - - return { html: md.renderer.render(tokens, md.options, {}), headings }; + openExternalLinks(tokens); + + return { + headings, + sections: collectSections(tokens), + render: async () => { + await loadHighlighter(); + return md.renderer.render(tokens, md.options, {}); + }, + }; }; diff --git a/packages/docs/build/meta.ts b/apps/docs/build/meta.ts similarity index 96% rename from packages/docs/build/meta.ts rename to apps/docs/build/meta.ts index 0d0972f..5e5c4c7 100644 --- a/packages/docs/build/meta.ts +++ b/apps/docs/build/meta.ts @@ -45,8 +45,8 @@ const toAuthorName = (author: string) => author.replace(/\s*<.*$/, '').trim(); const docs = readManifest('../package.json'); const workspace = readManifest('../../../package.json'); // The published library, which is the one an npm link should point at — the -// docs package is private and its name is not a package name. -const library = readManifest('../../keyrove/package.json'); +// docs package is private, so npm has no page for it. +const library = readManifest('../../../packages/keyrove/package.json'); /** * Where the site is deployed. @@ -123,7 +123,7 @@ const renderFacts = () => */ const LICENSE_PLACEHOLDER = /^
<\/div>$/gm; -const licenseText = readText('../../keyrove/LICENSE') +const licenseText = readText('../../../packages/keyrove/LICENSE') .replace(/^.*\n/, '') .trim(); diff --git a/packages/docs/build/og-card.html b/apps/docs/build/og-card.html similarity index 98% rename from packages/docs/build/og-card.html rename to apps/docs/build/og-card.html index 7136118..65cd628 100644 --- a/packages/docs/build/og-card.html +++ b/apps/docs/build/og-card.html @@ -3,7 +3,7 @@ page and uploaded to GitHub as the repo's social preview. Kept as HTML so the card can be edited and re-rendered rather than being an - unexplainable binary. To regenerate, from packages/docs: + unexplainable binary. To regenerate, from apps/docs: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ --headless --disable-gpu --hide-scrollbars --force-device-scale-factor=1 \ diff --git a/apps/docs/build/page-render.ts b/apps/docs/build/page-render.ts new file mode 100644 index 0000000..90f2af5 --- /dev/null +++ b/apps/docs/build/page-render.ts @@ -0,0 +1,69 @@ +import type { Page } from './content.ts'; +import { expandDemos, type Demos } from './demos.ts'; +import { toHtmlTree, type HtmlNode } from './html-tree.ts'; +import { expandIcons, stripIcons } from './icons.ts'; +import { parseMarkdown, type Heading, type Section } from './markdown.ts'; +import { expandMeta } from './meta.ts'; + +/** + * Everything the site makes of one page, from one parse. + * + * The page body, the rail and the search index are three readings of the same + * tokens, so the ids they name cannot disagree. The order the page goes + * through — placeholders expanded, markdown parsed, HTML rendered, icons drawn + * — is written down here and nowhere else. + * + * The `.md` twin is the one projection that is not parsed: it is the source + * with its placeholders expanded, and a demo expands differently there — as + * its source block alone, not as live markup with the source beside it. + */ +export type RenderedPage = { + page: Page; + /** The h2 and h3 headings, for the "On this page" rail. */ + headings: Heading[]; + /** The page split at those headings, for the search index. */ + sections: Section[]; + /** The page as standalone markdown, served at its URL with `.md` appended. */ + markdown: string; + /** + * The body as the tree src/components/markdown.tsx draws. The only + * projection that runs Shiki, so it is rendered on the first call rather + * than up front, and once. + */ + tree: () => Promise; +}; + +/** + * A page's `.md` twin. + * + * Frontmatter is site plumbing, so it is replaced by the title and description + * it carried, leaving a document that stands on its own when fetched in + * isolation. Demos are expanded, so the markup a page documents reaches a + * reader who never runs the site — the placeholder on its own carries none of + * it. Icons are dropped: there is nothing to draw them with, and an empty + * `` would only be noise beside the text it decorates. + */ +const toTwin = (page: Page, body: string) => { + const heading = `# ${page.title}`; + const lead = page.description === '' ? '' : `\n\n> ${page.description}`; + + return `${heading}${lead}\n\n${stripIcons(body)}\n`; +}; + +export const renderPage = (page: Page, demos: Demos): RenderedPage => { + // The About and License facts expand to markdown, so one expansion serves + // the HTML and the twin alike. + const body = expandMeta(page.body); + const parsed = parseMarkdown(expandDemos(body, demos, 'html')); + + let tree: Promise | undefined; + + return { + page, + headings: parsed.headings, + sections: parsed.sections, + markdown: toTwin(page, expandDemos(body, demos, 'markdown')), + tree: () => + (tree ??= parsed.render().then((html) => toHtmlTree(expandIcons(html)))), + }; +}; diff --git a/apps/docs/build/placeholders.ts b/apps/docs/build/placeholders.ts new file mode 100644 index 0000000..9169663 --- /dev/null +++ b/apps/docs/build/placeholders.ts @@ -0,0 +1,95 @@ +/** + * The placeholders: elements in a page's HTML that stand for something the + * markup cannot carry on its own — a widget React draws, or contents that + * something other than React keeps up to date. + * + * Three producers write them: build/markdown.ts (code tabs, copy buttons), + * build/demos.ts (demo panels, their previews and readouts), and content + * written by hand (the landing page's hero, pattern tabs and install button, + * the 404's links). Two readers act on them: build/html-tree.ts keeps an + * opaque placeholder's contents as HTML, and src/components/markdown.tsx swaps + * every other one for the component that draws it. This is the one list both + * read, so a placeholder is added once and neither can miss it; + * tests/placeholders.test.ts holds what the pages write to it. + * + * Read by Node and the browser alike, so it holds data only. Which component + * draws a placeholder is markdown.tsx's to say, in a table keyed by these + * names that does not compile with one missing. + */ + +/** + * What marks a placeholder: a `data-*` attribute or a class, and the tag as + * well where the mark alone could be on something else. Attributes are + * `data-*` only, which both trees spell the same: html-react-parser renames + * most others for React, `class` to `className` among them. + */ +type Marker = { tag?: string } & ( + { attribute: `data-${string}` } | { className: string } +); + +type Placeholder = Marker & { + /** + * Whether its contents are kept as HTML, for `dangerouslySetInnerHTML`, + * rather than as a tree React would reconcile: React draws them once, and + * something else rearranges them from then on. + */ + opaque: boolean; +}; + +/** In order of precedence: an element is the first placeholder it matches. */ +export const PLACEHOLDERS = { + /** Code blocks written back to back, or the landing page's pattern tabs. */ + codeTabs: { attribute: 'data-code-tabs', opaque: false }, + /** A demo panel, which its definition's behaviour is mounted on. */ + demo: { attribute: 'data-demo', opaque: false }, + /** The landing page's hero demo, written into the page. */ + hero: { className: 'hero-demo', opaque: false }, + /** The button that copies the code block beside it. */ + copyCode: { tag: 'button', className: 'code-copy', opaque: false }, + /** The landing page's button that copies the install command. */ + copyCommand: { tag: 'button', attribute: 'data-copy-command', opaque: false }, + /** The 404's list of places to go instead, put on the arrows. */ + notFoundLinks: { attribute: 'data-not-found-links', opaque: false }, + /** A demo's live preview, which keyrove and src/demos.ts rearrange. */ + demoPreview: { className: 'demo-panel-preview', opaque: true }, + /** A live demo's readout, which src/readout.ts rewrites on every key. */ + readout: { attribute: 'data-demo-readout', opaque: true }, +} as const satisfies Record; + +export type PlaceholderName = keyof typeof PLACEHOLDERS; + +export type OpaquePlaceholder = { + [Name in PlaceholderName]: (typeof PLACEHOLDERS)[Name]['opaque'] extends true + ? Name + : never; +}[PlaceholderName]; + +/** The placeholders React swaps for a component. */ +export type SwappedPlaceholder = Exclude; + +const isMarkedBy = ( + marker: Marker, + tag: string, + className: string, + has: (attribute: `data-${string}`) => boolean, +) => + (marker.tag === undefined || marker.tag === tag) && + ('attribute' in marker + ? has(marker.attribute) + : className.split(/\s+/).includes(marker.className)); + +/** + * The placeholder an element is, or undefined for a plain one — read off its + * tag, its `class` and its attributes, however the caller's tree holds them. + */ +export const placeholderOf = ( + tag: string, + className: string, + has: (attribute: `data-${string}`) => boolean, +): PlaceholderName | undefined => + (Object.keys(PLACEHOLDERS) as PlaceholderName[]).find((name) => + isMarkedBy(PLACEHOLDERS[name], tag, className, has), + ); + +export const isOpaque = (name: PlaceholderName): name is OpaquePlaceholder => + PLACEHOLDERS[name].opaque; diff --git a/packages/docs/build/plaintext.ts b/apps/docs/build/plaintext.ts similarity index 65% rename from packages/docs/build/plaintext.ts rename to apps/docs/build/plaintext.ts index 7350940..9c2de2d 100644 --- a/packages/docs/build/plaintext.ts +++ b/apps/docs/build/plaintext.ts @@ -1,40 +1,21 @@ +import { + GENERATED_FILES, + pagePath, + twinPath, + type Addresses, +} from './addresses.ts'; import type { NavGroup, Page } from './content.ts'; -import { expandDemos, type Demos } from './demos.ts'; -import { stripIcons } from './icons.ts'; -import { routeToPath, toMarkdownPath } from './paths.ts'; -import { expandMeta, META } from './meta.ts'; +import type { RenderedPage } from './page-render.ts'; /** - * The machine-readable half of the site: the markdown twins, llms.txt and - * llms-full.txt, and the two files crawlers look for. + * The machine-readable half of the site: llms.txt and llms-full.txt, and the + * two files crawlers look for. * * Every page is served twice: as HTML at `/docs/api`, and as markdown at - * `/docs/api.md`. The markdown is not the raw source file — frontmatter is - * site plumbing, so it is replaced by the title and description it carried, - * leaving a document that stands on its own when fetched in isolation. + * `/docs/api.md`. The markdown twin is one of a page's projections, made in + * build/page-render.ts; the files here index and collect the twins. */ -/** - * A page as standalone markdown. - * - * Demos are expanded on the way out, so the markup a page documents reaches a - * reader who never runs the site — the placeholder on its own carries none of - * it. - */ -export const toMarkdown = (page: Page, demos: Demos) => { - const heading = `# ${page.title}`; - const lead = page.description === '' ? '' : `\n\n> ${page.description}`; - const body = stripIcons( - expandMeta(expandDemos(page.body, demos, 'markdown')), - ); - - return `${heading}${lead}\n\n${body}\n`; -}; - -/** A page's absolute URL: `docs/api` → `https://keyrove.pages.dev/docs/api`. */ -const toPageUrl = (route: string, base: string) => - `${META.siteUrl}${base}${routeToPath(route).replace(/^\//, '')}`; - /** * llms.txt — an index pointing at each page's markdown. * @@ -44,16 +25,13 @@ const toPageUrl = (route: string, base: string) => export const toLlmsTxt = ( landing: Page | undefined, nav: NavGroup[], - base: string, + addresses: Addresses, ) => { - const url = (route: string) => - `${META.siteUrl}${base}${toMarkdownPath(route)}`; - const sections = nav.map((group) => { const entries = group.pages .map((page) => { const summary = page.description === '' ? '' : `: ${page.description}`; - return `- [${page.title}](${url(page.route)})${summary}`; + return `- [${page.title}](${addresses.twinUrl(page.route)})${summary}`; }) .join('\n'); @@ -68,8 +46,8 @@ export const toLlmsTxt = ( `> ${intro}`, '', 'Every page on this site is also available as markdown by appending `.md` to its URL —', - `for example ${base}docs/api renders the API reference, and ${base}docs/api.md returns its source.`, - `All of the pages below, in full and in this order, are one file at ${META.siteUrl}${base}llms-full.txt.`, + `for example ${addresses.href(pagePath('docs/api'))} renders the API reference, and ${addresses.href(twinPath('docs/api'))} returns its source.`, + `All of the pages below, in full and in this order, are one file at ${addresses.url(GENERATED_FILES.llmsFullTxt.path)}.`, ].join('\n'); return `${[preamble, ...sections].join('\n\n')}\n`; @@ -85,21 +63,20 @@ export const toLlmsTxt = ( */ export const toLlmsFullTxt = ( landing: Page | undefined, - readingOrder: Page[], - demos: Demos, - base: string, + readingOrder: RenderedPage[], + addresses: Addresses, ) => { const preamble = [ '# keyrove', '', `> ${landing?.description ?? ''}`, '', - `The full text of every page indexed by ${META.siteUrl}${base}llms.txt, in the same order.`, + `The full text of every page indexed by ${addresses.url(GENERATED_FILES.llmsTxt.path)}, in the same order.`, ].join('\n'); const pages = readingOrder.map( - (page) => - `Source: ${toPageUrl(page.route, base)}\n\n${toMarkdown(page, demos).trimEnd()}`, + ({ page, markdown }) => + `Source: ${addresses.pageUrl(page.route)}\n\n${markdown.trimEnd()}`, ); // Blank lines on both sides of each rule: straight under a line of text, @@ -107,9 +84,6 @@ export const toLlmsFullTxt = ( return `${[preamble, ...pages].join('\n\n---\n\n')}\n`; }; -/** The URL a page's markdown is served at, for the "View as Markdown" link. */ -export const markdownHref = (page: Page) => `${routeToPath(page.route)}.md`; - /** * sitemap.xml — every indexable page, as an absolute URL. * @@ -122,7 +96,7 @@ export const markdownHref = (page: Page) => `${routeToPath(page.route)}.md`; * dropped for a page git cannot date: the field is worth having only while * every date in it is true. */ -export const toSitemap = (pages: Page[], base: string) => { +export const toSitemap = (pages: Page[], addresses: Addresses) => { const urls = pages .filter((page) => !page.noindex) // The landing page carries no `order`, so it sorts last among the pages; @@ -134,7 +108,7 @@ export const toSitemap = (pages: Page[], base: string) => { ? '' : `${page.lastModified}`; - return ` ${toPageUrl(page.route, base)}${lastmod}`; + return ` ${addresses.pageUrl(page.route)}${lastmod}`; }) .join('\n'); @@ -172,7 +146,7 @@ const AI_CRAWLERS = [ const CONTENT_SIGNAL = 'search=yes, ai-input=yes, ai-train=yes'; -export const toRobotsTxt = (base: string) => +export const toRobotsTxt = (addresses: Addresses) => `User-agent: * Content-Signal: ${CONTENT_SIGNAL} Allow: / @@ -181,5 +155,5 @@ ${AI_CRAWLERS.map((agent) => `User-agent: ${agent}`).join('\n')} Content-Signal: ${CONTENT_SIGNAL} Allow: / -Sitemap: ${META.siteUrl}${base}sitemap.xml +Sitemap: ${addresses.url(GENERATED_FILES.sitemap.path)} `; diff --git a/apps/docs/build/search.ts b/apps/docs/build/search.ts new file mode 100644 index 0000000..2aac519 --- /dev/null +++ b/apps/docs/build/search.ts @@ -0,0 +1,28 @@ +import MiniSearch from 'minisearch'; + +import { searchOptions, type SearchDocument } from '../src/search-model.ts'; +import { pagePath } from './addresses.ts'; +import type { RenderedPage } from './page-render.ts'; + +export const toSearchIndex = (rendered: RenderedPage[]): string => { + const documents: SearchDocument[] = rendered + .filter(({ page }) => page.layout === 'docs' && !page.noindex) + .flatMap(({ page, sections }) => + sections.map((section) => { + const url = `${pagePath(page.route)}${section.id ? `#${section.id}` : ''}`; + return { + id: url, + url, + title: page.title, + heading: section.heading, + text: section.id + ? section.text + : `${page.description} ${section.text}`.trim(), + }; + }), + ); + + const index = new MiniSearch(searchOptions); + index.addAll(documents); + return JSON.stringify(index); +}; diff --git a/packages/docs/build/structured-data.ts b/apps/docs/build/structured-data.ts similarity index 100% rename from packages/docs/build/structured-data.ts rename to apps/docs/build/structured-data.ts diff --git a/packages/docs/components.json b/apps/docs/components.json similarity index 100% rename from packages/docs/components.json rename to apps/docs/components.json diff --git a/packages/docs/content/404.md b/apps/docs/content/404.md similarity index 100% rename from packages/docs/content/404.md rename to apps/docs/content/404.md diff --git a/packages/docs/content/_demos/editable.html b/apps/docs/content/_demos/editable.html similarity index 100% rename from packages/docs/content/_demos/editable.html rename to apps/docs/content/_demos/editable.html diff --git a/packages/docs/content/_demos/grid.html b/apps/docs/content/_demos/grid.html similarity index 100% rename from packages/docs/content/_demos/grid.html rename to apps/docs/content/_demos/grid.html diff --git a/packages/docs/content/_demos/keys.html b/apps/docs/content/_demos/keys.html similarity index 100% rename from packages/docs/content/_demos/keys.html rename to apps/docs/content/_demos/keys.html diff --git a/packages/docs/content/_demos/labels.html b/apps/docs/content/_demos/labels.html similarity index 100% rename from packages/docs/content/_demos/labels.html rename to apps/docs/content/_demos/labels.html diff --git a/packages/docs/content/_demos/landing-grid.html b/apps/docs/content/_demos/landing-grid.html similarity index 100% rename from packages/docs/content/_demos/landing-grid.html rename to apps/docs/content/_demos/landing-grid.html diff --git a/packages/docs/content/_demos/landing-horizontal.html b/apps/docs/content/_demos/landing-horizontal.html similarity index 100% rename from packages/docs/content/_demos/landing-horizontal.html rename to apps/docs/content/_demos/landing-horizontal.html diff --git a/packages/docs/content/_demos/landing-loop.html b/apps/docs/content/_demos/landing-loop.html similarity index 100% rename from packages/docs/content/_demos/landing-loop.html rename to apps/docs/content/_demos/landing-loop.html diff --git a/packages/docs/content/_demos/landing-roving.html b/apps/docs/content/_demos/landing-roving.html similarity index 100% rename from packages/docs/content/_demos/landing-roving.html rename to apps/docs/content/_demos/landing-roving.html diff --git a/packages/docs/content/_demos/landing-tools.html b/apps/docs/content/_demos/landing-tools.html similarity index 100% rename from packages/docs/content/_demos/landing-tools.html rename to apps/docs/content/_demos/landing-tools.html diff --git a/packages/docs/content/_demos/list.html b/apps/docs/content/_demos/list.html similarity index 100% rename from packages/docs/content/_demos/list.html rename to apps/docs/content/_demos/list.html diff --git a/packages/docs/content/_demos/listbox.html b/apps/docs/content/_demos/listbox.html similarity index 100% rename from packages/docs/content/_demos/listbox.html rename to apps/docs/content/_demos/listbox.html diff --git a/packages/docs/content/_demos/loop.html b/apps/docs/content/_demos/loop.html similarity index 100% rename from packages/docs/content/_demos/loop.html rename to apps/docs/content/_demos/loop.html diff --git a/packages/docs/content/_demos/menu.html b/apps/docs/content/_demos/menu.html similarity index 100% rename from packages/docs/content/_demos/menu.html rename to apps/docs/content/_demos/menu.html diff --git a/packages/docs/content/_demos/nested.html b/apps/docs/content/_demos/nested.html similarity index 100% rename from packages/docs/content/_demos/nested.html rename to apps/docs/content/_demos/nested.html diff --git a/packages/docs/content/_demos/orientation.html b/apps/docs/content/_demos/orientation.html similarity index 100% rename from packages/docs/content/_demos/orientation.html rename to apps/docs/content/_demos/orientation.html diff --git a/packages/docs/content/_demos/panes.html b/apps/docs/content/_demos/panes.html similarity index 100% rename from packages/docs/content/_demos/panes.html rename to apps/docs/content/_demos/panes.html diff --git a/packages/docs/content/_demos/responsive.html b/apps/docs/content/_demos/responsive.html similarity index 100% rename from packages/docs/content/_demos/responsive.html rename to apps/docs/content/_demos/responsive.html diff --git a/packages/docs/content/_demos/roving.html b/apps/docs/content/_demos/roving.html similarity index 100% rename from packages/docs/content/_demos/roving.html rename to apps/docs/content/_demos/roving.html diff --git a/packages/docs/content/_demos/rtl.html b/apps/docs/content/_demos/rtl.html similarity index 100% rename from packages/docs/content/_demos/rtl.html rename to apps/docs/content/_demos/rtl.html diff --git a/packages/docs/content/_demos/sidebar.html b/apps/docs/content/_demos/sidebar.html similarity index 100% rename from packages/docs/content/_demos/sidebar.html rename to apps/docs/content/_demos/sidebar.html diff --git a/packages/docs/content/_demos/skip.html b/apps/docs/content/_demos/skip.html similarity index 100% rename from packages/docs/content/_demos/skip.html rename to apps/docs/content/_demos/skip.html diff --git a/packages/docs/content/_demos/tools.html b/apps/docs/content/_demos/tools.html similarity index 100% rename from packages/docs/content/_demos/tools.html rename to apps/docs/content/_demos/tools.html diff --git a/packages/docs/content/_demos/tree.html b/apps/docs/content/_demos/tree.html similarity index 100% rename from packages/docs/content/_demos/tree.html rename to apps/docs/content/_demos/tree.html diff --git a/packages/docs/content/_demos/typeahead.html b/apps/docs/content/_demos/typeahead.html similarity index 100% rename from packages/docs/content/_demos/typeahead.html rename to apps/docs/content/_demos/typeahead.html diff --git a/packages/docs/content/docs/about.md b/apps/docs/content/docs/about.md similarity index 94% rename from packages/docs/content/docs/about.md rename to apps/docs/content/docs/about.md index 756442e..ba55c1f 100644 --- a/packages/docs/content/docs/about.md +++ b/apps/docs/content/docs/about.md @@ -18,4 +18,4 @@ for setup and behavior. The project is open source under the To read a page as Markdown, append `.md` to its URL or choose **View as Markdown** under the page title. **View source** opens its source file under -`packages/docs/content` in the repository. +`apps/docs/content` in the repository. diff --git a/packages/docs/content/docs/ai-prompts.md b/apps/docs/content/docs/ai-prompts.md similarity index 100% rename from packages/docs/content/docs/ai-prompts.md rename to apps/docs/content/docs/ai-prompts.md diff --git a/packages/docs/content/docs/api.md b/apps/docs/content/docs/api.md similarity index 99% rename from packages/docs/content/docs/api.md rename to apps/docs/content/docs/api.md index a262763..ced8e10 100644 --- a/packages/docs/content/docs/api.md +++ b/apps/docs/content/docs/api.md @@ -183,6 +183,15 @@ The root is the nearest ancestor of the event target carrying the listener is attached to (`currentTarget`). A listener on `document` or `window` falls back to ``. +The search doesn't stop at the listener's element. If no root sits between the +target and the listener, a root above the listener wins, and its items become +the group, including any outside the listener. Settings can therefore sit on a +wrapper root while the listener is on an inner element. Moves between groups +stay inside the listener's element: [exit](#exit-and-enter), +[focus keys](#focus-keys) and [`followFocus`](#followfocus-event-options) look +for the group around a nested root or an item no further out than the +listener's element. + A listener on a shadow root falls back to the shadow root itself. It has no attributes, so configure that group with [options](/docs/attributes-and-options), or wrap the items in an element carrying `data-keyrove-root`. Reading diff --git a/packages/docs/content/docs/attributes-and-options.md b/apps/docs/content/docs/attributes-and-options.md similarity index 100% rename from packages/docs/content/docs/attributes-and-options.md rename to apps/docs/content/docs/attributes-and-options.md diff --git a/packages/docs/content/docs/examples/basic.md b/apps/docs/content/docs/examples/basic.md similarity index 100% rename from packages/docs/content/docs/examples/basic.md rename to apps/docs/content/docs/examples/basic.md diff --git a/packages/docs/content/docs/examples/custom-keys.md b/apps/docs/content/docs/examples/custom-keys.md similarity index 100% rename from packages/docs/content/docs/examples/custom-keys.md rename to apps/docs/content/docs/examples/custom-keys.md diff --git a/packages/docs/content/docs/examples/editable-targets.md b/apps/docs/content/docs/examples/editable-targets.md similarity index 100% rename from packages/docs/content/docs/examples/editable-targets.md rename to apps/docs/content/docs/examples/editable-targets.md diff --git a/packages/docs/content/docs/examples/focus-keys.md b/apps/docs/content/docs/examples/focus-keys.md similarity index 100% rename from packages/docs/content/docs/examples/focus-keys.md rename to apps/docs/content/docs/examples/focus-keys.md diff --git a/packages/docs/content/docs/examples/grid.md b/apps/docs/content/docs/examples/grid.md similarity index 100% rename from packages/docs/content/docs/examples/grid.md rename to apps/docs/content/docs/examples/grid.md diff --git a/packages/docs/content/docs/examples/horizontal-lists.md b/apps/docs/content/docs/examples/horizontal-lists.md similarity index 100% rename from packages/docs/content/docs/examples/horizontal-lists.md rename to apps/docs/content/docs/examples/horizontal-lists.md diff --git a/packages/docs/content/docs/examples/index.md b/apps/docs/content/docs/examples/index.md similarity index 100% rename from packages/docs/content/docs/examples/index.md rename to apps/docs/content/docs/examples/index.md diff --git a/packages/docs/content/docs/examples/javascript-options.md b/apps/docs/content/docs/examples/javascript-options.md similarity index 100% rename from packages/docs/content/docs/examples/javascript-options.md rename to apps/docs/content/docs/examples/javascript-options.md diff --git a/packages/docs/content/docs/examples/listbox.md b/apps/docs/content/docs/examples/listbox.md similarity index 100% rename from packages/docs/content/docs/examples/listbox.md rename to apps/docs/content/docs/examples/listbox.md diff --git a/packages/docs/content/docs/examples/looping-lists.md b/apps/docs/content/docs/examples/looping-lists.md similarity index 100% rename from packages/docs/content/docs/examples/looping-lists.md rename to apps/docs/content/docs/examples/looping-lists.md diff --git a/packages/docs/content/docs/examples/nested-roots.md b/apps/docs/content/docs/examples/nested-roots.md similarity index 100% rename from packages/docs/content/docs/examples/nested-roots.md rename to apps/docs/content/docs/examples/nested-roots.md diff --git a/packages/docs/content/docs/examples/responsive-grid.md b/apps/docs/content/docs/examples/responsive-grid.md similarity index 100% rename from packages/docs/content/docs/examples/responsive-grid.md rename to apps/docs/content/docs/examples/responsive-grid.md diff --git a/packages/docs/content/docs/examples/roving-tabindex.md b/apps/docs/content/docs/examples/roving-tabindex.md similarity index 100% rename from packages/docs/content/docs/examples/roving-tabindex.md rename to apps/docs/content/docs/examples/roving-tabindex.md diff --git a/packages/docs/content/docs/examples/skipped-items.md b/apps/docs/content/docs/examples/skipped-items.md similarity index 100% rename from packages/docs/content/docs/examples/skipped-items.md rename to apps/docs/content/docs/examples/skipped-items.md diff --git a/packages/docs/content/docs/examples/tree-view.md b/apps/docs/content/docs/examples/tree-view.md similarity index 100% rename from packages/docs/content/docs/examples/tree-view.md rename to apps/docs/content/docs/examples/tree-view.md diff --git a/packages/docs/content/docs/examples/typeahead.md b/apps/docs/content/docs/examples/typeahead.md similarity index 100% rename from packages/docs/content/docs/examples/typeahead.md rename to apps/docs/content/docs/examples/typeahead.md diff --git a/packages/docs/content/docs/glossary.md b/apps/docs/content/docs/glossary.md similarity index 100% rename from packages/docs/content/docs/glossary.md rename to apps/docs/content/docs/glossary.md diff --git a/packages/docs/content/docs/installation.md b/apps/docs/content/docs/installation.md similarity index 100% rename from packages/docs/content/docs/installation.md rename to apps/docs/content/docs/installation.md diff --git a/packages/docs/content/docs/introduction.md b/apps/docs/content/docs/introduction.md similarity index 100% rename from packages/docs/content/docs/introduction.md rename to apps/docs/content/docs/introduction.md diff --git a/packages/docs/content/docs/license.md b/apps/docs/content/docs/license.md similarity index 100% rename from packages/docs/content/docs/license.md rename to apps/docs/content/docs/license.md diff --git a/packages/docs/content/index.md b/apps/docs/content/index.md similarity index 82% rename from packages/docs/content/index.md rename to apps/docs/content/index.md index c51dc67..51272b4 100644 --- a/packages/docs/content/index.md +++ b/apps/docs/content/index.md @@ -18,7 +18,7 @@ move focus the way keyboard users expect, in any framework.
[Get started](/docs/introduction) [Examples](/docs/examples) -npm i @mixedrays/keyrove +npm i @mixedrays/keyrove
@@ -33,7 +33,7 @@ move focus the way keyboard users expect, in any framework.
Live · mail folders - +