refactor(docs): migrate documentation to Blume and restructure content

This commit is contained in:
Prad Nukala
2026-08-14 13:15:38 -04:00
parent a3c1049bee
commit 5d69dee4d8
30 changed files with 240 additions and 407 deletions
-13
View File
@@ -1,13 +0,0 @@
# docs
This is a Waku application generated with [Create Fumadocs](https://github.com/fuma-nama/fumadocs).
Run development server:
```bash
npm run dev
# or
pnpm dev
# or
yarn dev
```
-37
View File
@@ -1,37 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"includes": [
"**",
"!node_modules",
"!.source"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
},
"domains": {
"react": "recommended"
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
View File
+162
View File
@@ -0,0 +1,162 @@
---
title: FAQ
description: Common questions about Blume — how it compares to other documentation tools, and why a Markdown formatter might collapse your callout directives.
sidebar:
label: FAQ
---
Answers to questions that come up often. Missing one? [Open an issue](https://github.com/haydenbleasel/blume/issues) or ask the in-page assistant.
## How is Blume different from Mintlify, Fumadocs, and others?
Most documentation tools sit at one of two extremes. **Managed platforms** like Mintlify give you a polished result fast, but the build and hosting are their service — you author inside their system and deploy to their infrastructure. **Component libraries and starters** like Fumadocs, Nextra, or Docusaurus are open-source and flexible, but they hand you an application (a Next.js or React project) that you scaffold, wire up, and maintain before and after you write a word.
Blume takes a third path: **the framework is the template.** You point it at a folder of Markdown and it generates and drives the whole site — navigation, search, theming, Open Graph images, SEO, and AI endpoints — with no app to own. It's fully open-source and self-hostable, so there's no managed service and no vendor lock-in, but there's also no boilerplate to maintain.
| | Blume | Mintlify | Fumadocs / Nextra / Docusaurus |
| --- | --- | --- | --- |
| **Model** | Zero-config framework; content only | Hosted platform | Library + app you scaffold |
| **Source** | Open-source (MIT) | Closed core | Open-source |
| **Hosting** | Anywhere — static or a server function | Their managed infrastructure | Anywhere; you build and deploy |
| **You maintain** | Your Markdown | Your Markdown + platform config | Your Markdown + the app around it |
| **Rendering** | Astro; core theme ships zero client JS | Their runtime | React/Next.js runtime |
| **AI features** | `llms.txt`, raw Markdown, Ask AI, MCP — built in, no hosted service | Built in (hosted) | Bring your own |
A few consequences worth calling out:
- **You own the output.** `blume build` produces a plain site you host on Vercel, Netlify, Cloudflare, S3, or your own box. Nothing phones home.
- **No lock-in, two ways out.** Your content is portable Markdown, and `blume eject` turns the project into a standalone Astro app that still uses the `blume` package when you want full control.
- **Fast by default.** The core theme is React-free and renders static HTML, so pages score well on Core Web Vitals without tuning. You opt into server features (Ask AI, MCP) only when you need them.
- **Type-safe configuration.** `blume.config.ts` and every `meta.ts` are real TypeScript validated by a schema — not loosely-typed YAML.
:::note
This isn't "better than everything" — managed platforms and full frameworks are the right call when you want a hosted product or maximum control over the app. Blume is for teams who want a production-grade docs site without owning either the platform or the plumbing.
:::
See [Why Blume exists](/docs) for the longer version.
## Is Blume free and open-source?
Yes — Blume is MIT-licensed and free. You install the `blume` package, keep your content in your own repository, and host the build wherever you like. There's no paid tier, no per-seat pricing, and no account to sign up for. The source lives on [GitHub](https://github.com/haydenbleasel/blume).
## Do I need to know Astro, React, or Tailwind?
No. A folder of Markdown is a complete site — navigation, search, and theming are inferred or set with a handful of tokens. You only reach for the underlying stack when you want to customize: [interactive islands](/docs/content/islands) (React), [component overrides](/docs/configuration/customization), or [theme tokens](/docs/configuration/theming) (Tailwind). Even then, [`blume.config.ts`](/docs/configuration) is typed, so your editor guides you.
## Can I use React components and MDX?
Yes. Any page can be `.md` or `.mdx`, and MDX lets you drop in the [built-in components](/docs/content/components) with no imports. You can also add your own `.tsx`/`.jsx` [islands](/docs/content/islands) — Blume auto-enables React only for the pages that use them, so the core theme stays JavaScript-free everywhere else.
## Where can I deploy it?
Anywhere. `blume build` outputs static HTML by default, which you can serve from any static host or CDN — Vercel, Netlify, Cloudflare Pages, GitHub Pages, S3, or your own server. Server-only features (Ask AI, the MCP server, on-demand rendering) switch the build to a server function through an adapter for Vercel, Node, Netlify, or Cloudflare. See [Deployment](/docs/deployment).
## Does search need a hosted service?
No. [Orama](/docs/configuration/search) builds a local index that works in both dev and production with nothing to host or pay for. For very large sites, [Pagefind](/docs/configuration/search) is one flag away. Either way the index ships as part of your site.
## How do I customize the look?
Start with [theme tokens](/docs/configuration/theming) — accent color, fonts, radius, and a `theme.css` for anything else Tailwind can express. Go further by [overriding built-in components](/docs/configuration/customization) or adding [custom pages](/docs/configuration/customization#custom-pages). When you want the Astro project itself, [`blume eject`](/docs/reference/cli) hands you a standalone app that still uses the `blume` package.
## Why is oxfmt / Ultracite collapsing my directives?
If you format your Markdown with [Ultracite](https://www.ultracite.ai) (which runs oxlint + [oxfmt](https://oxc.rs)) — as Blume itself does — you may notice that container directives get flattened onto a single line after a format pass:
```md
:::note
Regenerate the project with blume dev.
:::
```
becomes
```md
:::note Regenerate the project with blume dev. :::
```
Once the opening `:::note` fence is joined to the prose, it's no longer a directive, so it renders as literal text instead of a [callout](/docs/content/syntax#callouts).
### Why it happens
This is a bug in oxfmt's Markdown formatter (inherited from Prettier's Markdown printer — see [prettier/prettier#19040](https://github.com/prettier/prettier/pull/19040)). When it wraps prose, it treats the `:::` fence lines as ordinary text and joins them with the adjacent line, breaking the directive. It affects every container directive type — `:::note`, `:::tip`, `:::info`, `:::warning`, `:::danger`, `:::success`.
We reported it upstream in [oxc-project/oxc#24096](https://github.com/oxc-project/oxc/issues/24096); until it's fixed there, the patch below is the workaround.
### The fix
Patch oxfmt so it preserves the line break that sits directly against a `:::` fence. Blume ships the same fix in its own repo, and you can apply it in any project.
1. Save the patch as `patches/oxfmt@0.61.0.patch`:
```diff patches/oxfmt@0.61.0.patch
diff --git a/dist/markdown-ZuiQU4Xe.js b/dist/markdown-ZuiQU4Xe.js
index 566b9e6d27f36061d64b93736e238e871e1ee2b2..82d0595acc010807c2939fc4a1717dde887a8555 100644
--- a/dist/markdown-ZuiQU4Xe.js
+++ b/dist/markdown-ZuiQU4Xe.js
@@ -4875,7 +4875,43 @@ function lu(e, t, r) {
case "sentence": return Oh(e, r);
case "word": return t.parser !== "mdx" ? zh(e, t) : Uh(e);
case "whitespace": {
- let { next: a } = e, u = a && /^>|^(?:[*+-]|#{1,6}|\d+[).])$/.test(a.value) && !NE(e) && !(t.proseWrap === "preserve" && RE(e)) ? "never" : t.proseWrap;
+ let { next: a, previous: oxfmtFencePrev } = e;
+ // Preserve line breaks that sit directly against a `:::` container
+ // directive fence, so `proseWrap: "never"` keeps the opening/closing
+ // fence on their own lines instead of joining them into the prose (which
+ // breaks the directive). Ordinary prose still wraps per proseWrap.
+ // See prettier/prettier#19040.
+ let oxfmtIsFence = (w) => w != null && typeof w.value === "string" && w.value.startsWith(":::");
+ // A titled directive (`:::warning[Heads up]`) parses its `[title]` as a
+ // linkReference between two sentence nodes at the paragraph level: the
+ // fence word ends the sentence before the reference, and the body's
+ // leading newline opens the sentence after it. So when this whitespace
+ // starts its sentence, climb to the paragraph and check whether the two
+ // preceding siblings are a (link) reference and a sentence ending in a
+ // `:::` fence word.
+ let oxfmtPrevIsTitledFence = !1;
+ if (oxfmtFencePrev == null && e.index === 0 && e.grandparent != null && Array.isArray(e.grandparent.children)) {
+ let oxfmtSibs = e.grandparent.children, oxfmtSentIdx = oxfmtSibs.indexOf(e.parent);
+ if (oxfmtSentIdx >= 2) {
+ let oxfmtLink = oxfmtSibs[oxfmtSentIdx - 1], oxfmtBefore = oxfmtSibs[oxfmtSentIdx - 2];
+ let oxfmtLastWord = oxfmtBefore && oxfmtBefore.type === "sentence" && Array.isArray(oxfmtBefore.children) ? oxfmtBefore.children[oxfmtBefore.children.length - 1] : null;
+ oxfmtPrevIsTitledFence = oxfmtLink != null && (oxfmtLink.type === "linkReference" || oxfmtLink.type === "link") && oxfmtIsFence(oxfmtLastWord);
+ }
+ }
+ // The plain-markdown parser keeps a titled fence's `[title]` as literal
+ // words, so the whole directive is one sentence. For a newline
+ // whitespace, walk back to the start of its visual line within the
+ // sentence; a line led by a `:::` word is a fence whose break must stay.
+ if (!oxfmtPrevIsTitledFence && e.node.value.includes("\n") && e.parent != null && Array.isArray(e.parent.children)) {
+ let oxfmtLineFirst = null;
+ for (let oxfmtJ = e.index - 1; oxfmtJ >= 0; oxfmtJ--) {
+ let oxfmtSib = e.parent.children[oxfmtJ];
+ if (oxfmtSib.type === "whitespace" && typeof oxfmtSib.value === "string" && oxfmtSib.value.includes("\n")) break;
+ oxfmtLineFirst = oxfmtSib;
+ }
+ oxfmtPrevIsTitledFence = oxfmtIsFence(oxfmtLineFirst);
+ }
+ let u = oxfmtIsFence(oxfmtFencePrev) || oxfmtPrevIsTitledFence || oxfmtIsFence(a) ? "preserve" : a && /^>|^(?:[*+-]|#{1,6}|\d+[).])$/.test(a.value) && !NE(e) && !(t.proseWrap === "preserve" && RE(e)) ? "never" : t.proseWrap;
return ou(e, n.value, u, !1, t);
}
case "emphasis": {
```
2. Register it with your package manager's `patchedDependencies`. With Bun or pnpm, add to `package.json`:
```json package.json
{
"patchedDependencies": {
"oxfmt@0.61.0": "patches/oxfmt@0.61.0.patch"
}
}
```
3. Reinstall so the patch is applied:
```package-install
bun install
```
:::warning[Version-pinned]
The patch targets a specific oxfmt build — its diff references a file whose name is hashed per release (`dist/markdown-*.js`). When you bump oxfmt, regenerate the patch (e.g. `bun patch oxfmt`) or check whether the upstream fix has landed and the patch is no longer needed.
:::
+13
View File
@@ -0,0 +1,13 @@
---
title: 'Coding'
sidebar:
label: Overview
icon: arrow-right-circle
order: 1
---
## Description
Given two integer arrays `nums1` and `nums2`, return the minimum number of operations required to make both arrays equal.
You have the following three choices to modify two arrays:
@@ -0,0 +1,14 @@
---
title: 'Two Pointers'
sidebar:
label: Overview
icon: arrow-right-circle
badge: Easy
order: 1
---
## Description
Given two integer arrays `nums1` and `nums2`, return the minimum number of operations required to make both arrays equal.
You have the following three choices to modify two arrays:
-31
View File
@@ -1,31 +0,0 @@
---
title: Hello World
description: Your favourite docs framework.
icon: Rocket
---
Hey there! Fumadocs is a docs framework that works on Waku!
## Heading
Hello World!
<Cards>
<Card title="Learn more about Waku" href="https://waku.gg" />
<Card title="Learn more about Fumadocs" href="https://fumadocs.dev" />
</Cards>
### CodeBlock
```ts
console.log('Hello World');
```
#### Table
| Head | Description |
| ------------------------------- | ----------------------------------- |
| `hello` | Hello World |
| very **important** | Hey |
| _Surprisingly_ | Fumadocs |
| very long text that looks weird | hello world hello world hello world |
-12
View File
@@ -1,12 +0,0 @@
---
title: Test
description: This is another page
---
Hello World again!
## Installation
```npm
npm i fumadocs-core fumadocs-ui
```
+10
View File
@@ -0,0 +1,10 @@
---
title: Introduction
description: Welcome to your new Blume docs.
---
# Introduction
Welcome to **Blume** — markdown-first docs powered by Astro and Vite.
Edit `content/index.mdx` to get started, then run `blume dev`.
+13
View File
@@ -0,0 +1,13 @@
---
title: 'Coding'
sidebar:
label: Overview
icon: arrow-right-circle
order: 1
---
## Description
Given two integer arrays `nums1` and `nums2`, return the minimum number of operations required to make both arrays equal.
You have the following three choices to modify two arrays:
-15
View File
@@ -1,15 +0,0 @@
import defaultMdxComponents from 'fumadocs-ui/mdx';
import type { MDXComponents } from 'mdx/types';
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultMdxComponents,
...components,
} satisfies MDXComponents;
}
export const useMDXComponents = getMDXComponents;
declare global {
type MDXProvidedComponents = ReturnType<typeof getMDXComponents>;
}
-7
View File
@@ -1,7 +0,0 @@
'use client';
import type { ReactNode } from 'react';
import { RootProvider } from 'fumadocs-ui/provider/waku';
export function Provider({ children }: { children: ReactNode }) {
return <RootProvider>{children}</RootProvider>;
}
-1
View File
@@ -1 +0,0 @@
export { cn } from 'cnfast';
-12
View File
@@ -1,12 +0,0 @@
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
import { appName, gitConfig } from './shared';
export function baseOptions(): BaseLayoutProps {
return {
nav: {
// JSX supported
title: appName,
},
githubUrl: `https://github.com/${gitConfig.user}/${gitConfig.repo}`,
};
}
-11
View File
@@ -1,11 +0,0 @@
export const appName = 'Waku';
export const docsRoute = '/docs';
export const docsImageRoute = '/og/docs';
export const docsContentRoute = '/llms.mdx/docs';
// fill this with your actual GitHub info, for example:
export const gitConfig = {
user: 'fuma-nama',
repo: 'fumadocs',
branch: 'main',
};
-47
View File
@@ -1,47 +0,0 @@
import { loader } from 'fumadocs-core/source';
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
import { docsContentRoute, docsImageRoute, docsRoute } from './shared';
import { defineDocs } from 'fumadocs-mdx/macro';
import { metaSchema, pageSchema } from 'fumadocs-core/source/schema';
const docs = defineDocs({
dir: 'content/docs',
docs: {
schema: pageSchema,
postprocess: {
includeProcessedMarkdown: true,
},
},
meta: {
schema: metaSchema,
},
});
export const source = loader({
source: docs.toFumadocsSource(),
baseUrl: docsRoute,
plugins: [lucideIconsPlugin()],
});
export function getPageImageUrl(page: (typeof source)['$inferPage']) {
const segments = [...page.slugs, 'image.webp'];
return '/' + [page.locale, ...docsImageRoute.split('/'), ...segments].filter(Boolean).join('/');
}
export function getPageMarkdownUrl(page: (typeof source)['$inferPage']) {
const segments = [...page.slugs, 'content.md'];
return {
segments,
url: '/' + [page.locale, ...docsContentRoute.split('/'), ...segments].filter(Boolean).join('/'),
};
}
export async function getLLMText(page: (typeof source)['$inferPage']) {
const processed = await page.data.getText('processed');
return `# ${page.data.title} (${page.url})
${processed}`;
}
-1
View File
@@ -1 +0,0 @@
export { DefaultNotFound as default } from 'fumadocs-ui/layouts/home/not-found';
-7
View File
@@ -1,7 +0,0 @@
import type { ReactNode } from 'react';
import { baseOptions } from '@/lib/layout.shared';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
export default function Layout({ children }: { children: ReactNode }) {
return <HomeLayout {...baseOptions()}>{children}</HomeLayout>;
}
-21
View File
@@ -1,21 +0,0 @@
import { Link } from 'waku';
export default function Home() {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center">
<h1 className="font-medium text-xl mb-4">Fumadocs on Waku.</h1>
<Link
to="/docs"
className="px-3 py-2 rounded-lg bg-fd-primary text-fd-primary-foreground font-medium text-sm mx-auto"
>
Open Docs
</Link>
</div>
);
}
export async function getConfig() {
return {
render: 'static',
};
}
-4
View File
@@ -1,4 +0,0 @@
import { createFromSource } from 'fumadocs-core/search/server';
import { source } from '@/lib/source';
export const { GET } = createFromSource(source);
-13
View File
@@ -1,13 +0,0 @@
import { getLLMText, source } from '@/lib/source';
export async function GET() {
const scan = source.getPages().map(getLLMText);
const scanned = await Promise.all(scan);
return new Response(scanned.join('\n\n'));
}
export async function getConfig() {
return {
render: 'static' as const,
} as const;
}
@@ -1,29 +0,0 @@
import { getLLMText, source } from '@/lib/source';
import { ApiContext } from 'waku/router';
import { unstable_notFound } from 'waku/router/server';
export async function GET(
_: Request,
{ params }: ApiContext<'/llms.mdx/docs/[...slugs]/content.md'>,
) {
const slugs = params.slugs;
const page = source.getPage(slugs);
if (!page) unstable_notFound();
return new Response(await getLLMText(page), {
headers: {
'Content-Type': 'text/markdown',
},
});
}
export async function getConfig() {
const pages = source
.generateParams()
.map((item) => (item.lang ? [item.lang, ...item.slug] : item.slug));
return {
render: 'static' as const,
staticPaths: pages,
} as const;
}
-12
View File
@@ -1,12 +0,0 @@
import { source } from '@/lib/source';
import { llms } from 'fumadocs-core/source';
export function GET() {
return new Response(llms(source).index());
}
export async function getConfig() {
return {
render: 'static' as const,
} as const;
}
@@ -1,31 +0,0 @@
import { appName } from '@/lib/shared';
import { source } from '@/lib/source';
import { ImageResponse } from 'takumi-js/response';
import { generate as DefaultImage } from 'fumadocs-ui/og/takumi';
import { ApiContext } from 'waku/router';
export async function GET(_: Request, { params }: ApiContext<'/og/docs/[...slugs]/image.webp'>) {
const page = source.getPage(params.slugs);
if (!page) return new Response(undefined, { status: 404 });
return new ImageResponse(
<DefaultImage title={page.data.title} description={page.data.description} site={appName} />,
{
width: 1200,
height: 630,
format: 'webp',
},
);
}
export async function getConfig() {
const pages = source
.generateParams()
.map((item) => (item.lang ? [item.lang, ...item.slug] : item.slug));
return {
render: 'static' as const,
staticPaths: pages,
} as const;
}
-20
View File
@@ -1,20 +0,0 @@
import type { ReactNode } from 'react';
import { Provider } from '@/components/provider';
import '@/styles/globals.css';
export default async function RootElement({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head></head>
<body data-version="1.0" className="flex flex-col min-h-screen">
<Provider>{children}</Provider>
</body>
</html>
);
}
export async function getConfig() {
return {
render: 'static',
} as const;
}
-55
View File
@@ -1,55 +0,0 @@
import { getPageImageUrl, getPageMarkdownUrl, source } from '@/lib/source';
import { PageProps } from 'waku/router';
import { createRelativeLink } from 'fumadocs-ui/mdx';
import {
DocsBody,
DocsDescription,
DocsPage,
DocsTitle,
MarkdownCopyButton,
ViewOptionsPopover,
} from 'fumadocs-ui/layouts/docs/page';
import { unstable_notFound } from 'waku/router/server';
import { gitConfig } from '@/lib/shared';
import { getMDXComponents } from '@/components/mdx';
export default function Page({ slugs }: PageProps<'/docs/[...slugs]'>) {
const page = source.getPage(slugs);
if (!page) unstable_notFound();
const MDX = page.data.body;
const markdownUrl = getPageMarkdownUrl(page).url;
return (
<DocsPage toc={page.data.toc}>
<meta property="og:image" content={getPageImageUrl(page)} />
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
<div className="flex flex-row gap-2 items-center border-b pt-2 pb-6">
<MarkdownCopyButton markdownUrl={markdownUrl} />
<ViewOptionsPopover
markdownUrl={markdownUrl}
githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/content/docs/${page.path}`}
/>
</div>
<DocsBody>
<MDX
components={getMDXComponents({
// this allows you to link to other pages with relative file paths
a: createRelativeLink(source, page),
})}
/>
</DocsBody>
</DocsPage>
);
}
export async function getConfig() {
const pages = source
.generateParams()
.map((item) => (item.lang ? [item.lang, ...item.slug] : item.slug));
return {
render: 'static' as const,
staticPaths: pages,
} as const;
}
-12
View File
@@ -1,12 +0,0 @@
import type { ReactNode } from 'react';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { source } from '@/lib/source';
import { baseOptions } from '@/lib/layout.shared';
export default function Layout({ children }: { children: ReactNode }) {
return (
<DocsLayout {...baseOptions()} tree={source.getPageTree()}>
{children}
</DocsLayout>
);
}
-16
View File
@@ -1,16 +0,0 @@
@import "tailwindcss";
@import "fumadocs-ui/css/solar.css";
@import "fumadocs-ui/css/preset.css";
html {
scrollbar-gutter: stable;
}
html > body[data-scroll-locked] {
margin-right: 0px !important;
--removed-body-scroll-bar-size: 0px !important;
}
:root {
--fd-layout-width: 1400px;
}
+28
View File
@@ -0,0 +1,28 @@
export default {
async fetch(request, env) {
const auth = request.headers.get("Authorization") ?? "";
if (auth.startsWith("Basic ")) {
let ok = false;
try {
const decoded = atob(auth.slice(6));
const password = decoded.slice(decoded.indexOf(":") + 1); // password may contain ':'
ok = await passwordMatches(password, env.SITE_PASSWORD);
} catch {}
if (ok) return env.ASSETS.fetch(request);
}
return new Response("Authentication required", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="leetcode-docs", charset="UTF-8"' },
});
},
};
// timingSafeEqual needs equal-length buffers → hash both sides first
async function passwordMatches(candidate, expected) {
const enc = new TextEncoder();
const [a, b] = await Promise.all([
crypto.subtle.digest("SHA-256", enc.encode(candidate)),
crypto.subtle.digest("SHA-256", enc.encode(expected)),
]);
return crypto.subtle.timingSafeEqual(a, b);
}