Writing docs with an AI agent? Point it at
AGENTS.md— a self-contained spec of the content model, every Markdoc tag, and the gotchas, so it can author correct pages without reading this README or the source.
A reusable, container-shippable documentation site. Drop your markdown (or Markdoc) files into a directory, point the container at it, and you have a searchable, dark-mode-friendly, syntax-highlighted docs site.
Built with SvelteKit 2 + Svelte 5, Tailwind CSS 4, shadcn-svelte, Markdoc, Shiki, and Pagefind for static search.
open-docs ships its own documentation as content. Run the container
with nothing mounted (or make dev) and the site you get is the
full docs — a live, clickable demo. The source for those pages lives in
src/content/; mount your own /content to replace
them.
docker run --rm -p 3000:3000 \
-v ./content:/content:ro \
-v ./static:/static:ro \
-e PUBLIC_BRAND_NAME="My Project" \
-e PUBLIC_SITE_TITLE="My Project Docs" \
-e PUBLIC_REPO_URL=https://github.com/me/my-project \
ghcr.io/manchtools/open-docs:latestVisit http://localhost:3000. The container parses your content at
start (seconds, ~100 MB — small hosts are fine) and renders pages on
the fly; the search index follows moments later.
bun install
PUBLIC_BRAND_NAME="My Project" bun run devDrop your .md / .markdoc files into src/content/. The dev server
hot-reloads on save.
Markdown written for GitHub or VSCode works as-is: relative links
(./other.md#section), README.md as the folder page, images next to
your files, task lists, and footnotes all render. Inline HTML is never
rendered and bare URLs stay text, by design. See the
Bring existing markdown
docs page.
The sidebar is derived entirely from the folder structure — there is no nav config file to maintain.
src/content/
introduction.md ← landing page (also: index.md)
01-get-started/
01-install.md
02-quickstart.md
02-reference/
api.md
cli.md
theme.css ← optional, custom styling (see Theming)
- Top-level files become top-of-sidebar entries.
- First-level subdirectories become sidebar groups; the files inside them become that group's items.
- Subfolders nest as collapsible sub-sections, up to three levels deep; anything deeper flattens into the third level.
- Filenames are kebab-cased and become path segments
(
get-started/install.md→/get-started/install).
By default items sort alphabetically and titles come from the filename. Two override mechanisms — both still derived from the content, no external nav file:
-
Numeric prefixes. A leading
01-,02_,03.on a file or directory sets its sort position and is stripped from the URL and the title.01-get-started/02-install.md→ group "Get started" (sorts 1st), item "Install" (sorts 2nd), URL/get-started/install. -
Frontmatter (wins over the filename):
--- title: Installing the CLI # full page heading / browser title label: Install # short sidebar label (alias: sidebar_label) order: 2 # sort position within its group description: Install the… # meta description (else the first paragraph) ---
Mix freely — e.g. order the directories with NN- prefixes and set a
shorter label: per page in frontmatter.
A page with meta: true is kept out of the sidebar and listed in the
footer instead — handy for an imprint, privacy policy, or other legal page.
All configuration is environment variables — read at runtime by the
server, so changing them is a container restart, not a rebuild. Set them
on the docker run command, in a .env file, or in your shell.
| Variable | Default | Effect |
|---|---|---|
PUBLIC_BRAND_NAME |
open-docs |
Top-nav brand text |
PUBLIC_BRAND_TAGLINE |
docs |
Subtitle next to the brand |
PUBLIC_LOGO_SRC |
/favicon.svg |
Path under static/ |
PUBLIC_SITE_TITLE |
open-docs |
<title> + og:title |
PUBLIC_SITE_DESCRIPTION |
generic blurb | Default meta description |
PUBLIC_SITE_URL |
(empty) | Full base URL; enables canonical links, sitemap.xml, robots.txt, llms.txt |
PUBLIC_DEFAULT_LANG |
en |
Default language for unprefixed URLs (see Multi-language) |
PUBLIC_REPO_URL |
(empty) | If set, shows GitHub link in nav + footer |
BASE_PATH |
(empty) | Sub-path deploy, e.g. /docs |
The mobile browser theme-color follows your --primary token
automatically, so there is no separate flag for it.
Any env var starting with PUBLIC_TOKEN_ is exposed to markdown as a
{{NAME}} placeholder. For example, PUBLIC_TOKEN_API_URL=https://api.example.com
makes {{API_URL}} resolve to that value when the content is parsed at
start.
Send requests to {{API_URL}}/v1/widgets.This is pure string substitution, applied during preprocessing — no AST work, so it can't break Markdoc syntax as long as the substituted value doesn't contain Markdoc-significant characters.
Drop favicons, OpenGraph images, and screenshots into static/:
static/
favicon.svg ← required
favicon-16.png
favicon-32.png
apple-touch-icon.png
og.png ← social-card image
screenshots/...
In Docker, mount your static/ directory at /static. It's merged
into the image's static/ rather than replacing it, so the defaults
keep working if you only override a subset.
Ship a -dark variant next to any icon (favicon-32-dark.png,
apple-touch-icon-dark.png, …) and the tab icon follows the site's
dark-mode toggle automatically.
Translate a page by adding a language suffix to its filename:
introduction-de.md is the German version of introduction.md. Languages
are discovered from the suffixes when the content is parsed — no config
file.
- The default language stays unprefixed (
/getting-started/intro); other languages get a/<lang>prefix (/de/getting-started/intro). A single-language site has no prefixes at all. - Untranslated pages fall back to the default-language content at the same slug, so nothing 404s. Translate as much or as little as you want.
- You get a top-bar language switcher, a localized sidebar / prev-next,
localized interface labels (the chrome is translated into dozens of
languages), per-language search, and
hreflangalternates.
Set the default with PUBLIC_DEFAULT_LANG (defaults to en). See the
Multi-language docs page.
Set blog: true on any section's index.md and its pages become dated
posts: newest-first sorting, a generated listing with covers and reading
time, author pages, tag pages, Newer/Older navigation, and an Atom feed
at /<section>/feed.xml. Posts need date: YYYY-MM-DD in frontmatter
(validated at startup); draft: true posts serve only in dev. Works per
section: docs, a blog, and a changelog can share one container. See the
Blogging docs page; this
repo's own changelog is a live example.
Every page is rendered on the server, so search engines and AI crawlers see the full content without running JavaScript. On top of that:
- Per-page metadata — each page emits its own
<title>,<meta name="description">, canonical link, and Open Graph / Twitter tags. The description comes from the page'sdescriptionfrontmatter, falling back to its first paragraph. sitemap.xml,robots.txt, and anllms.txtindex for AI assistants — all generated from your content, so they never drift.
Set PUBLIC_SITE_URL to your site's full base URL to turn on absolute
canonical/sitemap links. See the SEO & AI search
docs page for details.
Drop a theme.css into your content root (the same directory you
mount at /content) and open-docs loads it automatically — after its
own stylesheet, so your rules always win. No fork, no rebuild of the
image.
my-docs/
index.md
theme.css ← your overrides
get-started/...
Two layers are overridable:
- Design tokens — the shadcn CSS custom properties (
--primary,--background,--sidebar,--radius,--font-sans, …). Change a few and the whole site re-themes coherently in both light and dark mode. This is the recommended path. - Component classes — target any class the site renders
(
.prose, the sidebar links, the top nav) for structural tweaks the tokens don't reach.
Copy theme.example.css to theme.css as a
starting point. It's plain CSS — light/dark is handled by the .dark
class that mode-watcher
toggles on <html> (the theme toggle lives in the top nav).
In Docker, the file rides along with your content mount; nothing extra to configure:
docker run --rm -p 3000:3000 \
-v ./content:/content:ro \
ghcr.io/manchtools/open-docs:latest
# where ./content/theme.css holds your overridesmake install # bun install
make dev # vite dev (hot reload)
make check # svelte-check type check
make build # production build → ./build/
make preview # bun serve ./build/
make docker # docker build -t open-docs:dev .