This is the human-readable contributor guide for docs.sifa.id. If you're an AI assistant, CLAUDE.md has the same content distilled into instructions. If you're a maintainer looking for the strategic context (why Fumadocs, why one site with tabs, why CSP is shaped the way it is), the workspace decision log at ~/Documents/CoreNotes/Workspaces/Sifa/decisions/2026-06-02-docs-site.md is the source.
Prerequisites: Node 24, pnpm 10. If you don't have pnpm, use npx pnpm@10.29 for everything (it's what CI uses).
git clone https://github.com/singi-labs/sifa-docs.git
cd sifa-docs
npx pnpm@10.29 install
pnpm dev # http://localhost:3000The dev server hot-reloads on MDX and component edits.
Useful scripts:
pnpm build # static export into out/
pnpm lint # eslint
pnpm format # prettier --write .
pnpm format:check # prettier --check . (what CI runs)
pnpm typecheck # tsc --noEmit on the Next.js code
pnpm examples:typecheck # tsc --noEmit on examples/*.ts against the pinned SDK
pnpm screenshots:capture # run the Playwright pipeline (requires SCREENSHOT_HANDLE + SCREENSHOT_PASSWORD env vars)Three steps:
Create the file under content/docs/:
- User docs (most pages):
content/docs/<slug>.mdx - SDK reference:
content/docs/sdk/<slug>.mdxorcontent/docs/sdk/reference/<slug>.mdx
Frontmatter every page needs:
---
title: Short page title
description: One sentence. Used as <meta name="description"> and og:description. Plain text only. No markdown links here, they render literal in meta tags.
---
Body content in MDX.Open the relevant meta.json:
- User docs sidebar:
content/docs/meta.json - SDK sidebar:
content/docs/sdk/meta.json
Insert your slug at the right group. Section headers use the ---Group name--- format:
{
"pages": [
"index",
"---Getting started---",
"create-account",
"your-new-page",
"---Features---",
"..."
]
}Every external PDS, app, tool, or spec mentioned in body prose gets a markdown link on its first mention per page. Subsequent mentions stay bare for readability. Frontmatter description: fields stay link-free.
Canonical-URL table for the entities that show up most often:
| Entity | URL |
|---|---|
| AT Protocol | https://atproto.com |
| Atmosphere (general intro) | https://atmosphereaccount.com |
| Bluesky | https://bsky.app |
| Eurosky | https://eurosky.social |
| Tangled | https://tangled.org |
| Blacksky | https://blacksky.community |
| Leaflet | https://leaflet.pub |
| WhiteWind | https://whtwnd.com |
| Smoke Signal | https://smokesignal.events |
| Offprint | https://offprint.pub |
| pckt.blog | https://pckt.blog |
| pdsls.dev | https://pdsls.dev |
| PDSmoover | https://pdsmoover.com |
| goat CLI | https://github.com/bluesky-social/indigo/tree/main/cmd/goat |
| Standard.site | https://standard.site |
| ORCID | https://orcid.org |
| Keyoxide | https://keyoxide.org |
| Substack | https://substack.com |
| Mastodon | https://joinmastodon.org |
| NetNewsWire, Reeder, Feedly, Inoreader, FreshRSS, Miniflux, NewsBlur | (see existing pages for URLs) |
If you mention an entity not in this table, look up the canonical URL and link the first mention. Don't invent URLs; if you can't verify, leave the term bare and flag it in PR review.
GitHub deliberately stays unlinked (too ubiquitous).
The site uses a few build-time components to anchor docs to source of truth instead of hand-maintained text. Examples already in tree:
<ActivityRegistry tier="creation" />renders theACTIVITY_TIERStaxonomy from@singi-labs/sifa-sdkas a table.<SdkVersion />renders the currently-pinned SDK version frompackage.json.<SdkColors />renders the brand-color tokens from@singi-labs/sifa-sdk/tokens.<SdkTypography />renders the font tokens.<SdkIconWeights />renders the icon-weight convention.<Example path="01-fetch-activity-tiers.ts" />reads an example file fromexamples/and renders it as a syntax-highlighted code block.<Screenshot src caption alt />renders a Playwright-captured screenshot frompublic/screenshots/.
To add a new component:
-
Write the component under
components/<name>.tsx. React Server Component is the default; client-side state needs'use client'at the top. -
Register it in
mdx-components.tsx:import { MyComponent } from './components/my-component' export function getMDXComponents(components?: MDXComponents): MDXComponents { return { ...defaultMdxComponents, MyComponent, // ...existing components... ...components, } }
-
Use it from any MDX file:
<MyComponent />.
If your component reads from sifa-sdk or any other dependency, use the structural-anchor pattern: import the live data at build time, render it directly. Don't hand-write what the SDK can tell you.
Examples under examples/ are real TypeScript files. They get typechecked against the pinned SDK on every CI run (pnpm examples:typecheck), so a breaking change in the SDK fails the docs build before stale examples can ship.
- Add
examples/<NN>-<short-description>.ts. - Make sure it imports from
@singi-labs/sifa-sdk(or a subpath) rather than redefining types. - End with a "side effect" line if needed (
void someFunctionThatHasToBeReachable) so unused-locals lint passes. - Reference it from MDX with
<Example path="<NN>-<short-description>.ts" />.
If the example needs a type the SDK exports under an unusual name, add a re-export to examples/sdk-types.ts so future renames in the SDK fail loudly.
The pattern is: don't write code blocks by hand in MDX. Write them as .ts files, let CI watch them.
The full four-step workflow lives in CLAUDE.md because it has cross-PR ordering that's easy to mess up. Summary:
- Decide what deserves a shot (skip auth-gated, third-party UI, unstable surfaces).
- Add a target to
scripts/screenshot-targets.tsand merge that PR first. - Run
gh workflow run screenshots.yml -R singi-labs/sifa-docs. The bot opens achore(screenshots)PR with the new PNG. Eyeball, merge. - Open a second PR adding the
<Screenshot src="..." alt="..." caption="..." />reference in MDX.
Don't bundle target + embed in one PR. The embed will fail validation (scripts/validate-screenshot-refs.ts) until the PNG lands on main.
content/docs/meta.json is the User docs sidebar.
content/docs/sdk/meta.json has "root": true set, which makes the SDK section its own sidebar context when the URL is /docs/sdk/*.
Top-nav switcher between the two tabs is in app/layout.config.tsx's links array. If you add a third tab, mirror the pattern: a folder with root: true in its meta.json plus a links entry.
Every page under content/docs/ is written in Simplified Technical English, based on ASD-STE100. pnpm check:ste enforces the mechanical rules on every PR. Run it before you push.
Prose is in scope: body text, headings, list items, table cells, the frontmatter description, and the alt and caption text of <Screenshot>. Code fences, inline code, URLs and generated SDK tables are out of scope.
| Rule | What it means | Checked |
|---|---|---|
| S1 | Instruction sentences: 20 words max. Descriptive sentences: 25 words max. | yes |
| S2 | Six sentences max per paragraph. | yes |
| S3 | One action per instruction. Split "Open settings and click Save" in two. | yes |
| S4 | Active voice. Name the actor. | yes |
| S5 | Simple tenses only. No "has been added", no "is running". | yes |
| S6 | No -ing participles as verbs. "After you connect GitHub", not "After connecting GitHub". |
yes |
| S7 | No contractions. Write "do not", "cannot", "it is". Possessive 's is fine. |
yes |
| S8 | One word, one meaning. Use the approved word: use (not utilize), before (not prior to), to (not in order to), also (not additionally), get (not obtain), need (not require), make sure (not ensure), with or by (not via). | yes |
| S9 | No idiom, slang, or empty intensifiers. No "under the hood", "spin up", "seamless", "simply", "powerful". | yes |
| S10 | Noun clusters: three words max. | reviewer |
| S11 | One term per concept. "AT Protocol", "atproto", "Atmosphere". | partly |
| S12 | A warning goes above the step it qualifies, never after. | reviewer |
| S13 | Keep articles. "Open the settings page", not "Open settings page". | reviewer |
To keep a single violation, put a directive on that line or the line above. A reason is required:
{/* ste-allow: S4 quoting the wording the API returns */}
The record is rejected by the server.There is no baseline file. The whole corpus passes, and it stays that way.
Full rule and rationale: decisions/2026-08-06-docs-simplified-technical-english.md in the Sifa workspace. We follow the structure of ASD-STE100, not its licensed approved-word dictionary, so never describe the site as "ASD-STE100 certified".
External-audience MDX pages on docs.sifa.id (anything under content/docs/) are humanizer-required: every draft passes through the humanizer skill (or its equivalent in this workspace) before merge. The rule is "draft → humanizer → ship", not "ship and pass through humanizer later".
Tight constraints worth knowing:
- No em-dashes (the wide horizontal one) anywhere. Use a colon, comma, semicolon, or restructure. CI does not enforce this; reviewers should.
- No AI-trope phrases: avoid "It's not just X, it's Y", "didn't just...also", "isn't X. It's Y", "Here's the thing", "Let me tell you", "In conclusion".
- No throat-clearing: if a sentence's only job is to announce what comes next, delete it.
- Contractions: banned under
content/docs/(rule S7 above, enforced). The old "contractions throughout" rule now applies only outside the docs corpus, for example in this file and in the README. - Hedges for predictions, direct for principles: "I think this might break" but "This breaks. Fix it."
- AT Protocol terminology: use "AT Protocol" (preferred public form), "atproto" (lowercase, dev contexts), or "Atmosphere" (ecosystem name). Never "ATproto" / "ATProtocol" / "atmosphere" lowercase.
Full reference: ~/Documents/CoreNotes/Workspaces/writing/writing-style-2.md.
Every PR runs these (in order):
pnpm format:check: prettier 3.8.1, strict.pnpm build: Next.js static export. Fails if any route handler underapp/lacksexport const dynamic = 'force-static'.pnpm lint: eslint. Fails on unescaped apostrophes in JSX (react/no-unescaped-entities); use'.pnpm check:ste:test: unit tests for the STE checker itself (node:testthrough tsx).pnpm check:ste: Simplified Technical English check overcontent/**/*.mdx. Fails on any violation. See "Voice and style" above.pnpm check:links:test: unit tests for the app-link checker itself.pnpm check:links: fails when a universal Sifa app route (/settings/*) appears in inline code or bare prose instead of a Markdown link. Those pages are the same for every reader, so they are always clickable. Placeholder routes (/p/<username>,/c/<domain>) are out of scope, and so are fenced code blocks. Suppress one line with{/* links-allow: reason */}.pnpm examples:typecheck:tsc --noEmitagainstexamples/tsconfig.json. Fails if any.tsfile underexamples/doesn't compile against the pinned SDK.
Deploy CI runs the same plus rsync to sifa-prod after merge to main.
Push to main → GitHub Actions builds + rsyncs to /var/www/docs.sifa.id/ on sifa-prod → Caddy serves the new files. Typically 1 to 2 minutes from merge to live.
Verify after a change:
curl -sSI https://docs.sifa.id/<your-route>/ # 200
curl -sS https://docs.sifa.id/<your-route>/ | grep -i 'your change here'For visual changes, open the page in a browser (incognito to avoid cache) or run:
gh workflow run screenshots.yml -R singi-labs/sifa-docs # if your change wants new screenshots| Symptom | Fix |
|---|---|
react/no-unescaped-entities lint error |
Replace ' with ' in the JSX text |
export const dynamic = "force-static"...not configured |
Add export const dynamic = 'force-static' to the new route handler |
Frontmatter description shows [Foo](url) literally on Google |
Strip markdown links from frontmatter; use plain text only |
| Live site shows old content | HTML caches for 5 min. Hard-refresh, or wait |
| Dark mode breaks | CSP got tightened; restore 'unsafe-inline' in script-src on the Caddyfile (see ~/Documents/CoreNotes/Workspaces/Sifa/decisions/2026-06-02-docs-site.md) |
| 404 page renders Next.js stock instead of the Sifa-branded one | Confirm app/not-found.tsx is in tree and the Caddyfile has the handle_errors block |
| Lockfile mismatch in CI | npx pnpm@10.29 install locally and commit pnpm-lock.yaml; the system pnpm 11 strips overrides |
You'll get an auto-opened PR on sifa-docs titled chore(deps): bump @singi-labs/sifa-sdk to X.Y.Z. CI on that PR runs both typechecks against the new SDK. If green, just merge. If red, the breaking change in the SDK needs accommodating in examples/ or in MDX content. Fix forward on the bump branch; don't roll back the SDK.
The dispatch loop is documented in ~/Documents/CoreNotes/Workspaces/Sifa/infrastructure/docs-sifa-id.md.
| File | Audience | What it covers |
|---|---|---|
README.md |
Public / npm | Project description and links |
AGENTS.md |
AI agents (public) | Minimal pointer file |
CLAUDE.md |
Claude Code | Operational rules (screenshots, force-static, CSP, first-mention linking) |
CONTRIBUTING.md |
Humans | This file |