Building a manual-mode site#
Manual mode is the simplest pipeline. You write Markdown; Ovellum produces HTML, CSS, and a tiny bit of JavaScript. There's no source parsing, no merge engine, no orphan archive — just rendering.
Minimum project#
my-docs/
ovellum.config.json
content/
index.md
getting-started.md
Config:
{
"mode": "manual",
"input": "./content",
"output": "./dist",
"site": {
"title": "My docs"
}
}
Build:
npx ovellum build
Result:
dist/
index.html
getting-started/index.html
assets/
ovellum.css
ovellum.js
Pretty URLs are the default. Every page becomes <slug>/index.html so the
URL is /<slug>/. No server-side rewrites needed; works on any static
host.
Note that neither file above has frontmatter, and there's no _meta.json
anywhere — both are optional. The slug comes from the filename and the title
from the first # H1. See Adding navigation.
Adding navigation#
The sidebar is built automatically from your file tree — no config required. Two rules cover the whole structure:
- Slug = filename without
.md.orphans.md→/orphans/. Subfolders nest:guides/install.md→/guides/install/. Anindex.mdrepresents its folder rather than getting its own slug. - Each subfolder is a section, titled after the folder name, title-cased:
getting-started/→ "Getting started".
The home page (/) resolves automatically: index.md, else a root
README.md — so an existing repo README becomes the docs home with no
config. To use a different file, set site.home
(e.g. "overview.md"); to keep the README out entirely, add it to ignoreFiles.
Sections collapse by default in the sidebar (a click expands them) — the
section holding the page you're on stays open, so you always see where you are.
Prefer everything expanded? Set site.sidebar.collapse: false.
Page titles resolve, in order:
- The frontmatter
title:field, if set. - The first
# H1in the body, otherwise. - The filename, as a last resort (
getting-started.md→ "Getting started").
So frontmatter is optional — a page can be just a # Heading followed by
its content. That heading becomes the on-page title, the sidebar label, and
the <title> tag. Add frontmatter only when you want to override something:
title— overrides the H1/filename for the title, sidebar, and<title>.description— sets<meta name="description">.tags— a list →<meta name="keywords">.permalink— a custom URL for the page (e.g.permalink: /faq/).draft: true— a draft (shown indev, excluded frombuild).updated— pins the Edited date (e.g.updated: 2026-05-20), overriding the git/filesystem lookup. Use it when you want the displayed date to reflect a meaningful edit rather than git mechanics.
Taking control with _meta.json (optional)#
_meta.json is never required. Drop one into a directory only when you want
to override the automatic order or the folder-name section title:
content/
guides/
_meta.json
install.md
configure.md
deploy.md
{
"title": "Guides",
"order": ["install", "configure", "deploy"]
}
titleoverrides the (title-cased) folder name.orderis a list of slugs (filenames / subfolder names without.md); anything not listed sorts alphabetically after the explicit set.collapseoverrides the sidebarsite.sidebar.collapsedefault for this folder:falsekeeps it always expanded,truealways collapsed.hidden: truedrops the folder (and everything under it) from the build.
Without a _meta.json, the folder's pages simply sort alphabetically — often
exactly what you want.
Excluding pages and folders#
Four ways to keep content out of the published site, from broad to narrow:
-
site.ignoreFolders— list folder names in your config to drop them entirely (no sidebar entry, not rendered, not copied). Matched at any depth:{ "site": { "ignoreFolders": ["drafts", "internal"] } } -
site.ignoreFiles— file globs to drop individual files (Markdown pages and passthrough assets), without editing them. No-slash patterns match the basename anywhere; slashed patterns match the path relative toinput:{ "site": { "ignoreFiles": ["README.md", "*.draft.md", "drafts/**"] } } -
_meta.json"hidden": true— let a folder opt itself out, in place:{ "hidden": true } -
Frontmatter
draft: true— unpublish a single page:--- title: Work in progress draft: true ---
All four drop the content from the sidebar and the build, and both
build and check honor them (so check never lints excluded files).
Asset-only folders with no Markdown are kept out of the sidebar automatically,
while their files still pass through to dist/. The reserved
public/ folder is special — it's copied to the output root
(see Static assets below).
Running from your project root? input: "." is fine — dotfiles,
node_modules, package manifests/lockfiles, your ovellum.config.*, and the
output dir itself are always excluded automatically, so none of them leak
into the build. Use ignoreFiles for anything else (like a repo README.md).
Callouts#
Five labeled callout types — [!NOTE], [!TIP], [!IMPORTANT],
[!WARNING], [!CAUTION] — render by writing GitHub-flavored alert
blockquotes:
> [!NOTE]
> Background context that's useful but skippable.
> [!TIP]
> A shortcut or a sharper way of doing the thing above.
> [!IMPORTANT]
> Something the reader has to internalize before going further.
> [!WARNING]
> Footgun. Action causes mild damage.
> [!CAUTION]
> Footgun. Action causes severe damage. Data loss, broken state, etc.
Each renders as a panel with a colored left rule, a small uppercase label, and the body content. Mix any inline Markdown inside — links, code spans, even nested lists.
Like this one. The label disappears when the syntax matches; if it doesn't, you get a plain blockquote — handy for short pull-quotes.
Drop the [!TYPE] marker on its own line, then leave a blank
blockquote line before the body, if you want a clean visual break in
the source.
Use callouts sparingly — three per page is plenty. They're meant to interrupt the reader, so each one should earn the interruption.
Footnotes#
Use the standard Markdown footnote convention — a [^id] reference in the
prose and a matching [^id]: definition anywhere in the file:
Ovellum merges generated and hand-written docs in one file[^merge], and
ships a static-site builder for purely manual docs[^manual].
[^merge]: The merge engine keeps your protected zones intact on regeneration.
[^manual]: This very page is built that way.
The reference renders as a small superscript that links down to the note, and
each note carries a ↩ link back to where you were reading. The id is just a
label — use words ([^merge]) or numbers ([^1]), whichever reads better in
the source. Numbering follows the order references first appear in the page,
not the order the definitions are written, and all the notes collect into a
tinted panel at the foot of the page.
Here's that example rendered live1, with a second reference2 to show the numbering.
Adding the right-side ToC#
There's nothing to enable — the right column populates automatically from
every page's ## h2 and ### h3 headings. Each heading also gets a
clickable # anchor on hover, so readers can deep-link.
You don't need a single configuration line for any of this. Write Markdown, get a working ToC.
Static assets#
For the full picture — images, video/audio embeds, PDFs and other downloads — see the Assets & downloads guide. The essentials:
Anything in content/ that isn't a .md file passes through verbatim:
content/
images/
architecture.svg
screenshot.png
hello.md
Reference assets with relative paths in your Markdown:

