Docs Site Skill
Scaffold a GitHub-styled TanStack Start documentation site for project analysis documents. Creates a unified hub with per-project sidebar and incremental update support.
When to Use
Use this skill when the user:
- Asks to "create a docs site", "host analysis docs", "build a documentation site"
- Says
/docs-siteor/host-docs - Wants to publish/surface analysis documents as a browsable web site
- Has analysis
.mdfiles and wants a web UI for them - Wants to update an existing docs site (add/remove projects, exclude directories)
- Says
/docs-site --exclude ...or/docs-site --only ...on an already-hosted site
Prerequisites
- bun must be available on the system
Flow Overview
Complete trigger, guard, and workflow decision flow.
flowchart TD
Trigger(["/docs-site triggered"]) --> Guard{"site/ exists?"}
Guard -->|"No"| Scan["Scan projects M1-M9"]
Guard -->|"Yes"| Marker{"Has marker file?"}
Marker -->|"Yes: our site"| U1["Find new/changed files<br/>via git status + git log"]
Marker -->|"No: foreign site"| Abort["STOP: report error<br/>do NOT overwrite"]
U1 --> U2{"Files outside<br/>topics/?"}
U2 -->|"Yes"| U2a["Move into topics/"]
U2 -->|"No"| U3
U2a --> U3["Incremental update<br/>registry.ts"]
U3 --> Build
Scan --> Build["Build"]
Build --> Done(["Done"])
⚠️ FIRST ACTION: Site Existence Guard (MANDATORY — NO EXCEPTIONS)
This check MUST run as the VERY FIRST STEP every time the skill is triggered, BEFORE any argument parsing or workflow execution. No step may proceed until this guard is resolved.
Check if the target directory already contains a site/ subdirectory:
test -d {target}/site && echo "EXISTS" || echo "NOT_EXISTS"
Decision Flow (STOP AT FIRST MATCH)
site/does NOT exist → Proceed with normal creation workflow (scan, scaffold, etc.)site/exists AND.docs-site-skillmarker found → STOP. Do NOT scaffold. Enter Update Mode only. (see "Existing Site Update Mode" below)site/exists AND no marker found → STOP. Report error and abort. Do NOT overwrite.
Error message when site exists but is not ours
ERROR: A "site/" directory already exists in the target project.
This site was not created by the docs-site skill and will NOT be overwritten.
If you want to replace it, please remove the existing site/ directory first:
rm -rf {target}/site
Then re-run /docs-site.
Marker convention
When this skill creates a new site, it MUST write a marker file site/.docs-site-skill containing:
This site was scaffolded by the docs-site skill.
This allows future runs to distinguish our sites from pre-existing ones.
Key Rule: Never Rebuild Existing Sites
When site/ already exists with our marker, the skill MUST:
- ONLY enter Incremental Update Mode (detect changed files via git, move into topics/, update only affected entries in
registry.ts, rebuild) - NEVER re-run
bunx create, re-install deps, or overwrite components/styles - NEVER delete or replace the existing
site/directory - NEVER re-scan all projects from scratch — only process new/changed
.mdfiles
Arguments
- Optional: target path (defaults to current working directory)
- Optional:
--name "Site Name"to override site title - Optional:
--only project1,project2,...to include only specific projects - Optional:
--exclude project1,project2,...to exclude specific projects
Example invocations:
/docs-site(scan current directory)/docs-site /path/to/code-analysi/docs-site /path/to/code-analysi --only tokio,k8s,zinx/docs-site /path/to/code-analysi --exclude resume,stock/docs-site /path/to/code-analysi --name "Code Analysis Hub"
Exclusion Rules
When scanning directories, apply these rules to decide which projects to include:
1. Command-line filtering (--only / --exclude):
- If
--onlyis provided: only include projects whose directory name matches one of the listed names - If
--excludeis provided: skip projects whose directory name matches one of the listed names - If both are provided:
--onlytakes precedence (ignore--exclude)
2. Always exclude (hardcoded skip list):
node_modules,site,.git,.claude,.vscode,.idea- Hidden directories (starting with
.) assets,dist,build,out,public- Directories with zero
.mdfiles (neither in root nor intopics/)
3. Minimum content threshold:
- A directory must have at least 1
.mdfile intopics/(after normalization) to be included - If a directory has
.mdfiles but they are allREADME.md, it is excluded
4. Confirmation prompt: After scanning and filtering, show the user the final project list with document counts and ask for confirmation before proceeding. Format:
Found {count} projects to include:
✓ tokio 6 topics
✓ k8s 5 topics
✓ zinx 13 topics (8 core + 5 deep dives)
✗ resume (excluded: --exclude flag)
✗ stock (excluded: only 0 topic files)
Proceed with these {count} projects? [Y/n]
This ensures the user has a chance to review and adjust before the site is generated.
Existing Site Update Mode (Incremental)
When the user runs /docs-site on a target directory that already has a site/ directory with the .docs-site-skill marker, enter update mode instead of re-scaffolding from scratch.
Detection: Check if {target}/site/.docs-site-skill exists. If yes → update mode. If site/ exists but no marker → see "Site Existence Guard" error and abort.
Core principle: incremental update, not full rebuild. Only process files that are new or changed since last build. Do NOT re-scan everything.
What update mode does:
Discover changed files using git:
%% Uncommitted (unstaged + staged) git status --porcelain -- '*.md' %% Recently committed (last build timestamp from marker file or git log) git log --diff-filter=A --name-only --pretty=format: --since="<last-build-time>" -- '*.md'Collect all new/modified
.mdfiles from both sources.Normalize locations — for each changed file found above:
- If the file is inside a project directory but NOT inside
topics/(e.g. sitting in project root):- Move it into
{project}/topics/ - Report:
Moved {file} → {project}/topics/
- Move it into
- If the file is already inside
topics/→ no move needed
- If the file is inside a project directory but NOT inside
Incrementally update
registry.ts:- Parse existing
site/src/lib/registry.tsto get current state - For each new
.mdfile: read its H1 heading, generate slug, add import + entry to the correct project's topic list - For each removed
.mdfile (deleted from disk): remove its import + entry from registry - Do NOT regenerate the entire file — only add/remove the affected entries
- Re-number
orderfields for the affected project if needed - Ensure slug uniqueness within each project (append suffix on collision)
- Parse existing
Rebuild to verify:
cd site && bun run buildReport what changed:
Updated existing site (incremental). Changes: + added: tokio/topics/07-async-scheduler.md + added: zinx/topics/deep-dive-graceful-shutdown.md ~ moved: k8s/05-crd.md → k8s/topics/05-crd.md - removed: stock/topics/01-overview.md Registry updated, build succeeded.
What update mode does NOT do:
- Does NOT re-run
bunx create site(scaffold) - Does NOT re-install dependencies
- Does NOT overwrite
styles.css,Header.tsx,Footer.tsx, or other custom components - Does NOT reset any user customizations
- Does NOT re-scan all projects from scratch — only processes git-detected changes
Key rule: The user may have manually edited styles, components, or config after the initial scaffold. Update mode only touches registry.ts — everything else is left alone.
Workflow
Detailed step-by-step creation workflow (M1-M9).
flowchart TD
M1["M1. Scan & Filter projects"] --> M1b["Normalize .md into topics/"]
M1b --> M1c["Extract titles & categorize"]
M1c --> Confirm{"User confirms?"}
Confirm -->|"Yes"| M2
Confirm -->|"No"| Stop(["Stop"])
M2["M2. Scaffold TanStack Start"] --> M2b["Remove site/.git + write marker"]
M2b --> M3["M3. Install dependencies"]
M3 --> M4["M4. Create route structure"]
M4 --> M5["M5. Generate registry.ts"]
M5 --> M6["M6. Copy route templates"]
M6 --> M7["M7. Copy shared components"]
M7 --> M8["M8. Configure Cloudflare Workers"]
M8 --> M9["M9. Build & verify"]
M9 -->|"Success"| Done(["Done"])
M9 -->|"Fail"| Fix["Fix and retry"]
Fix --> M9
M1. Scan Projects & Normalize
Run the Site Existence Guard (see "⚠️ FIRST ACTION: Site Existence Guard" section above). This is MANDATORY and must happen before anything else. Only proceed with the steps below if the guard returns "site/ does NOT exist".
List all subdirectories in the target path. Apply the Exclusion Rules from the Arguments section (always-skip dirs,
--only/--excludeflags, minimum content threshold)For each project directory that passes filtering: a. Create
mkdir {project}/topicsb. Move all.mdfiles from project root intotopics/, EXCEPTREADME.md(if it's a generic README) c. Report what was moved d. Scantopics/and extract title from first#heading of each file e. Categorize: core (NN-*.md), deep-dives (deep-dive-*.md), other f. Read the first non-heading paragraph from any root-level*-analysis.mdor firsttopics/*.mdas project descriptionCollect all projects into a registry:
{ name: "tokio", slug: "tokio", description: "...", topics: [...], deepDives: [...] }Show confirmation prompt (per Exclusion Rules #4) with the final project list, included/excluded status, and document counts. Wait for user confirmation before proceeding.
After confirmation, report the final project list
M2. Scaffold TanStack Start
Run inside the target directory:
bunx --bun @tanstack/cli create site
Then:
- Remove scaffolded
about.tsx - Remove any
.gitdirectory created by the scaffold — the target directory (e.g.~/ai/code-analysi/) is already a git repository. A nested.gitwould create a submodule conflict:rm -rf site/.git - Write the marker file to identify this site as created by the docs-site skill:
echo "This site was scaffolded by the docs-site skill." > site/.docs-site-skill
M3. Install Dependencies
cd site
bun add react-markdown remark-gfm rehype-highlight highlight.js mermaid
M4. Create Hub File Structure
Create inside site/src/:
src/
├── components/
│ ├── Header.tsx
│ ├── Footer.tsx
│ ├── ProjectLayout.tsx # Combines sidebar + main content for project pages
│ ├── MarkdownRenderer.tsx
│ └── MermaidBlock.tsx
├── lib/
│ └── registry.ts # Auto-generated project registry
└── routes/
├── __root.tsx # Root layout (no sidebar — hub mode)
├── index.tsx # Hub homepage: project card grid
└── project/
└── $projectSlug/
├── index.tsx # Project overview (wraps with <ProjectLayout>)
├── topics/
│ └── $slug.tsx # Topic page (wraps with <ProjectLayout>)
└── deep-dives/
└── $slug.tsx # Deep dive page (wraps with <ProjectLayout>)
Key architecture decision: Each project page wraps its content with <ProjectLayout> which provides the sidebar + main content area. There is NO project/$projectSlug/__root.tsx — the layout is handled by a component, not a route layout. This avoids TanStack's nested __root.tsx complexity.
M5. Generate registry.ts
This file is generated dynamically and is the core of the hub. For each project and each .md file within it, generate:
Import statements using Vite
?urlsuffix (NOT?raw—?rawembeds full file content and causes large bundles), with paths relative tosite/src/lib/.?urlreturns only the asset URL string (~50 bytes); markdown content is loaded at runtime viauseTopicContenthook. Import names MUST include the project slug as prefix to avoid collisions across projects:import md_tokio_1 from '../../tokio/topics/01-overview.md?url' import md_tokio_2 from '../../tokio/topics/02-architecture.md?url' import md_codex_1 from '../../codex/topics/01-intro.md?url' import md_codex_2 from '../../codex/topics/02-project-structure.md?url'Naming pattern:
md_{projectSlug}_{sequentialNumber}— the project slug prefix ensures every import name is globally unique.Extract titles at generation time by reading each
.mdfile's H1 heading. Titles are hardcoded in the registry — NOT extracted at runtime.A nested data structure with
urlfield (NOTcontent):
Slug uniqueness rule: Every topic slug within a project MUST be unique and non-empty. Slug is derived from the filename (without .md). If two files in the same project would produce the same slug (e.g. MongoDB.md and another MongoDB.md, or filenames that normalize to the same string), append a distinguishing suffix based on the title or order number (e.g. MongoDB-sharding, MongoDB-multi-server). Empty slugs (from files with no meaningful name) must be given a descriptive slug derived from the title. This is critical because:
getTopic()uses.find()by slug — duplicate slugs make some pages inaccessible- React list rendering uses
key={topic.order}to avoid duplicate key warnings, but slug uniqueness is still required for correct URL routing
export interface TopicMeta {
slug: string
title: string
category: 'core' | 'deep-dive' | 'other'
order: number
url: string
}
export interface ProjectMeta {
slug: string
name: string
description: string
topics: TopicMeta[]
coreTopics: TopicMeta[]
deepDiveTopics: TopicMeta[]
}
export const projects: ProjectMeta[] = [
{
slug: 'tokio',
name: 'Tokio',
description: '...',
topics: [...],
coreTopics: [...],
deepDiveTopics: [...]
},
// ... more projects
]
export function getProject(slug: string): ProjectMeta | undefined { ... }
export function getTopic(projectSlug: string, topicSlug: string): TopicMeta | undefined { ... }
M6. Copy Route Templates
Copy from templates/multi/ (see "Route Templates" table below for details):
hub-root.tsx→site/src/routes/__root.tsxhub-index.tsx→site/src/routes/index.tsxProjectLayout.tsx→site/src/components/ProjectLayout.tsxproject-index.tsx→site/src/routes/project/$projectSlug/index.tsxproject-topic.tsx→site/src/routes/project/$projectSlug/topics/$slug.tsxproject-deepdive.tsx→site/src/routes/project/$projectSlug/deep-dives/$slug.tsx
M7. Copy Shared Components
Copy from templates/:
styles.css,header.tsx,footer.tsx,markdown-renderer.tsx,mermaid-block.tsx,theme-toggle.tsx
Additionally, copy the async content loading hook to site/src/hooks/:
use-topic-content.ts→site/src/hooks/useTopicContent.ts
M8. Configure Cloudflare Workers
M8a. Install Cloudflare dependencies
cd site
bun add -d wrangler @cloudflare/vite-plugin
M8b. Create wrangler.toml
Create site/wrangler.toml:
name = "{site-name-hub}"
main = "src/worker.ts"
compatibility_date = "2026-03-28"
compatibility_flags = ["nodejs_compat"]
[assets]
directory = "dist/client"
binding = "ASSETS"
M8c. Create src/worker.ts
Create site/src/worker.ts:
import server from '../dist/server/server.js'
export default {
async fetch(request: Request, env: { ASSETS: { fetch: (req: Request) => Promise<Response> } }) {
const url = new URL(request.url)
// Static asset requests — serve from ASSETS binding
if (isStaticAsset(url.pathname)) {
const assetResponse = await env.ASSETS.fetch(request)
if (assetResponse.status !== 404) return assetResponse
}
// Everything else — SSR
return server.fetch(request)
},
} satisfies ExportedHandler<{ ASSETS: Fetcher }>
function isStaticAsset(pathname: string): boolean {
return /\.(js|css|png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|eot|json|webmanifest|txt|xml|map|md)$/i.test(pathname)
}
M8d. Add deploy scripts to package.json
Add to scripts:
{
"deploy": "wrangler deploy",
"cf-dev": "wrangler dev"
}
M8e. Update vite.config.ts
Ensure server.fs.allow includes parent directory (for ?url imports to resolve sibling project directories):
server: {
fs: {
allow: ['..'],
},
},
M9. Build & Verify
cd site && bun run build
Report result:
Docs site created!
Projects ({count}):
- tokio: {N} topics, {M} deep dives
- k8s: {N} topics
- ...
Start: cd site && bun run dev
Build: cd site && bun run build
Deploy: cd site && bun run deploy (manual)
Pages:
- / Hub homepage
- /project/{slug} Project overview
- /project/{slug}/topics/{id} Topic page
- /project/{slug}/deep-dives/{id} Deep dive page
Template Files
Shared Components (~/.claude/skills/docs-site/templates/)
| File | Purpose |
|---|---|
styles.css |
GitHub-style theme (light + dark) |
header.tsx |
Sticky header with logo and theme toggle |
footer.tsx |
Simple footer |
sidebar.tsx |
Left sidebar with grouped navigation links |
markdown-renderer.tsx |
react-markdown + remark-gfm + mermaid code block detection |
mermaid-block.tsx |
Dynamic mermaid.js renderer |
theme-toggle.tsx |
Dark/light theme switcher |
use-topic-content.ts |
Hook for fetching markdown content from URL at runtime |
worker.ts |
Cloudflare Workers entry point (SSR + static assets) |
wrangler.toml |
Cloudflare Workers deployment config |
Route Templates (~/.claude/skills/docs-site/templates/multi/)
| File | Purpose |
|---|---|
hub-root.tsx |
Hub root layout (header + hub-main + footer, NO sidebar) |
hub-index.tsx |
Hub homepage with project cards |
ProjectLayout.tsx |
Component combining sidebar + main content, used by all project pages |
project-index.tsx |
Project overview with topic/deep-dive card grids, wrapped in <ProjectLayout> |
project-topic.tsx |
Topic page with markdown rendering + prev/next, wrapped in <ProjectLayout> |
project-deepdive.tsx |
Deep dive page with markdown rendering + prev/next, wrapped in <ProjectLayout> |
registry.ts |
Registry template (placeholder-based, ?url imports for small bundle) |
Mermaid Syntax Rules
When writing or validating mermaid code blocks in .md files, follow these rules to avoid render failures:
Comment Syntax
- Use
%%for comments, never#—#causes Parse error
Node & Subgraph IDs
- IDs must be globally unique — a subgraph ID (e.g.
subgraph DRA[...]) and a node ID (e.g.DRA[...]) cannot share the same name. This creates a "cycle" error. - Reserved keywords are case-insensitive: never use
loop,end,alt,opt,par,critical,breakas node or participant IDs. Use a different name (e.g.LoopFninstead ofLoop).
Node Label Special Characters
- Wrap labels in double quotes when they contain:
@,[],:followed by/(e.g. IP CIDR), or()immediately after<br/>:@ Symbol→ID["@ Symbol"]AgentMessage[]→ID["AgentMessage"]10.244.0.0/16→ID["CIDR: 10.244.0.0/16"]CronService<br/>(state)→ID["CronService<br/>(state)"]
- The
[]inside labels is ambiguous with mermaid's node shape syntax — always quote it. - The
>from<br/>followed by(can make the parser treat(text)as a rounded-edge node — quote the label.
sequenceDiagram Rules
alt/else/endsyntax only — never usealt cond1|cond2| targetoralt 是|否|:alt condition A ... else condition B ... endstyledirective only works ingraph/flowchart— never usestyleinsequenceDiagram. It causes Parse error.Note overonly works insequenceDiagram— never use it ingraph/flowchart.
HTML Entities
- Never use HTML entities (
<,>,&) in mermaid code blocks. They are rendered as literal text, not decoded. Use the actual characters or alternative notation:HashMap<K,V>→HashMap(K,V)orHashMap[K,V]<-->should be the literal characters, not<-->
Arrow Syntax
- Bidirectional arrows
<-->are valid ingraph/flowchartdiagrams - Extra spaces around arrows are fine:
A <--> Bworks
Nested Subgraphs
- Nested subgraphs are supported in mermaid v10+, but empty labels like
subgraph Row1[""]can cause Parse error. Always give nested subgraphs a meaningful label.
rehype-highlight Interference
- The
rehype-highlightplugin wraps code content in<span class="hljs-*>elements and addshljsto the class - The MarkdownRenderer
CodeBlockcomponent must:- Use regex
className?.match(/language-(\w+)/)?.[1](notreplace) to extract language - Use a recursive
extractText()function to get plain text from children (notString(children)which produces[object Object])
- Use regex
Important Notes
- Always use bun, never npm
- Never initialize
.gitinsidesite/— the target directory is already a git repo. Removesite/.gitafter scaffolding to avoid submodule conflict. - Topic slugs must be unique and non-empty within each project. Deduplicate by appending suffixes (e.g.
MongoDB-sharding). Duplicate/empty slugs breakgetTopic()and URL routing. - Use
key={topic.order}(notkey={topic.slug}) in all.map()lists shellComponentpattern is required in__root.tsx(TanStack Start SSR)- Mermaid is loaded via dynamic
import('mermaid')— do not import at top level - Route file names with
$like$slug.tsxare TanStack Router's dynamic segment syntax - Deploy is manual:
cd site && bun run deploy