-
-
Notifications
You must be signed in to change notification settings - Fork 2
Docs site built on Flatbread, reading the repo's own Markdown #247
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e66d659
a67f33a
0092a95
591b2cc
eaf20ad
e932827
e359f8f
043b45e
1f68167
c3383dc
03c0676
013c43c
e461a5f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| --- | ||
| id: con-the-docs-site-may-not-depend-on-motion-or-any-pa--1b06vxdgbrcem943 | ||
| effort: eff-relational-content-foundation--8a8332x4cazgf2k0 | ||
| title: The docs site may not depend on Motion+ or any paid private registry | ||
| kind: hard | ||
| created_at: '2026-08-12T23:05:41.717Z' | ||
| --- | ||
|
|
||
| Motion's `splitText` — the documented way to break a string into characters for animation — ships in Motion+, a paid membership installed from a private registry at `api.motion.dev` with a secret access token. | ||
|
|
||
| A public repository cannot hold that token, and a contributor without a membership could not install dependencies or build the site. The same reasoning rules out any other component behind a private registry. | ||
|
|
||
| The docs site therefore splits text itself, in `apps/docs/app/components/motion/SplitText.tsx`. It is about thirty lines. Two details are easy to get wrong and are handled there: the whole string stays in `aria-label` so a screen reader hears a sentence rather than a stream of letters, and every piece is `inline-block`, because transforms do nothing to an inline element. | ||
|
|
||
| If someone with a Motion+ token later wants the official utility, the component is a drop-in swap — but it must stay optional. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| --- | ||
| id: fnd-building-the-docs-site-on-flatbread-found-six-li--tabacz23jq6jk545 | ||
| effort: eff-relational-content-foundation--8a8332x4cazgf2k0 | ||
| title: Building the docs site on Flatbread found six limits in the content model | ||
| kind: retrospective | ||
| created_at: '2026-08-12T23:05:00.464Z' | ||
| --- | ||
|
|
||
| The docs site at `apps/docs` renders the repository's own guides and package READMEs through Flatbread. Getting it working surfaced six limits. Each has a workaround in the site today, so none of them blocks; they are ranked by how much they cost a newcomer. | ||
|
|
||
| **1. A content path may not climb above the project directory.** `path: '../../packages/[id]/README.md'` matched nothing and produced a `Package` type with only `_collection` on it. No error said why. The site keeps symlinks in `content/reference/` instead. | ||
|
|
||
| **2. A capture that names a directory followed by a fixed filename is not matched on the initial load.** `packages/[id]/README.md` is the natural way to say "one README per package". `gatherFileNodes` splits the path on `/[`, treats `id]/README.md` as one branch, and computes a filename-stripping length that yields an empty capture, so every candidate is then dropped by the extension filter. `matchPath`, which watch mode uses, handles the same pattern. Initial load and watch therefore disagree about which paths are valid. | ||
|
|
||
| **3. There is no heading extraction, so a contents list needs a plugin.** The markdown transformer returns `raw`, `html`, `excerpt`, and `timeToRead` and nothing structural. The site adds ids with its own rehype plugin and reads them back out of the HTML string with a regex. | ||
|
|
||
| **4. There is no ranked search.** `filter` can match with `regex` and `wildcard` but cannot rank. The site flattens every page into a list at build time and scores it in the browser. | ||
|
|
||
| **5. `sortBy` reads top-level keys only.** Navigation cannot sort through the `section` ref, so every page carries its own `order` number. | ||
|
|
||
| **6. Every generated field is nullable.** A field only exists in the schema if some record carries it, so `AllDocsQuery` returns `id?: string | null` for a field that is required and validated. Each reader in `lib/content.ts` narrows once so pages can rely on plain values. | ||
|
|
||
| What worked without argument: refs (including a self-referencing `related` on `Doc`), path captures supplying `id` with no frontmatter, markdown and YAML transformers side by side, remark and rehype plugin hooks, `flatbread codegen`, and `flatbread start -- next build` holding the server open for exactly the length of a production build. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| --- | ||
| id: iss-a-content-path-cannot-reach-a-file-outside-the-p--gf3ef2xv3dm1g95h | ||
| effort: eff-relational-content-foundation--8a8332x4cazgf2k0 | ||
| title: A content path cannot reach a file outside the project directory | ||
| kind: gap | ||
| status: open | ||
| created_at: '2026-08-12T23:05:30.208Z' | ||
| derives_from: | ||
| - fnd-building-the-docs-site-on-flatbread-found-six-li--tabacz23jq6jk545 | ||
| --- | ||
|
|
||
| `path: '../../packages/[id]/README.md'` matches nothing. A project that wants to read files kept beside it — a monorepo package README, a sibling content directory, a shared folder — has to leave a symlink in its own tree instead. | ||
|
|
||
| The docs site does exactly that: `apps/docs/content/reference/core.md` is a symlink to `packages/core/README.md`. It works, and the symlink's own name conveniently supplies the record id. But it is a workaround a newcomer has to be told about, and it does not survive on a filesystem without symlinks. | ||
|
|
||
| Two questions worth settling before changing anything. Is the restriction deliberate — a sandbox around what a config may read — or is it an accident of how paths are resolved against `process.cwd()`? If it is deliberate, say so in an error message, because today the collection simply comes back empty. If it is not, allowing a path to resolve above the project root would remove the need for symlinks in every monorepo. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| --- | ||
| id: iss-initial-load-and-watch-mode-disagree-about-which--54gyvwapr3ewcdeb | ||
| effort: eff-relational-content-foundation--8a8332x4cazgf2k0 | ||
| title: Initial load and watch mode disagree about which capture patterns are valid | ||
| kind: defect | ||
| status: open | ||
| created_at: '2026-08-12T23:05:17.703Z' | ||
| derives_from: | ||
| - fnd-building-the-docs-site-on-flatbread-found-six-li--tabacz23jq6jk545 | ||
| --- | ||
|
|
||
| `gatherFileNodes` (initial load) and `matchPath` (watch mode) are separate implementations, and they accept different patterns. `content/packages/[id]/README.md` — a captured directory followed by a fixed filename — works in `matchPath` and matches nothing in `gatherFileNodes`. | ||
|
|
||
| The cause is in `packages/source-filesystem/src/utils/gatherFileNodes.ts`. The path is split on `/\[`, so `[id]/README.md` arrives as the single branch `id]/README.md`. The code reads the capture name up to `]` and treats everything after it as a suffix to strip from the matched name, giving `remove: 10`. Applied to a directory called `core`, `name.slice(0, 4 - 10)` is the empty string, and the extension filter then drops the directory because it has no `.md` on the end. | ||
|
|
||
| Two effects. A user gets an empty collection with no error — the GraphQL type is generated with only `_collection` on it, which reads as though the collection has no fields rather than no records. And a config that works under `flatbread start --watch` can fail under `flatbread start -- next build`, which is the worst possible place to find out. | ||
|
|
||
| Worth fixing together: an empty collection should say so. `apps/docs` hit both halves of this and worked around them with symlinks named after the id. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| name: pnpm install | ||
| description: Install workspace dependencies with a frozen lockfile. Retry on transient native-binary download failures. | ||
|
|
||
| runs: | ||
| using: composite | ||
| steps: | ||
| - name: Install dependencies | ||
| shell: bash | ||
| run: | | ||
| max=3 | ||
| attempt=1 | ||
| while [ "$attempt" -le "$max" ]; do | ||
| if pnpm install --frozen-lockfile; then | ||
| exit 0 | ||
| fi | ||
| if [ "$attempt" -eq "$max" ]; then | ||
| echo "pnpm install failed after ${max} attempts" >&2 | ||
| exit 1 | ||
| fi | ||
| echo "pnpm install failed (attempt ${attempt}/${max}); retrying in 20s" >&2 | ||
| attempt=$((attempt + 1)) | ||
| sleep 20 | ||
| done |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,7 +3,8 @@ | |
| Thanks for your interest in contributing! This guide covers local development and the release process (bumping versions and publishing packages). | ||
|
|
||
| **Flatbread** turns related content files in Git into typed data for TypeScript | ||
| apps. **GraphQL is one way to read that data** (see `docs/glossary.md`); it is | ||
| apps. **GraphQL is one way to read that data** (see | ||
| `apps/docs/content/docs/glossary.md`); it is | ||
| not the whole product. | ||
|
|
||
| For a first project with posts, authors, and tags, see the | ||
|
|
@@ -34,11 +35,12 @@ Optional **`pnpm play`** from the repo root is a shortcut for **`cd examples/nex | |
| - Build all packages: `pnpm build` | ||
| - **Workspace libraries (watch-only):** `pnpm dev` — runs package `dev` scripts (e.g. `tsup --watch`) for `packages/*`; it does **not** start the Next.js example. | ||
| - **Next.js example:** prefer the flow under [Recommended onboarding](#recommended-onboarding-try-flatbread-in-the-nextjs-example); or `pnpm play` as a convenience alias. | ||
| - **Documentation site:** from the repo root, `pnpm docs:dev` builds the packages (`predocs:dev` runs `pnpm build` first, so a fresh clone works), then starts Flatbread on **5057** and Next on **3000**. `pnpm docs:build` builds the packages and then the static site. `pnpm docs:check` checks frontmatter and links without building. The content model is in `apps/docs/README.md`. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MED — Docs bullet correctly covers Minimal fix: One sentence here, or a cross-link to the README warning. Also land the |
||
| - **Proof explorer:** | ||
| 1. Run `pnpm play:efforts` (builds `@flatbread/explorer` via `preplay:efforts`, then `flatbread start --watch --open`). | ||
| 2. When `flatbread.config.js` uses `proofContent()`, Flatbread serves `@flatbread/explorer` at `http://localhost:5057/`. The Apollo sandbox is at `/graphql`. | ||
| 3. For hot module replacement (HMR) on the single-page app (SPA) shell, run `pnpm exec flatbread start --watch` and `pnpm --filter @flatbread/explorer dev` in parallel. Vite on **5173** proxies API routes to **5057**. | ||
| - Check local CI parity before opening a PR: `pnpm verify` | ||
| - Check local CI parity before opening a PR: `pnpm verify`. That run ends with `pnpm docs:check`, so it checks the docs pages but does not run the full docs build. The GitHub Actions job `docs-site` in `.github/workflows/pipeline.yml` runs that full build. | ||
|
|
||
| ## Working on a package | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| # Flatbread docs site | ||
|
|
||
| The documentation site for Flatbread, built on Flatbread. | ||
|
|
||
| Every page here is a Markdown file that already lives in this repository. The | ||
| site does not copy or generate that Markdown; it reads the same files through | ||
| Flatbread's GraphQL server while the site is being built, then ships plain | ||
| files. Change a guide or a package README, and the page changes with it. | ||
|
|
||
| ## Run it | ||
|
|
||
| From the repository root: | ||
|
|
||
| ```bash | ||
| pnpm docs:dev # build the packages, then Flatbread on :5057 and Next on :3000 | ||
| ``` | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| `pnpm play` (the Next.js example) and `pnpm docs:dev` both use Flatbread on **5057** | ||
| and Next on **3000**. Running the two at once fails; stop one before you start | ||
| the other. | ||
|
|
||
| Or from this directory, once the packages are built: | ||
|
|
||
| ```bash | ||
| pnpm dev # flatbread start --watch -- next dev --turbopack | ||
| pnpm build # flatbread start -- next build, writing ./out | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| pnpm serve # serve the built files | ||
| pnpm check:links # check frontmatter and links without building | ||
| pnpm test # vitest run: link rewriter, contents list, search ranking, page checker | ||
| ``` | ||
|
|
||
| `flatbread start` runs the GraphQL server for as long as the command after | ||
| `--` runs, so the production build has data and the finished site needs no | ||
| server at all. | ||
|
|
||
| ## Where the content lives | ||
|
|
||
| | Collection | Files | What it holds | | ||
| | ---------- | ------------------------ | --------------------------------------------------------------------------- | | ||
| | `Doc` | `content/docs/*.md` | The guides. Real files, moved here from the old top-level `docs/`. | | ||
| | `Section` | `content/nav/*.yaml` | Navigation groups, written as YAML so the site exercises both transformers. | | ||
| | `Package` | `content/reference/*.md` | Symlinks to `packages/*/README.md`. | | ||
|
|
||
| Two rules shape that layout, and both are worth knowing before you add a | ||
| collection: | ||
|
|
||
| 1. A content path may not climb above the project directory. `../../packages` | ||
| matches nothing, which is why `content/reference` holds symlinks. | ||
| 2. A capture may name a directory or a filename, but a capture followed by a | ||
| fixed filename — `packages/[id]/README.md` — is not matched when the | ||
| content is first loaded. Capturing the filename, `content/reference/[id].md`, | ||
| works. | ||
|
|
||
| Flatbread requires an `id` on every record and never invents one. The guides | ||
| declare `id` in frontmatter, and the filename capture supplies the same value. | ||
| The package pages have no frontmatter at all — a README cannot carry any | ||
| without showing it on npm — so the symlink's own name is the id. | ||
|
|
||
| A clone on Windows without symlink support gets plain text files that hold a | ||
| path, and the package pages then render that path instead of the README. Enable | ||
| Git symlinks (`git config core.symlinks true`) and clone again. On Windows that | ||
| needs Developer Mode or an elevated shell. | ||
|
|
||
| ## Adding a page | ||
|
|
||
| 1. Write `content/docs/<id>.md`. | ||
| 2. Give it frontmatter: `id` (matching the filename), `title`, `section` (an | ||
| id from `content/nav`), `order`, `summary`, and optionally `related`. | ||
| 3. Run `pnpm check:links`. | ||
|
|
||
| `pnpm build` runs that check first, so a page that names a missing section or | ||
| links to a file that moved fails the build rather than shipping. | ||
|
|
||
| ## Markdown pipeline | ||
|
|
||
| Flatbread's Markdown transformer takes remark and rehype plugins, and the site | ||
| supplies five of its own in `plugins/`: | ||
|
|
||
| | Plugin | What it does | | ||
| | ---------------------------- | --------------------------------------------------------------------------------------------------------- | | ||
| | `remark-strip-first-heading` | Removes the leading `# Heading` so the page title is not printed twice. The file keeps its H1 for GitHub. | | ||
| | `remark-code-meta` | Carries a fence's info string onto the `<code>` element as a label. | | ||
| | `remark-repo-links` | Rewrites `./glossary.md` and `../../packages/core/README.md` into site routes. | | ||
| | `rehype-heading-anchors` | Adds an `id` and a self link to every heading below H1. | | ||
| | `rehype-shiki` | Colours code with Shiki, writing both themes as CSS variables. | | ||
|
|
||
| They are written against plain syntax trees and pull in no unified packages of | ||
| their own. Flatbread's transformer depends on unified 10, while most published | ||
| plugins now target unified 11, so a plugin from npm may or may not fit. | ||
|
|
||
| ## What the site does not use | ||
|
|
||
| - **No MDX.** MDX would parse the Markdown outside Flatbread, which is the | ||
| opposite of what this site is meant to demonstrate. Interactive behaviour is | ||
| added to the rendered HTML afterwards instead — see `CodeCopy`. | ||
| - **No Motion+.** Motion's `splitText` needs a paid membership and a private | ||
| registry token, which CI on a public repository cannot have. The site splits | ||
| text itself in `app/components/motion/SplitText.tsx`. | ||
| - **No search service.** Flatbread can filter but not rank. The build flattens | ||
| every page into a list and the browser scores it. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| /** | ||
| * A blinking block, the way a terminal marks where it is. | ||
| * | ||
| * The blink is a CSS animation rather than a script, so it costs nothing and | ||
| * stops on its own for anyone who asks for less motion. | ||
| */ | ||
| export function Cursor({ className }: { className?: string }) { | ||
| return ( | ||
| <span | ||
| aria-hidden | ||
| className={['fb-cursor', className].filter(Boolean).join(' ')} | ||
| > | ||
| ▮ | ||
| </span> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import type { ReactNode } from 'react'; | ||
|
|
||
| interface FrameProps { | ||
| /** Sits in the top rule, the way a filename sits in a box-drawn panel. */ | ||
| label?: ReactNode; | ||
| /** Sits at the right of the top rule. Use it for counts and short notes. */ | ||
| note?: ReactNode; | ||
| className?: string; | ||
| children: ReactNode; | ||
| } | ||
|
|
||
| /** | ||
| * A panel drawn as a box. | ||
| * | ||
| * The four sides are ordinary one-pixel borders so they stay crisp at any zoom | ||
| * and any width. Only the corners are real box-drawing characters, sitting on | ||
| * top of the border. A panel built entirely from characters comes apart the | ||
| * moment the container is resized; this does not. | ||
| */ | ||
| export function Frame({ label, note, className, children }: FrameProps) { | ||
| return ( | ||
| <section className={['fb-frame', className].filter(Boolean).join(' ')}> | ||
| <span aria-hidden className="fb-frame__corner fb-frame__corner--tl"> | ||
| ┌ | ||
| </span> | ||
| <span aria-hidden className="fb-frame__corner fb-frame__corner--tr"> | ||
| ┐ | ||
| </span> | ||
| <span aria-hidden className="fb-frame__corner fb-frame__corner--bl"> | ||
| └ | ||
| </span> | ||
| <span aria-hidden className="fb-frame__corner fb-frame__corner--br"> | ||
| ┘ | ||
| </span> | ||
|
|
||
| {label ? <p className="fb-frame__label">{label}</p> : null} | ||
| {note ? <p className="fb-frame__note">{note}</p> : null} | ||
|
|
||
| <div className="fb-frame__body">{children}</div> | ||
| </section> | ||
| ); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.