After build:
dist/
images/
architecture.svg
screenshot.png
hello/index.html
Reserved public/ folder → site root#
The publicDir (default public) is a
reserved folder copied to the output root — the convention you know from
Next, Astro, Vite, and Hugo (static/). It's the place for files that must live
at the site root (favicon.ico, robots.txt, CNAME, OG images) plus any
other static assets:
content/
public/
favicon.ico → /favicon.ico
robots.txt → /robots.txt
fonts/Geist.ttf → /fonts/Geist.ttf
Nothing inside it is processed (no pages, no sidebar entries — even a .md is
copied as-is). Rename it via site.publicDir. (Static files outside public/,
like the images/ example above, still pass through keeping their own path.)
Landing page #
Manual mode ships an optional marketing-style homepage. The behavior
at / depends on a single flag:
site.landing.enabled: false(the default) —/is just a doc page. It renderscontent/index.mdwith the regular layout (sidebar, content, on-this-page ToC). Use this if you want readers to land straight in the documentation.site.landing.enabled: true—/becomes a marketing-style page: hero, feature grid, optional_landing.mdprose body, optional trust strip. The topbar grows a "Docs" link so readers always have a one-click path into the documentation proper, and the build ignorescontent/index.mdwith a warning (move that prose to_landing.mdor rename the file).
If you flip from landing-on to landing-off, rename _landing.md to
index.md (or write a fresh index.md) so / still has content.
When enabled, the config looks like this:
{
"site": {
"landing": {
"enabled": true,
"docsHref": "/getting-started/",
"hero": {
"title": "My project",
"subtitle": "What it does in one sentence.",
"ctas": [
{ "label": "Get started", "href": "/getting-started/" },
{ "label": "GitHub", "href": "https://github.com/me/proj", "style": "secondary" }
]
},
"features": [
{ "title": "Fast", "description": "Builds in seconds." },
{ "title": "Themed", "description": "Auto/light/dark out of the box." }
]
}
}
}
If you have a content/_landing.md file, its prose body renders between
the feature grid and the trust strip. Treat it as the "Why" section.
Full landing reference: config → site.landing.
404 page#
Every build ships a 404 page — you don't have to write one. If you
don't, Ovellum generates a default "Page not found" that matches your
theme (with a link home). To customize it, add a content/404.md file —
an ordinary Markdown page, write whatever you like (a short apology, a
link back home, a search prompt):
---
title: Page not found
---
# Page not found
That page doesn't exist. Head back to the [documentation](/).
It's treated as a special page, not a normal doc:
- It renders on a centered, narrower column with a larger heading, and the sidebar, on-this-page ToC, breadcrumbs, prev/next, and edit-this-page links are all hidden — it's a dead end, so it drops the navigation chrome.
- It's kept out of the reading flow: it never appears in the sidebar,
the
sitemap.xml, the RSS feed, or as a prev/next neighbor (so the first real page's "Previous" is empty, not the 404).
The build emits it as both dist/404/index.html (the pretty URL) and a
top-level dist/404.html. The second is the file most static hosts
(GitHub Pages, Netlify, Cloudflare, …) serve on missing URLs — so your
custom 404 triggers in production with no extra step. The dev server
(ovellum dev / ovellum serve) serves it for missing paths too, so it
behaves the same locally.
On a host served from a subpath (site.basePath), the 404 still works —
internal links inside it are prefixed like every other page.
Theme switching#
Three themes ship in the default template: auto (follow OS),
light, and dark. The topbar toggle cycles between them; the choice is
remembered in localStorage and applied before paint, so there's no theme
flash on subsequent loads.
If you want to ship a different default for first-time visitors, set
site.defaultTheme to light or dark. See
Theming for restyling beyond the defaults.
Static-site essentials#
The default template ships with sensible defaults for the things that matter:
- Light + dark themes from the same OKLCH palette.
- System fonts only — no
@font-face, no FOIT. - Build-time syntax highlighting via shiki; zero runtime JS for code.
- Pre-paint theme script (no flash on reload).
- Copy buttons injected client-side onto every code block.
- Responsive grid: sidebar drops first, then collapses on narrow viewports.
- Accessible: focus rings, semantic landmarks (
<header>,<main>,<aside>witharia-labels), and proper heading levels.
Everything is generated; nothing here is configurable for now. The theming guide covers what's customizable today.