What Otto is
An AsciiDoc-powered static site generator with Jekyll-style conventions: layouts, includes, data files, posts, drafts, permalinks, and custom collections.
-
AsciiDoc
Otto uses AsciiDoc markup to generate HTML.
-
Jekyll-style conventions
Layouts, includes, data files, posts, drafts, permalinks, and custom collections.
-
Built-in server
otto serveserves_build/on port 8778.otto watchrebuilds when a file changes.
Install
One gem, one executable. The gem is ottogen; the command it installs is otto.
gem install ottogen
Otto requires Ruby 3.0 or newer.
Quickstart
Five lines take you from an empty directory to a site in a browser.
gem install ottogen
mkdir mysite && cd mysite
otto init
otto build
otto serve
open http://127.0.0.1:8778/
Open http://127.0.0.1:8778/. You should see the welcome page,
wrapped in the default layout.
Commands
The whole surface of the CLI, in nine lines.
| Command | Description |
|---|---|
otto init [DIR] | Scaffold a new site (current dir if omitted) |
otto build | Render the site to _build/ |
otto build --drafts | Include posts from _drafts/ |
otto watch | Rebuild on file change |
otto serve | Serve _build/ on port 8778 |
otto generate PAGE | Create a new page in pages/ |
otto post "Title" | Create a new dated post in _posts/ |
otto clean | Delete _build/ |
otto doctor | Sanity-check project layout |
- Creates files:
init,generate,post - Renders:
build,watch,serve - Deletes
_build/:clean
Project layout
otto init writes this tree. Each directory has one job, and the
leading underscore marks the ones Otto reads rather than copies.
my-site/
├── .otto # marker
├── config.yml # site config
├── assets/ # copied verbatim into _build/
├── pages/ # AsciiDoc pages, output mirrors path
├── _layouts/ # ERB layouts (.html.erb)
├── _includes/ # ERB partials
├── _data/ # YAML/JSON files exposed as site.data.*
├── _posts/ # YYYY-MM-DD-slug.adoc
└── _drafts/ # undated drafts (excluded by default)
Configuration
Site-wide settings live in config.yml at the project root.
title: My Otto Site
description: Things I write
url: https://example.com
baseurl: ""
permalink: /:year/:month/:day/:slug/
collections:
recipes:
output: true
The permalink setting accepts five tokens: :year,
:month, :day, :slug, and :title.
If a template ends in /, Otto writes <path>/index.html
and you get a pretty URL.
Pages and posts
Both are AsciiDoc files with YAML front matter, and both go through the same rendering pipeline. The difference is where they live and how you name them.
Authoring a page
Pages live under pages/. The output path mirrors the source path,
so pages/about.adoc becomes /about.html.
---
layout: default
title: About
---
= About
This is the about page.
The --- block is YAML front matter, and its values reach you two ways:
- In the AsciiDoc body
{page_title},{page_url},{site_title}, and the rest.- In an ERB layout
<%= page.title %>,<%= site.title %>.
Authoring a post
Run otto post "My First Post" to scaffold a post. It creates
_posts/YYYY-MM-DD-my-first-post.adoc and fills in the front matter for you.
Posts share the page pipeline, and they add three behaviors:
- Otto derives the date and the slug from the filename.
- Every post appears in
site.posts, sorted by date descending. -
tags:andcategories:in front matter feedsite.tagsandsite.categories, which are hashes keyed by tag or category.
Layouts and includes
A layout wraps rendered AsciiDoc in HTML. A partial is a fragment you pull into a layout. Both are ERB.
Layouts
Layouts live in _layouts/. The default scaffold gives you
_layouts/default.html.erb.
<!DOCTYPE html>
<html>
<head><title><%= page.title %></title></head>
<body><%= content %></body>
</html>
Every layout is an ERB template with three locals:
- content
- The rendered AsciiDoc body of the page or post.
- page
- The page or post object, carrying its front matter, its URL, and so on.
- site
- The site config and data, including the title, the posts, and the collections.
Layouts can chain, because a layout may declare front matter of its own:
---
layout: default
---
<article><%= content %></article>
So Otto wraps a page using layout: post in
_layouts/post.html.erb, then wraps that result in
_layouts/default.html.erb.
Includes (partials)
Drop reusable HTML or ERB into _includes/. Then call
<%= partial 'name.html' %> from any layout, or from another partial.
_includes/
header.html
footer.html
Partials see the same site, page, and content as the calling layout.
Data files
Otto exposes anything in _data/ as site.data.<filename>.
- title: Home
url: /
- title: About
url: /about
<nav>
<% site.data.nav.each do |item| %>
<a href="<%= item['url'] %>"><%= item['title'] %></a>
<% end %>
</nav>
Otto reads both YAML and JSON.
Collections
A collection is a group of documents that is neither a page nor a post. Declare
it in config.yml, then create the matching directory.
collections:
recipes:
output: true
Files in _recipes/*.adoc become site.recipes, an array of items.
With output: true they render to /recipes/<slug>.html.
With output: false they are available to layouts, but Otto does not write
them to disk.
Permalinks
Set a URL on one document, or a default for every post.
To customize one document, put permalink in its front matter:
---
permalink: /custom/path.html
---
To set a default for every post, put it in config.yml:
permalink: /:year/:month/:day/:slug/
A template ending in / produces a pretty URL, because Otto writes
<path>/index.html instead of a bare .html file.
Five tokens are available: :year, :month, :day,
:slug, and :title.
Drafts
Drafts live in _drafts/<slug>.adoc, with no date prefix on the filename.
Otto excludes drafts from a build. Pass --drafts to include them.
otto build --drafts
otto watch --drafts
When you include them, drafts take today's date, which sorts them to the top of
site.posts.
Health checks
One command tells you whether the project layout is intact.
otto doctor
otto doctor verifies the project layout and reports any missing files.
AsciiDoc primer
A working subset of AsciiDoc, enough to be productive. The full reference is at docs.asciidoctor.org.
Document structure
= Document title
:author: Ada Lovelace
:revdate: 2026-05-01
== Section 1
A paragraph.
== Section 2
Another paragraph.
= Title is the document title, and ==, ===,
and deeper are subsections. An attribute below the title, in the form
:key: value, sets a document-wide variable.
Paragraphs and text formatting
A normal paragraph. *Bold*, _italic_, `monospace`, ~subscript~, ^superscript^.
A second paragraph separated by a blank line.
Links
https://example.com[Example]
link:about.html[About this site]
Lists
* unordered
* bullet
** nested
. ordered
. one
. two
term:: definition
another term:: another definition
Code blocks
[source,ruby]
----
def hello
puts "world"
end
----
Admonitions
NOTE: A short note in line.
[WARNING]
====
A longer warning block
that spans multiple lines.
====
Five levels are available: NOTE, TIP,
IMPORTANT, WARNING, and CAUTION.
Tables
|===
| Column 1 | Column 2
| cell A1 | cell A2
| cell B1 | cell B2
|===
Images
image::pictures/cat.jpg[A cat, 400, 300]
The first argument is alt text, and the numbers are width and height.
Includes
include::shared/disclaimer.adoc[]
An AsciiDoc include pulls file content in at conversion time. Paths are relative to the including file.
Attributes in content
Reference site and page metadata anywhere in the body:
This is {site_title}, written by {site_author}.
You're reading "{page_title}" — see all posts at {site_url}/blog.
Otto prefixes these keys with site_, for values from
config.yml, or page_, for values from the document's own
front matter.
IDs and roles
[#main-section,role="lead"]
== Hello
That adds id="main-section" and class="lead" to the
rendered section.