Ovellum v0.25.0
English
Open in

Edited

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. A complete, runnable manual site lives in examples/ if you'd rather read working code.

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/. An index.md represents 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:

  1. The frontmatter title: field, if set.
  2. The first # H1 in the body, otherwise.
  3. 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 in dev, excluded from build).
  • 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"]
}
  • title overrides the (title-cased) folder name.
  • order is a list of slugs (filenames / subfolder names without .md); anything not listed sorts alphabetically after the explicit set.
  • collapse overrides the sidebar site.sidebar.collapse default for this folder: false keeps it always expanded, true always collapsed.
  • hidden: true drops 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 to input:

    { "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.

Note

Like this one. The label disappears when the syntax matches; if it doesn't, you get a plain blockquote — handy for short pull-quotes.

Tip

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.

Warning

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:

![Architecture](/images/architecture.svg)

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 renders content/index.md with 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.md prose 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 ignores content/index.md with a warning (move that prose to _landing.md or rename the file).
Tip

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.

Note

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> with aria-labels), and proper heading levels.

Everything is generated; nothing here is configurable for now. The theming guide covers what's customizable today.

Footnotes

  1. The merge engine keeps your protected zones intact on regeneration. ↩

  2. This very page is built in manual mode. ↩

Edit this page