init(docs): create docs site template with Fumapress and Waku

This commit is contained in:
Prad Nukala committed 2026-07-12 13:13:51 -04:00
1 parent 49a52b138f
commit 1aaab66971
18 files changed
+3059

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.source
.vercel
+85
View File
@@ -0,0 +1,85 @@
# docs
A [Fumapress](https://press.fumadocs.dev) (Fumadocs + Waku) docs site with
typography by [shadcn/typeset](https://ui.shadcn.com/docs/typeset) — one CSS
file you own.
## Getting started
Requires **Node.js 22+**.
```bash
bun install
bun dev # http://localhost:3000
```
## Your typeset
| Control | Choice |
| ------- | ------ |
| Heading | DM Sans |
| Body | Instrument Sans |
| Mono | JetBrains Mono |
| Size | 16px |
| Leading | Loose (1.9) |
| Flow | Compact (1em) |
| Measure | 80ch |
- `src/typeset.css` is the stylesheet from the
[typeset builder](https://ui.shadcn.com/typeset). It lives in your project —
edit it directly, or regenerate it from the builder any time.
- `src/app.css` holds your presets. `.typeset-docs` carries the choices
above; `.typeset-compact` is a tighter preset for chat-like UI. Rhythm is
three variables: `--typeset-size`, `--typeset-leading`, `--typeset-flow`.
- `typeset-prompt.md` is an agent-ready prompt with the choices above baked
in (like the builder's Prompt tab). Paste it into your coding agent to wire
typeset into the surfaces that render markdown.
Wrap any rendered markdown/HTML:
```tsx
<article className="typeset typeset-docs typeset-measure">{page}</article>
<div className="typeset typeset-compact">{message}</div>
```
Opt components out with `not-typeset`:
```tsx
<div className="typeset typeset-docs">
<p>Styled prose.</p>
<Card className="not-typeset">Untouched component.</Card>
</div>
```
## Fumadocs prose seam
Fumadocs UI ships its own `prose` styles for docs page bodies. If you apply
`typeset` to the same container, both systems will add spacing. This
template zeroes out `margin-block-end` inside `.typeset.prose` (see
`src/app.css`) so flow comes only from typeset's `margin-block-start`.
To apply the typeset classes to docs pages, customize the page layout in
`press.config.tsx` — see the
[Fumapress layouts docs](https://press.fumadocs.dev/docs/layouts).
## Structure
```
├── waku.config.ts # Waku/Vite plugins: press(), mdx(), tailwindcss()
├── source.config.ts # Fumadocs MDX content source (content/docs)
├── press.config.tsx # Site config, adapters, plugins
├── typeset-prompt.md # Agent prompt with your typeset picks baked in
├── content/docs/ # Your MDX pages
└── src/
├── app.css # Tailwind + presets + your typeset presets
└── typeset.css # shadcn/typeset — yours to edit
```
## Deploying static
Fumapress builds static + dynamic routes by default. For a pure CDN deploy,
force static mode in `press.config.tsx`:
```tsx
export default defineConfig({ mode: "static", /* ... */ });
```
+1732
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
---
title: Hello World
description: A Fumapress site styled with shadcn/typeset
---
## Overview
This page renders through Fumadocs MDX and is styled by the `typeset typeset-docs` container — headings, paragraphs, lists, tables, and code all inherit their rhythm from three variables: `--typeset-size`, `--typeset-leading`, and `--typeset-flow`.
### Why one CSS file?
- The file lives in your repo — edit rules directly, no plugin config
- Spacing flows one direction (`margin-block-start` only), so streamed content never restyles earlier blocks
- Zero-specificity `:where()` guards mean Tailwind utilities always win
```ts
// Inline overrides still work inside a typeset container
export const rhythm = {
size: "15px",
leading: 1.75,
flow: "1.5em",
};
```
> Tune a tighter preset for chat, a roomier one for docs — same file, different rhythm.
| Control | Purpose | Default |
| ------------------ | --------------------- | -------- |
| `--typeset-size` | Base text size | `1em` |
| `--typeset-leading`| Line height | `1.75` |
| `--typeset-flow` | Space between blocks | `1.25em` |
+33
View File
@@ -0,0 +1,33 @@
{
"name": "docs",
"private": true,
"type": "module",
"scripts": {
"dev": "waku dev",
"build": "waku build",
"start": "waku start",
"types:check": "fumadocs-mdx && tsc --noEmit"
},
"dependencies": {
"@base-ui/react": "latest",
"@fontsource-variable/dm-sans": "latest",
"@fontsource-variable/instrument-sans": "latest",
"@fontsource-variable/jetbrains-mono": "latest",
"fumadocs-core": "latest",
"fumadocs-mdx": "latest",
"fumadocs-ui": "npm:@fumadocs/base-ui@latest",
"fumapress": "latest",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-server-dom-webpack": "^19.2.0",
"vercel": "^55.0.0",
"waku": "latest"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0"
}
}
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig } from "fumapress";
import { fumadocsMdx } from "fumapress/adapters/mdx";
import { flexsearchPlugin } from "fumapress/plugins/flexsearch";
import { llmsPlugin } from "fumapress/plugins/llms.txt";
import { docs } from "./.source/server";
export default defineConfig({
site: {
name: "Docs",
baseUrl: import.meta.env.DEV
? "http://localhost:3000"
: "https://docs.example.com",
git: {
user: "your-github-user",
branch: "main",
repo: "docs",
},
},
content: {
docs: docs.toFumadocsSource(),
},
meta: {
root() {
return <meta property="og:type" content="website" />;
},
},
})
.plugins(flexsearchPlugin(), llmsPlugin())
.adapters(fumadocsMdx());
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig, defineDocs } from "fumadocs-mdx/config";
export const docs = defineDocs({
dir: "content/docs",
docs: {
// Required by fumapress' llms.txt plugin (getText("processed")).
postprocess: { includeProcessedMarkdown: true },
},
});
export default defineConfig();
+65
View File
@@ -0,0 +1,65 @@
@import "tailwindcss";
@import "fumadocs-ui/css/neutral.css";
@import "fumadocs-ui/css/preset.css";
@import "fumapress/css/preset.css";
/* Fonts — self-hosted via Fontsource, referenced by the typeset presets. */
@import "@fontsource-variable/dm-sans";
@import "@fontsource-variable/instrument-sans";
@import "@fontsource-variable/jetbrains-mono";
/* shadcn/typeset — one CSS file you own.
Regenerate it any time at https://ui.shadcn.com/typeset */
@import "./typeset.css";
:root {
--font-dm-sans: "DM Sans Variable", sans-serif;
--font-instrument-sans: "Instrument Sans Variable", sans-serif;
--font-jetbrains-mono: "JetBrains Mono Variable", monospace;
}
@theme inline {
--default-font-family: var(--font-instrument-sans);
--font-heading: var(--font-dm-sans);
--font-mono: var(--font-jetbrains-mono);
}
/* ---------------------------------------------------------
Typeset presets — tune rhythm per context.
Three controls: size, leading, flow.
Built with https://ui.shadcn.com/typeset
--------------------------------------------------------- */
.typeset-docs {
--typeset-font-body: var(--font-instrument-sans);
--typeset-font-heading: var(--font-dm-sans);
--typeset-font-mono: var(--font-jetbrains-mono);
--typeset-size: 16px;
--typeset-leading: 1.9;
--typeset-flow: 1em;
}
/* Tighter rhythm, e.g. for changelog callouts or chat-like UI */
.typeset-compact {
--typeset-leading: 1.6;
--typeset-flow: 1em;
}
/* Measure (80ch) — the builder puts max-width on the wrapper,
not in typeset.css. Add this class next to `typeset` when your
layout doesn't already constrain line length. */
.typeset-measure {
max-width: 37em;
}
/* ---------------------------------------------------------
Fumadocs interop:
Fumadocs UI ships its own `prose` styling for page bodies.
When a container is both, let Typeset own the rhythm by
disabling the prose margins that would double up.
Keep interactive Fumadocs components (tabs, callouts,
accordions) out of Typeset with `not-typeset`.
--------------------------------------------------------- */
.typeset.prose :where(*) {
margin-block-end: 0;
}
+490
View File
@@ -0,0 +1,490 @@
/*
* shadcn/typeset
* https://ui.shadcn.com/docs/typeset.
*/
@layer components {
.typeset {
--typeset-font-body: inherit;
--typeset-font-heading: var(--font-heading);
--typeset-font-mono: var(--font-mono);
--typeset-size: 1em;
--typeset-leading: 1.75;
--typeset-flow: 1.25em;
}
}
@layer components {
.typeset {
font-family: var(--typeset-font-body);
font-size: calc(var(--typeset-size) * 1.125);
line-height: var(--typeset-leading);
color: var(--color-foreground, currentColor);
overflow-wrap: break-word;
margin-trim: block-start;
@media (min-width: 48rem), print {
font-size: var(--typeset-size);
}
--typeset-muted: var(
--color-muted-foreground,
color-mix(in oklab, currentColor 60%, transparent)
);
--typeset-rule: var(
--color-border,
color-mix(in oklab, currentColor 20%, transparent)
);
}
.typeset
*:not(
:where(
.not-typeset,
[data-not-typeset],
.not-typeset *,
[data-not-typeset] *
)
) {
/* Paragraphs. */
&:where(p) {
margin-block-start: var(--typeset-flow);
margin-block-end: 0;
}
/* Headings. */
&:where(h1, h2, h3, h4, h5, h6) {
color: var(--color-foreground, currentColor);
font-family: var(--typeset-font-heading);
font-weight: 600;
margin-block-end: 0;
}
&:where(h1) {
font-size: 1.75em;
line-height: 1.3;
margin-block-start: var(--typeset-flow);
}
&:where(h2) {
font-size: 1.25em;
line-height: 1.4;
margin-block-start: calc(var(--typeset-flow) * 1.4);
}
&:where(h3) {
font-size: 1.125em;
line-height: 1.45;
margin-block-start: var(--typeset-flow);
}
&:where(h4) {
font-size: 1em;
line-height: 1.5;
margin-block-start: var(--typeset-flow);
}
&:where(h5) {
font-size: 0.875em;
line-height: 1.5;
font-weight: 500;
color: var(--typeset-muted);
margin-block-start: calc(var(--typeset-flow) / 0.875);
}
&:where(h6) {
font-size: 0.8125em;
line-height: 1.5;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--typeset-muted);
margin-block-start: calc(var(--typeset-flow) / 0.8125);
}
/* Anchors. */
&:where([id]) {
scroll-margin-block-start: var(--typeset-flow);
}
/* Headings own the space below them. */
&:where(h1 + *, h2 + *, h3 + *, h4 + *, h5 + *, h6 + *) {
margin-block-start: 1em;
}
&:where(:is(h1, h2, h3, h4) :is(code)) {
font-size: 0.9em;
}
/* Links. */
&:where(a) {
color: inherit;
font-weight: 500;
text-decoration-line: underline;
text-decoration-color: color-mix(in oklab, currentColor 30%, transparent);
}
&:where(a:hover) {
text-decoration-color: currentColor;
}
&:where(a:focus-visible) {
outline: 2px solid var(--color-ring, currentColor);
outline-offset: 2px;
border-radius: 0.125em;
}
&:where(:is(h1, h2, h3, h4, h5, h6) :is(a)) {
font-weight: inherit;
}
/* Inline semantics. */
&:where(strong, b) {
font-weight: 600;
}
&:where(:is(h1, h2, h3, h4) :is(strong, b)) {
font-weight: 600;
}
&:where(mark) {
background-color: color-mix(
in oklab,
oklch(0.86 0.17 95) 40%,
transparent
);
color: inherit;
padding: 0.05em 0.15em;
border-radius: 0.2em;
}
&:where(abbr[title]) {
text-decoration-line: underline;
text-decoration-style: dotted;
text-underline-offset: 0.2em;
cursor: help;
}
&:where(del, s) {
color: var(--typeset-muted);
text-decoration-line: line-through;
}
&:where(sup, sub) {
font-size: 0.75em;
line-height: 0;
position: relative;
vertical-align: baseline;
}
&:where(sup) {
top: -0.5em;
}
&:where(sub) {
bottom: -0.25em;
}
&:where(sup a) {
text-decoration-line: none;
font-weight: 500;
}
/* Lists. */
&:where(ul) {
list-style-type: disc;
}
&:where(ol) {
list-style-type: decimal;
}
&:where(ul ul) {
list-style-type: circle;
}
&:where(ul ul ul) {
list-style-type: square;
}
&:where(ol[type="a" i]) {
list-style-type: lower-alpha;
}
&:where(ol[type="i" i]) {
list-style-type: lower-roman;
}
&:where(ol[type="A" s]) {
list-style-type: upper-alpha;
}
&:where(ol[type="I" s]) {
list-style-type: upper-roman;
}
&:where(ul, ol) {
margin-block-start: var(--typeset-flow);
margin-block-end: 0;
padding-inline-start: 1.5em;
}
&:where(li) {
margin-block-start: 0.5em;
padding-inline-start: 0.4em;
}
&:where(li > p, li > ul, li > ol) {
margin-block-start: 0.5em;
}
&:where(ul > li)::marker {
color: var(--typeset-muted);
}
&:where(ol > li)::marker {
color: var(--typeset-muted);
}
/* GFM task lists. */
&:where(ul.contains-task-list) {
list-style-type: none;
padding-inline-start: 0.25em;
}
&:where(li.task-list-item > input[type="checkbox"]) {
margin-inline-end: 0.5em;
vertical-align: -0.1em;
accent-color: var(--color-primary, currentColor);
}
/* Disclosures. */
&:where(details) {
margin-block-start: var(--typeset-flow);
margin-block-end: 0;
}
&:where(summary) {
cursor: pointer;
font-weight: 500;
}
&:where(summary)::marker {
color: var(--typeset-muted);
}
/* Keyboard keys. */
&:where(kbd) {
font-family: inherit;
font-size: 0.85em;
font-weight: 500;
border: 1px solid var(--typeset-rule);
border-block-end-width: 2px;
border-radius: min(calc(var(--radius, 0.5em) * 0.6), 0.35em);
padding: 0.0625em 0.35em;
}
/* Definition lists. */
&:where(dl) {
margin-block-start: var(--typeset-flow);
margin-block-end: 0;
}
&:where(dt) {
font-weight: 500;
margin-block-start: 1em;
}
&:where(dt + dt) {
margin-block-start: 0.25em;
}
&:where(dd) {
margin-block-start: 0.25em;
margin-inline-start: 0;
padding-inline-start: 1em;
color: var(--typeset-muted);
}
/* Inline code. */
&:where(:not(pre) > code) {
background-color: var(
--color-muted,
color-mix(in oklab, currentColor 8%, transparent)
);
font-family: var(--typeset-font-mono);
font-size: 0.85em;
border-radius: min(calc(var(--radius, 0.5em) * 0.6), 0.35em);
padding: 0.125em 0.3em;
}
/* Code blocks. */
&:where(pre) {
background-color: var(
--color-muted,
color-mix(in oklab, currentColor 8%, transparent)
);
font-family: var(--typeset-font-mono);
font-size: 0.875em;
line-height: 1.5;
tab-size: 2;
direction: ltr;
border-radius: var(--radius, 0.5em);
padding: 0.75em 1em;
overflow-x: auto;
margin-block-start: calc(var(--typeset-flow) / 0.875);
margin-block-end: 0;
}
&:where(pre code) {
background-color: transparent;
font-family: inherit;
font-size: inherit;
padding: 0;
border-radius: 0;
}
/* Blockquote. */
&:where(blockquote) {
border-inline-start: 2px solid var(--typeset-rule);
padding-inline-start: 1em;
margin-block-start: var(--typeset-flow);
margin-block-end: 0;
margin-inline: 0;
}
/* Dividers. */
&:where(hr) {
border: 0;
border-block-start: 1px solid var(--typeset-rule);
margin-block-start: calc(var(--typeset-flow) * 2.4);
margin-block-end: 0;
}
&:where(hr + h1, hr + h2, hr + h3, hr + h4) {
margin-block-start: var(--typeset-flow);
}
/* GFM footnotes. */
&:where(.footnotes, [data-footnotes]) {
margin-block-start: calc(var(--typeset-flow) * 2);
border-block-start: 1px solid var(--typeset-rule);
padding-block-start: var(--typeset-flow);
font-size: 0.875em;
color: var(--typeset-muted);
}
/* Math. */
&:where(math[display="block"]) {
margin-block-start: var(--typeset-flow);
margin-block-end: 0;
overflow-x: auto;
overflow-y: hidden;
padding-block: 0.25em;
}
/* Media. */
&:where(img, video) {
border-radius: var(--radius, 0.5em);
max-width: 100%;
height: auto;
margin-block-start: var(--typeset-flow);
margin-block-end: 0;
}
&:where(p img) {
margin-block-start: 0;
}
&:where(figure) {
margin-block-start: var(--typeset-flow);
margin-block-end: 0;
margin-inline: 0;
}
&:where(figcaption, caption) {
color: var(--typeset-muted);
font-size: 0.875em;
text-align: center;
margin-block-start: calc(0.75em / 0.875);
}
&:where(caption) {
caption-side: bottom;
}
/* Tables. Separators sit on cells, not tr:last-child: append-safe.
Bare tables stay real tables (keep their semantics) and wrap to fit. */
&:where(table) {
max-width: 100%;
font-size: 1em;
line-height: 1.5;
font-variant-numeric: tabular-nums;
border-collapse: separate;
border-spacing: 0;
border-block-end: 1px solid var(--typeset-rule);
margin-block-start: var(--typeset-flow);
margin-block-end: 0;
}
/* Horizontal scroll for any wide block. Wrap it and the wrapper owns the
flow margin. Tables shrink to fit, so widen the table to make it scroll
instead of compress. */
&:where(.typeset-scroll) {
overflow-x: auto;
margin-block-start: var(--typeset-flow);
}
&:where(.typeset-scroll > *) {
margin-block-start: 0;
}
&:where(.typeset-scroll table) {
width: max-content;
max-width: none;
}
&:where(thead th) {
font-weight: 500;
text-align: start;
white-space: nowrap;
padding: 0.65em 1em;
}
&:where(:is(tbody, tfoot) :is(td, th)) {
padding: 0.75em 1em;
text-align: start;
vertical-align: top;
border-block-start: 1px solid var(--typeset-rule);
}
&:where(tbody th, tfoot th) {
font-weight: 500;
}
&:where(th:first-child, td:first-child) {
padding-inline-start: 0;
}
&:where(th[align="center"], td[align="center"]) {
text-align: center;
}
&:where(th[align="right"], td[align="right"]) {
text-align: end;
}
/* Blocks inside list items keep the list's tighter rhythm. */
&:where(li > blockquote, li > table, li > figure) {
margin-block-start: 0.5em;
}
&:where(li > pre) {
margin-block-start: calc(0.5em / 0.875);
}
@media print {
&:where(pre, table, blockquote, figure) {
break-inside: avoid;
}
&:where(h1, h2, h3, h4) {
break-after: avoid;
}
}
/* Forced colors strips the code background, so give it a border. */
@media (forced-colors: active) {
&:where(:not(pre) > code) {
border: 1px solid;
}
}
}
/* First child adds no space above. Last so it wins ties. */
.typeset
> :where(:first-child):not(
:where(
.not-typeset,
[data-not-typeset],
.not-typeset *,
[data-not-typeset] *
)
),
.typeset
> :where(:first-child)
> :where(:first-child):not(
:where(
.not-typeset,
[data-not-typeset],
.not-typeset *,
[data-not-typeset] *
)
),
.typeset
:where(
li > :first-child,
blockquote > :first-child,
td > :first-child,
th > :first-child,
dd > :first-child,
figure > :first-child,
figure > picture > img
):not(
:where(
.not-typeset,
[data-not-typeset],
.not-typeset *,
[data-not-typeset] *
)
) {
margin-block-start: 0;
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"isolatedModules": true,
"noEmit": true,
"types": ["react/experimental"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "press.config.tsx", "source.config.ts", ".source"],
"exclude": ["node_modules", "dist"]
}
+84
View File
@@ -0,0 +1,84 @@
Install shadcn/typeset in this Fumapress (Fumadocs + Waku) project.
Typeset is a single stylesheet that styles rendered markdown: wrap the output
in a `typeset` container and everything inside (headings, lists, tables,
code, blockquotes, math) is styled. Everything outside is untouched.
If this project was scaffolded with create-fumapress-typeset, steps 1-4 are
already done — verify each one and move on; every step below is safe to
re-run.
1. Download https://ui.shadcn.com/typeset.css and save it as
`src/typeset.css` (next to the main CSS file, `src/app.css`). If the
file already exists, replace it with the downloaded copy.
2. Import it in `src/app.css`, after the Tailwind and Fumadocs imports:
@import "./typeset.css";
3. Install the fonts:
bun add @fontsource-variable/dm-sans @fontsource-variable/instrument-sans @fontsource-variable/jetbrains-mono
Then import them in `src/app.css`:
@import "@fontsource-variable/dm-sans";
@import "@fontsource-variable/instrument-sans";
@import "@fontsource-variable/jetbrains-mono";
:root {
--font-dm-sans: "DM Sans Variable", sans-serif;
--font-instrument-sans: "Instrument Sans Variable", sans-serif;
--font-jetbrains-mono: "JetBrains Mono Variable", monospace;
}
4. Add this preset to `src/app.css`, after the typeset import. If a class
named `.typeset-docs` already exists, update its values in place. Leave
any other `typeset-*` presets untouched: they are separate surfaces:
.typeset-docs {
--typeset-font-body: var(--font-instrument-sans);
--typeset-font-heading: var(--font-dm-sans);
--typeset-font-mono: var(--font-jetbrains-mono);
--typeset-size: 16px;
--typeset-leading: 1.9;
--typeset-flow: 1em;
}
5. Do not apply the class anywhere yet. Search the project for surfaces that
render markdown or rich content:
- The Fumadocs docs page body. Fumapress owns that layout: customize it in
`press.config.tsx` with `createDocsLayoutPage` from
"fumapress/layouts/docs" (see https://press.fumadocs.dev/docs/layouts)
and wrap the page body there.
- MDX components, react-markdown/Streamdown renderers,
dangerouslySetInnerHTML with parsed markdown, `prose` classes, CMS
content renderers.
Present the candidates you find as a short list and ask the user which
surface should use typeset. Then wrap only the surface they pick:
<div className="typeset typeset-docs max-w-[37em]">
{content}
</div>
(`src/app.css` also defines `.typeset-measure` with the same
max-width, if a plain class is preferred over a Tailwind utility.)
If the picked surface already has its own typography (Fumadocs' `prose`
class, styled markdown components), list those styles and let the user
decide what to remove before wrapping. Note: `src/app.css` already zeroes
`margin-block-end` inside `.typeset.prose` so typeset owns the rhythm
when both classes share a container.
Notes:
- To exclude an embedded component from typeset styles, add the
`not-typeset` class or the `data-not-typeset` attribute to it. Keep
interactive Fumadocs components (tabs, callouts, accordions) out of typeset
this way.
- Verify on the surface the user picked: headings, lists, tables, and code
inside the container should be styled with no classes on the content
itself.
- Docs: https://ui.shadcn.com/docs/typeset
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "waku/config";
import tailwindcss from "@tailwindcss/vite";
import press from "fumapress/vite";
import mdx from "fumadocs-mdx/vite";
// Config file for Waku & Vite
export default defineConfig({
vite: {
plugins: [press(), mdx(), tailwindcss()],
},
});