On this page
View as Markdown

site

Configure and build a documentation website

A Site combines pages, shared settings, and optional generated files. Use Site.from_dir to read Markdown, notebooks, and octavo.yml from a source directory. Pass keyword arguments to override file settings.

Page settings inherit from the site and ancestor directory indexes. A page’s own frontmatter takes precedence. Header navigation comes from nav-links; sidebar navigation comes from source directories, or from sidebar-links when set.

Site.build writes HTML and Markdown pages, assets, redirects, and a Pagefind search index. It also writes a sitemap and robots.txt when site-url is configured. Every other visible file in the source directory is copied as an asset; list glob patterns in the exclude setting to leave paths out of the site. Links between pages are relative and resolve separately for HTML and Markdown output.

octavo.site turns a directory of markdown files and notebooks into a static website, with navigation, search, and a markdown version of each page. It combines the documents read by octavo.pages with the rendering in octavo.core and the layout and assets in octavo.theme.

Use Site.from_dir to read markdown files, notebooks, and settings from octavo.yml, then build to write the site.

site = Site.from_dir('docs', title='My project')
missing = site.build(out='_site')  # writes to docs/_site

Keyword settings passed to from_dir override octavo.yml. If you already have Page objects, use Site(pages, **settings) instead.

The sidebar follows the source directories. Content links can refer to source files, such as api/01_config.ipynb; the build converts them to the corresponding HTML or markdown destinations. build returns a dictionary of pages with unresolved document links rather than failing on those links.

Relative output paths are resolved from the source directory. Each build deletes the existing output directory before writing, so use a directory containing only generated files. The result can be deployed to static hosting; this module does not serve or watch the site.

Settings

Settings follow Quarto's names and meanings. Write them with hyphens in octavo.yml and frontmatter, as Quarto does. Python code uses underscores, because snake_keys converts the keys as Octavo reads them: toc-depth in a file is toc_depth here.

DEFAULTS lists the settings a page can inherit, with the values used when neither the site nor the page sets them:

Page settings

Create a Site from Page objects and shared settings, or use Site.from_dir to read them from a source directory. When constructing a site directly, pass root to copy assets from that directory and resolve relative output paths against it. Without root, no source assets are copied and relative output paths use the current working directory.

Only the settings listed in DEFAULTS inherit from the site and directory indexes to individual pages. Octavo ignores settings it doesn't read, but build warns about unrecognised site settings, such as a misspelt toc-dept. unknown_keys lists them.


Site

def Site(
    pages:list, # The site's `Page`s
    root:str=None, # Source directory for copying assets and resolving relative output paths
    files:list[octavo.pages.GeneratedFile]=None, # to write verbatim to the output
    **settings
):

A collection of pages, generated files, and site settings

A directory’s index page can set defaults for pages below it. Resolving those settings requires the directory indexes and a list of each page’s ancestors.

opts resolves page settings in this order: built-in defaults, site settings, ancestor directory indexes from shallowest to deepest, then the page’s own frontmatter. Later values override earlier ones. Only keys in DEFAULTS are inherited. Page-specific metadata such as title and description stays on its original page.


Site.opts

def opts(
    page, # Page to resolve settings for
):

Settings for page: defaults < site < directory indexes < page frontmatter

This example site has a homepage, a getting-started page, and tutorial, explanation, and API directories. The site-wide table of contents depth is 2.

config     = Page('api/01_config.md',           '---\ntitle: config\norder: 1\n---\n')
directives = Page('explanations/directives.md', '---\ntitle: Directives\norder: 2\ntoc-depth: 1\n---\n')

pages = [Page('index.md',                 '---\ntitle: nbdev\nsidebar: false\n---\n'),
         Page('getting_started.md',       '---\ntitle: Getting Started\n---\n'),
         Page('tutorials/index.md',       '---\ntitle: Tutorials\norder: 2\n---\n'),
         Page('tutorials/tutorial.md',    '---\ntitle: End-To-End Walkthrough\norder: 1\n---\n'),
         Page('explanations/index.md',    '---\ntitle: Explanations\norder: 3\n---\n'),
         directives,
         Page('api/index.md',             '---\ntitle: API\norder: 9\n---\n'),
         config,
         Page('api/02_maker.md',          '---\ntitle: maker\norder: 2\n---\n')]
site = Site(pages, title='nbdev', toc_depth=2)
site
Site(9 pages)

The config page does not set toc-depth, so it inherits 2 from the site.

site.opts(config)
{ 'css': None,
  'js': None,
  'math': 'brackets',
  'page_layout': None,
  'pagetitle': None,
  'sidebar': True,
  'toc': True,
  'toc_depth': 2}

A page's frontmatter overrides site settings for that page. directives.md sets toc-depth: 1, which Python code reads as toc_depth.

test_eq(site.opts(directives).toc_depth, 1)
test_eq(site.opts(config).toc_depth, 2)
site.opts(directives)
{ 'css': None,
  'js': None,
  'math': 'brackets',
  'page_layout': None,
  'pagetitle': None,
  'sidebar': True,
  'toc': True,
  'toc_depth': 1}

Without a configured toc-depth, a page uses Quarto's default of 3.

plain = Site(pages)
test_eq(plain.opts(config).toc_depth, 3)
plain.opts(config)
{ 'css': None,
  'js': None,
  'math': 'brackets',
  'page_layout': None,
  'pagetitle': None,
  'sidebar': True,
  'toc': True,
  'toc_depth': 3}

The site title and page title remain separate. Neither is returned by opts, because title is not an inherited page setting.

test_eq(site.opts(config).get('title'), None)
site.settings.title, config.title
('nbdev', 'config')

The page tree is derived from the source directories. Each directory's index page supplies its title and order. A directory without an index uses its directory name. Root pages precede directory sections.

sidebar: false hides a page's sidebar and excludes it from the tree. On a directory index, it applies to the directory's pages through the settings cascade. Primary navigation remains in the header. A page can appear in both the header and sidebar.

A section's heading links to its index page when one exists. The disclosure marker expands or collapses its links. All sidebar sections start open.


Section

def Section(
    dir:str, # Directory the pages sit in, relative to the source root
    index:octavo.pages.Page, # Its `index` page, where it has one
    pages:list, # Its other pages
):

A directory of pages, headed by its own index page


Site.sections

def sections():

The site's root pages and directory sections in the sidebar

The homepage sets sidebar: false and is omitted from the tree. Getting Started appears before the directory sections because root pages are sorted separately. The directory indexes set the order of Tutorials, Explanations, and API.

test_eq([o.title for o in site.sections], ['Getting Started','Tutorials','Explanations','API'])
site.sections
[getting_started.md -> getting_started.html, Tutorials -> tutorials/index.html (1 pages), Explanations -> explanations/index.html (1 pages), API -> api/index.html (2 pages)]

A relative link gives directions from the current page's folder. For example, from api/config.html:

These directions stay the same whether the site is hosted at example.com/ or example.com/myproject/. relhref(src, dst) calculates this relative link from the two pages' paths within the site.


relhref

def relhref(
    src:str, dst:str
):

Link to page dst from a page at src, both given relative to the site root

From api/config.html, api/maker.html becomes maker.html. A tutorial in another directory requires a ../tutorials/ prefix.

test_eq(relhref('api/config.html', 'api/maker.html'), 'maker.html')
test_eq(relhref('index.html', 'api/config.html'), 'api/config.html')
relhref('api/config.html', 'tutorials/tutorial.html')
'../tutorials/tutorial.html'

navigation renders the derived page tree in the sidebar. Links are relative to the current page. The current page has aria-current="page". Groups use native details and summary elements.


Site.navigation

def navigation(
    page:octavo.pages.Page, # Page whose navigation this is
):

The sidebar as native navigation: sidebar_links when set, otherwise the derived document tree

The config page’s navigation marks its current entry with aria-current="page". Links within api/ use local filenames; tutorial links use ../tutorials/.

bar = site.navigation(config)
assert 'aria-current="page"' in bar
assert 'href="../index.html"' not in bar
assert '<a href="../tutorials/tutorial.html">' in bar
assert '<a href="maker.html">' in bar
assert '<details open><summary><a href="index.html">API</a></summary>' in bar
assert '<details open><summary><a href="../tutorials/index.html">Tutorials</a></summary>' in bar
assert '>Overview</a>' not in bar
bar
'<nav aria-label="Site"><ul><li><a href="../getting_started.html">Getting Started</a></li><li><details open><summary><a href="../tutorials/index.html">Tutorials</a></summary><ul><li><a href="../tutorials/tutorial.html">End-To-End Walkthrough</a></li></ul></details></li><li><details open><summary><a href="../explanations/index.html">Explanations</a></summary><ul><li><a href="../explanations/directives.html">Directives</a></li></ul></details></li><li><details open><summary><a href="index.html">API</a></summary><ul><li><a href="config.html" aria-current="page">config</a></li><li><a href="maker.html">maker</a></li></ul></details></li></ul></nav>'

A project can list its sidebar instead, in the sidebar-links setting. Items use the format of nav-links, described under the site header below. When sidebar-links is set, the sidebar shows exactly these items in this order, in place of the tree derived from directories. An item without text takes the title of the page at its href. This suits a hand-written sidebar, such as a Quarto sidebar.yml that groups pages from one directory into sections:

listed = Site(pages, sidebar_links=[dict(href='getting_started.html'),
    dict(text='Reference', children=[dict(href='api/config.html'), dict(href='explanations/directives.html')])])
bar = listed.navigation(config)
assert '<details open><summary>Reference</summary>' in bar
assert '<a href="config.html" aria-current="page">config</a>' in bar
assert 'tutorial.html' not in bar
bar
'<nav aria-label="Site"><ul><li><a href="../getting_started.html">Getting Started</a></li><li><details open><summary>Reference</summary><ul><li><a href="config.html" aria-current="page">config</a></li><li><a href="../explanations/directives.html">Directives</a></li></ul></details></li></ul></nav>'

Loading site configuration

Site.from_dir('docs') reads markdown files and notebooks under docs/ and loads settings from docs/octavo.yml if it exists. Pass keyword arguments to override individual settings without editing the file—for example, Site.from_dir('docs', toc_depth=1) overrides toc_depth from the YAML configuration.


Site.from_dir

def from_dir(
    root:str, # Directory of source documents
    nbproc:NoneType=None, # Optional callable applied to notebooks before conversion
    **settings
):

Read a site from root, with keyword settings overriding octavo.yml


read_cfg

def read_cfg(
    root:str
):

Settings from octavo.yml, through snake_keys; empty when the file is absent or empty

This source directory sets the site title, table of contents depth, output directory, and favicon in octavo.yml. Its exclude setting keeps the drafts directory out of the site: its documents are not pages, and its other files are not copied. See source_files for the pattern rules.

root = Path(tempfile.mkdtemp())
(root/'octavo.yml').write_text('title: nbdev\ntoc-depth: 2\noutput: _site\nfavicon: favicon.png\nexclude: [drafts]\n')
(root/'index.md').write_text(r'''---
title: Home
---

A_name starts with [config](api/01_config.md).

```python
items = ["a_b"]
```

\[x_1 + y_2\]
''')
(root/'api').mkdir()
(root/'api/01_config.md').write_text('---\ntitle: config\naliases:\n  - /configuration.html\n---\n\n## Reading\n\nWhere it looks.\n\n### Search order\n\nWhich file wins.\n')
(root/'images').mkdir()
(root/'images/logo.svg').write_text('<svg/>')
(root/'drafts').mkdir()
(root/'drafts/plan.md').write_text('# Plan\n')
(root/'drafts/notes.txt').write_text('notes\n')

built = Site.from_dir(root)
test_eq(built.pages.attrgot('rel'), ['api/01_config.md', 'index.md'])
test_eq(built.settings.title, 'nbdev')
test_eq(built.opts(built.pages[0]).toc_depth, 2)
built.settings
{ 'exclude': ['drafts'],
  'favicon': 'favicon.png',
  'output': '_site',
  'title': 'nbdev',
  'toc_depth': 2}

Passing toc_depth=1 overrides the file’s value of 2 while retaining its title.

over = Site.from_dir(root, toc_depth=1)
test_eq(over.settings.toc_depth, 1)
test_eq(over.settings.title, 'nbdev')
over.settings
{ 'exclude': ['drafts'],
  'favicon': 'favicon.png',
  'output': '_site',
  'title': 'nbdev',
  'toc_depth': 1}

octavo.yml holds a mapping of settings. An empty file gives none, and a list or a single value is an error:

bad = Path(tempfile.mkdtemp())
(bad/'octavo.yml').write_text('- title: nbdev\n')
with expect_fail(ValueError, regex='mapping'): read_cfg(bad)
(bad/'octavo.yml').write_text('')
test_eq(read_cfg(bad), {})

Use nav-links in octavo.yml to add links and dropdown groups to the site header:

nav-links:
  - text: Get started
    href: getting_started.html
  - text: Help
    children:
      - text: Report an Issue
        href: https://github.com/fastai/nbdev/issues

Each item's text is its visible label. Add href for a link or children for a group; an item can have both, and groups can be nested.

For internal links, use the generated HTML path relative to the site root, such as api/config.html, not the source path api/01_config.ipynb. Octavo makes these links relative to the current page. External URLs, mailto: links, and same-page anchors are left unchanged.


Site.header

def header(
    page:octavo.pages.Page, # Page whose header this is
):

The site brand, primary navigation, icon links, and search control


Site.tools

def tools(
    page:octavo.pages.Page, # Page whose tools these are
):

The site's declared icon links followed by its search control


Site.home

def home():

The site's root index page, where it has one

Header navigation can contain nested groups. Hiding a page’s sidebar does not remove its header links.

cfg = dict(title='nbdev', nav_links=[dict(text='Get Started', href='getting_started.html'),
    dict(text='Help', children=[
        dict(text='Report an Issue', href='https://github.com/fastai/nbdev/issues'),
        dict(text='Using nbdev', children=[dict(text='Start here', href='getting_started.html')])])])
navsite = Site(pages, **cfg)
sidebar = navsite.navigation(config)
assert 'getting_started.html' in sidebar and 'Help' not in sidebar
bar = navsite.header(config)
assert '<a href="../getting_started.html">Get Started</a>' in bar
assert '<a href="https://github.com/fastai/nbdev/issues">' in bar
assert '<summary>Help</summary>' in bar and '<summary>Using nbdev</summary>' in bar
excluded = Page('private.md', '---\ntitle: Private\nsidebar: false\n---\n')
private_site = Site(pages+[excluded], **cfg)
assert 'Private' not in private_site.navigation(config)
assert not private_site.navigation(excluded)
assert 'Get Started' in private_site.header(excluded)
bar
'<header class="site-header"><a href="../index.html">nbdev</a><details class="header-nav" open><summary>Menu</summary><nav aria-label="Primary"><ul><li><a href="../getting_started.html">Get Started</a></li><li><details><summary>Help</summary><ul><li><a href="https://github.com/fastai/nbdev/issues">Report an Issue</a></li><li><details><summary>Using nbdev</summary><ul><li><a href="../getting_started.html">Start here</a></li></ul></details></li></ul></details></li></ul></nav></details><div class="site-tools"><pagefind-modal-trigger compact></pagefind-modal-trigger></div></header>'

The site header contains its name, primary navigation, icon links, and search trigger. Its name links to the home page when one exists. The search dialog belongs to the page body.

bar = Site(pages, title='nbdev').header(config)
assert '<a href="../index.html">nbdev</a>' in bar
assert '<pagefind-modal-trigger compact>' in bar
assert '<pagefind-modal>' not in bar
Site([config], title='nbdev').header(config)
'<header class="site-header"><span>nbdev</span><div class="site-tools"><pagefind-modal-trigger compact></pagefind-modal-trigger></div></header>'

tools declares the icon links beside search in the site header:

tools:
  - icon: github
    href: https://github.com/fastai/nbdev
    label: GitHub
  - icon: images/forum.svg
    href: https://forums.fast.ai
    label: Forum

Bundled ICONS inherit the header's text colour. Paths and URLs render as images. Each link's label provides its accessible name and tooltip. The icon name is the default label.

toolsite = Site(pages, title='nbdev', tools=[dict(icon='github', href='https://github.com/fastai/nbdev', label='GitHub'),
                                             dict(icon='images/forum.svg', href='https://forums.fast.ai', label='Forum')])
bar = toolsite.header(config)
assert 'aria-label="GitHub"' in bar and '<img src="../images/forum.svg"' in bar
assert bar.index('aria-label="GitHub"') < bar.index('<pagefind-modal-trigger compact>')
toolsite.tools(config)
'<div class="site-tools"><a href="https://github.com/fastai/nbdev" aria-label="GitHub" title="GitHub"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg></a><a href="https://forums.fast.ai" aria-label="Forum" title="Forum"><img src="../images/forum.svg" alt=""></a><pagefind-modal-trigger compact></pagefind-modal-trigger></div>'

An unknown icon name raises KeyError. Use a bundled name or an image path or URL.

with expect_fail(KeyError, regex='gitlab'): Site(pages, tools=[dict(icon='gitlab', href='https://gitlab.com')]).tools(config)

Links between pages can use either the source path or the generated HTML path. For example, these refer to the same page:

[Maker](/api/02_maker.ipynb#usage)
[Maker](/api/maker.html#usage)

Octavo identifies the target page and chooses the link for each output format: HTML output links to maker.html#usage, while markdown output links to maker.md#usage. Writing .html in a local link does not restrict it to HTML output.

Paths starting with / are relative to the source root. Other paths are relative to the current source file’s directory. A directory path such as /tutorials links to that directory’s index page.

Full URLs, such as https://example.com/api/maker.html, stay unchanged.


Site.by_path

def by_path():

Map extensionless source and output paths, including index directories, to pages

Document extensions are removed during lookup. Asset extensions are retained to distinguish, for example, docs.svg from docs.md.

External URLs, mail addresses, and same-page anchors remain unchanged. Unresolved document links are recorded in missing. Relative unresolved links retain their original address. Root-relative paths are made relative to the current page, including paths to images and downloads.


Linker

def Linker(
    site, # The `Site` being built
    page:octavo.pages.Page, # Page the addresses sit on
    ext:str='html', # Extension of the output being written
):

Resolve page links for an output format and collect unresolved document addresses in missing.


Site.linker

def linker(
    page:octavo.pages.Page, # Page containing the links
    ext:str='html', # Extension of the output being written
):

A Linker for page

Source filenames, generated filenames, and directory paths resolve to the same pages. URL fragments such as #usage are retained.

Here's an example for a site with the following file tree:

.
├── index.md
├── getting_started.md
├── tutorials/
│   ├── index.md
│   └── tutorial.md
├── explanations/
│   ├── index.md
│   └── directives.md
└── api/
    ├── index.md
    ├── 01_config.md       ← current page
    └── 02_maker.md
lk = site.linker(config)
test_eq(lk('02_maker.ipynb'), 'maker.html')
test_eq(lk('/api/02_maker.ipynb#usage'), 'maker.html#usage')
test_eq(lk('/tutorials'), '../tutorials/index.html')

/explanations/directives.svg remains an image link even though directives.md is a page. Root-relative asset paths become relative to the current page.

test_eq(lk('/explanations/directives.svg'), '../explanations/directives.svg')
{a: lk(a) for a in ['/images/logo.svg', '/explanations/directives.svg']}
{'/images/logo.svg': '../images/logo.svg',
 '/explanations/directives.svg': '../explanations/directives.svg'}

gone.ipynb names no known page. It is recorded in missing and left unchanged. External URLs and same-page anchors are not recorded as missing documents.

test_eq(lk('https://fast.ai'), None)
test_eq(lk('#top'), None)
test_eq(lk('gone.ipynb'), None)
test_eq(lk.missing, ['gone.ipynb'])
lk
Linker(api/config.html -> .html, 1 missing)

For Markdown output, the same page lookup produces .md destinations. Asset filenames do not change.

mdlk = site.linker(config, ext='md')
test_eq(mdlk('02_maker.ipynb'), 'maker.md')
test_eq(mdlk('/images/logo.svg'), '../images/logo.svg')
{a: mdlk(a) for a in ['02_maker.ipynb', '/tutorials', '/images/logo.svg']}
{'02_maker.ipynb': 'maker.md',
 '/tutorials': '../tutorials/index.md',
 '/images/logo.svg': '../images/logo.svg'}

Local document addresses resolve separately for HTML and Markdown, preserving queries and fragments. Absolute URLs express an explicit destination and stay unchanged, even when they point to the configured production site.

published = Site(pages, site_url='https://nbdev.fast.ai/docs/')
for ext in ['html', 'md']:
    local_link = published.linker(config, ext=ext)
    test_eq(local_link('/api/config.html?view=full#get_config'), f'config.{ext}?view=full#get_config')
    test_eq(local_link('/?view=full#intro'), f'../index.{ext}?view=full#intro')
    for addr in ['https://nbdev.fast.ai/docs/api/config.html#get_config',
                 'https://nbdev.fast.ai/docs/', 'https://pypi.org/project/nbdev/',
                 '//nbdev.fast.ai/docs/api/config.html', 'mailto:info@example.org']:
        test_eq(local_link(addr), None)
    test_eq(local_link.missing, [])

Redirects

If you move tutorial.md to tutorials/tutorial.md, existing links to tutorial.html would break. For backward compatibility you can add its old address to the new page’s frontmatter:

aliases:
  - /tutorial.html

The build writes a redirect at tutorial.html that sends visitors to tutorials/tutorial.html.

Aliases are paths relative to the site root, not full URLs. You can omit the .html extension: /tutorial and /tutorial.html create the same redirect file.


Site.redirects

def redirects():

Map alias output paths to current page destinations


alias_path

def alias_path(
    a:str
):

The file an alias a names, relative to the site root

The tutorial now lives at tutorials/tutorial.html. Its alias preserves the old /tutorial.html URL.

moved = Page('tutorials/tutorial.md', '---\ntitle: End-To-End Walkthrough\naliases:\n  - /tutorial.html\n---\n')
test_eq(Site([moved]).redirects, {'tutorial.html': 'tutorials/tutorial.html'})
Site([moved]).redirects
{'tutorial.html': 'tutorials/tutorial.html'}

A redirect page uses a meta refresh to open the new destination without JavaScript. It also includes a canonical link and a visible link. The destination is relative to the redirect page, allowing deployment below a project path.


redirect_html

def redirect_html(
    src:str, dst:str
):

A stub page at src that sends a reader to dst, both relative to the site root

The redirect at tutorial.html points to tutorials/tutorial.html.

stub = redirect_html('tutorial.html', 'tutorials/tutorial.html')
assert 'url=tutorials/tutorial.html' in stub
print(stub)
<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<title>Redirected</title>
<link rel="canonical" href="tutorials/tutorial.html">
<meta http-equiv="refresh" content="0; url=tutorials/tutorial.html">
</head>
<body><p>This page has moved to <a href="tutorials/tutorial.html">tutorials/tutorial.html</a>.</p></body></html>

The redirect at old/guides/tutorial.html points to ../../tutorials/tutorial.html.

deep = redirect_html('old/guides/tutorial.html', 'tutorials/tutorial.html')
assert 'href="../../tutorials/tutorial.html"' in deep
{a: alias_path(a) for a in ['/tutorial', '/tutorial.html', '/old/guides/', '/']}
{'/tutorial': 'tutorial.html',
 '/tutorial.html': 'tutorial.html',
 '/old/guides/': 'old/guides.html',
 '/': 'index.html'}

Sitemap

A sitemap helps search engines discover your published pages. To generate one, set the site's public address in octavo.yml:

site-url: https://example.com/docs

For a page written to api/config.html, the generated sitemap.xml contains https://example.com/docs/api/config.html. Include any deployment subdirectory, such as /docs, in site-url.

The build also writes a robots.txt that names the sitemap, as Quarto does, unless the source directory supplies its own. Crawlers read robots.txt only at the root of a host, so it has an effect only when the site is served from a domain root.


Site.robots

def robots():

Return robots.txt text naming the sitemap, or an empty string when site_url is unset


Site.sitemap

def sitemap():

Return sitemap XML, or an empty string when site_url is unset

With site_url="https://nbdev.fast.ai", the config page is listed as https://nbdev.fast.ai/api/config.html.

mapped = Site(pages, title='nbdev', site_url='https://nbdev.fast.ai')
assert '<loc>https://nbdev.fast.ai/api/config.html</loc>' in mapped.sitemap()
test_eq(mapped.robots(), 'Sitemap: https://nbdev.fast.ai/sitemap.xml\n')
print(mapped.sitemap()[:300])
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url><loc>https://nbdev.fast.ai/index.html</loc></url>
  <url><loc>https://nbdev.fast.ai/getting_started.html</loc></url>
  <url><loc>https://nbdev.fast.ai/tutorials/index.html</loc></url>
  <url><l

Without site_url, sitemap and robots return empty strings, and the build writes neither file.

quiet = Site(pages, title='nbdev')
test_eq((quiet.sitemap(), quiet.robots()), ('', ''))
quiet.settings
{'title': 'nbdev'}

Listings and Feeds

A listing adds links to other pages using their frontmatter titles and descriptions. Configure it under listing in the frontmatter of the page where it should appear—not in octavo.yml:

listing:
  contents: posts
  type: cards
  sort: date desc
  feed: true

Each setting controls a separate choice:

Use listing: {} to enable a listing with all defaults. The build appends it after the page’s own content in both HTML and markdown output, with links adjusted for each format.


Site.render_listing

def render_listing(
    page:octavo.pages.Page
):

The page's listing as Markdown, ready for HTML and GFM


Site.categories_nav

def categories_nav(
    page:octavo.pages.Page
):

Render category filters and post counts for a listing page


Site.rss_feed

def rss_feed(
    page:octavo.pages.Page
):

RSS 2.0 feed XML for a listing page


Site.listing_pages

def listing_pages(
    page:octavo.pages.Page, sort:str=None
):

The selected pages in listing order, unless overridden

This example site has two sections: an API section with an index and one documentation page, and a blog section with an index and two posts.

The indexes configure different listings:

The blog index also sets sidebar: false. Its posts inherit that setting, so neither the blog index nor its posts appear in the sidebar navigation. The API section keeps the default, sidebar: true.

Sidebar visibility and listing selection are independent: hiding the blog section from navigation does not exclude its posts from the blog listing.

p_api_idx = Page('api/index.md', '---\ntitle: API\nlisting:\n  type: table\n---\n')
p_api_fn = Page('api/01_core.md', '---\ntitle: Core [API]\ndescription: The `core` | functions\n---\n')
p_blog_idx = Page('blog/index.md', '---\ntitle: Blog\nsidebar: false\nlisting:\n  contents: posts\n  sort: "date desc"\n  feed: true\n---\n')
p_post1 = Page('blog/posts/2022-07-28-v2/index.md', '---\ntitle: Release v2\norder: 0\ndate: "2022-07-28"\nauthor: [Hamel Husain, Jeremy Howard]\ndescription: Announcing v2\nimage: /images/card.png\ncategories: [release, "Hugging Face"]\n---\n')
p_post2 = Page('blog/posts/2022-08-25-git/index.md', '---\ntitle: Git Friendly\ndate: "2022-08-25"\nauthor: Jeremy Howard\ndescription: Solving [git] conflicts with `git` | nbdev\nimage: cover.png\ncategories: [technical]\n---\n')

test_site = Site([p_api_idx, p_api_fn, p_blog_idx, p_post1, p_post2], title='TestSite', site_url='https://example.com')

test_eq(test_site.opts(p_api_idx).sidebar, True)
test_eq(test_site.opts(p_blog_idx).sidebar, False)
test_eq(test_site.opts(p_post1).sidebar, False)
test_eq(test_site.opts(p_post2).sidebar, False)

assert not test_site.navigation(p_blog_idx)  # blog has no sidebar
assert 'aria-label="Site"' in test_site.navigation(p_api_idx)  # api has sidebar
assert 'Blog' not in [o.title for o in test_site.sections]  # blog does not appear as section

The table preserves brackets in its title, a literal pipe in its description, and inline code formatting. Its child link resolves to HTML or Markdown according to the output.

api_doc = md2doc(test_site.render_listing(p_api_idx))
api_html = str(api_doc.to_html(link=test_site.linker(p_api_idx)))
api_gfm = api_doc.to_gfm(link=test_site.linker(p_api_idx, 'md'))
assert 'core.md' in api_gfm and 'Core [API]' in api_gfm
assert r'The `core` \| functions' in api_gfm
assert '<table>' in api_html and 'href="core.html"' in api_html
assert 'Core [API]' in api_html and 'The <code>core</code> | functions' in api_html
assert api_html.count('<td>') == 2
print(api_gfm)
| Title | Description |
| --- | --- |
| [Core [API]](<core.md>) | The `core` \| functions |
print(api_gfm)
| Title | Description |
| --- | --- |
| [Core [API]](<core.md>) | The `core` \| functions |

Blog cards retain their metadata and newest-first order in both outputs. HTML keeps the card classes and category data; GFM contains ordinary headings, links, paragraphs, and images. Image paths are resolved from each post, then made relative to the index.

blog_doc = md2doc(test_site.render_listing(p_blog_idx))
blog_html = str(blog_doc.to_html(link=test_site.linker(p_blog_idx)))
blog_gfm = blog_doc.to_gfm(link=test_site.linker(p_blog_idx, 'md'))
assert 'class="listing-card"' in blog_html and 'data-categories="|release|hugging face|"' in blog_html
for rendered in [blog_html, blog_gfm]:
    assert 'Git Friendly' in rendered and 'Hamel Husain, Jeremy Howard' in rendered
    assert 'Aug 25, 2022' in rendered and 'Jul 28, 2022' in rendered
    assert 'posts/2022-08-25-git/cover.png' in rendered and '../images/card.png' in rendered
    assert rendered.index('Git Friendly') < rendered.index('Release v2')
assert 'posts/2022-08-25-git/index.html' in blog_html
assert 'posts/2022-08-25-git/index.md' in blog_gfm
assert ':::' not in blog_gfm and 'data-categories' not in blog_gfm
assert '## [Git Friendly]' in blog_gfm and 'technical' in blog_gfm
assert 'class="listing-category"' in blog_html and '{.listing-' not in blog_gfm and '{:' not in blog_gfm
assert 'Solving [git] conflicts with `git` | nbdev' in blog_gfm
print(blog_gfm)
## [Git Friendly](<posts/2022-08-25-git/index.md>)

Aug 25, 2022 · Jeremy Howard

Solving [git] conflicts with `git` | nbdev

[technical](<#category=technical>)

![](<posts/2022-08-25-git/cover.png>)

## [Release v2](<posts/2022-07-28-v2/index.md>)

Jul 28, 2022 · Hamel Husain, Jeremy Howard

Announcing v2

[release](<#category=release>) [Hugging Face](<#category=Hugging%20Face>)

![](<../images/card.png>)

Category filters appear in the article above the listing. Their counts come from the same selected posts.

cat_nav = test_site.categories_nav(p_blog_idx)
assert 'All (2)' in cat_nav and 'technical (1)' in cat_nav and 'Hugging Face (1)' in cat_nav
assert 'aria-label="Filter by category"' in cat_nav
cat_nav
'<nav class="categories-nav" aria-label="Filter by category"><ul><li><a href="#category=" aria-current="true">All (2)</a></li>\n<li><a href="#category=Hugging Face">Hugging Face (1)</a></li>\n<li><a href="#category=release">release (1)</a></li>\n<li><a href="#category=technical">technical (1)</a></li></ul></nav>'

RSS feeds always put the newest posts first, independently of listing order. Here, order: 0 puts Release v2 first in the default listing, but the newer post comes first in the feed.

Feed text is XML-escaped. Publication dates use GMT. Date-only values mean midnight UTC; timestamps without a timezone also use UTC. Timestamps with an offset are converted to the equivalent time in GMT.

feed_idx = Page('blog/index.md', '---\ntitle: Blog\nlisting:\n  contents: posts\n  feed: true\n---\n')
test_eq(test_site.listing_pages(feed_idx), [p_post1, p_post2])
rss = test_site.rss_feed(feed_idx)
test_eq(rss, test_site.rss_feed(p_blog_idx))

special_idx = Page('blog/index.md', '---\ntitle: News & Updates\nlisting:\n  contents: posts\n---\n')
special_post = Page('blog/posts/space&gradio.md', '---\ntitle: Spaces & Gradio\ndescription: Deploy <apps> & notebooks\nauthor: Research & Development\ndate: 2022-11-07T10:30:00+02:00\n---\n')
feed_site = Site([special_idx, p_post1, p_post2, special_post], site_url='https://example.com')
channel = ET.fromstring(feed_site.rss_feed(special_idx)).find('channel')
test_eq(channel.findtext('title'), 'News & Updates')
test_eq(channel.findtext('description'), 'News & Updates')
items = channel.findall('item')
test_eq([o.findtext('title') for o in items], ['Spaces & Gradio', 'Git Friendly', 'Release v2'])
test_eq(items[0].findtext('description'), 'Deploy <apps> & notebooks')
test_eq(items[0].findtext('author'), 'Research & Development')
test_eq(items[0].findtext('link'), 'https://example.com/blog/posts/space&gradio.html')
test_eq(items[0].findtext('guid'), items[0].findtext('link'))
dates = [parsedate_to_datetime(o.findtext('pubDate')) for o in items]
test_eq(dates[0], datetime.fromisoformat('2022-11-07T10:30:00+02:00'))
test_eq(dates[1], datetime(2022, 8, 25, tzinfo=timezone.utc))
test_eq(dates[2], datetime(2022, 7, 28, tzinfo=timezone.utc))
[(o.findtext('title'), o.findtext('pubDate')) for o in items]
[('Spaces & Gradio', 'Mon, 07 Nov 2022 08:30:00 GMT'),
 ('Git Friendly', 'Thu, 25 Aug 2022 00:00:00 GMT'),
 ('Release v2', 'Thu, 28 Jul 2022 00:00:00 GMT')]

Page metadata

Search engines show a page's <meta name="description"> under its link. Chat apps and social sites build link previews from Open Graph tags (og:) and Twitter card tags (twitter:). A page's description frontmatter gives its description tag. Two settings in octavo.yml add the preview tags, as Quarto's open-graph and twitter-card do:

description: Python supercharged for fastai development
open-graph: true
twitter-card:
  creator: "@jeremyphoward"
  site: "@fastdotai"
  image: https://docs.fast.ai/images/layered.png

true turns a service on with its defaults. A mapping turns it on and overrides them. Both services take title, description, image, image-alt, image-width and image-height. Open Graph also takes site-name and locale, and Twitter takes card-style, creator and site.

The title defaults to the page's browser title, and the description to the page's description, else the site's. Open Graph's site-name defaults to the site title. Twitter's card-style is summary_large_image when there is an image, else summary. image is a full URL, or a path from the source root; previews need absolute URLs, so a path needs site-url. A page's own image frontmatter replaces the site's image in its previews. Descriptions lose their Markdown formatting.

A page can also set open-graph or twitter-card in its frontmatter. Its settings override the site's one by one, and turn the service on for that page.


Site.meta_tags

def meta_tags(
    page:octavo.pages.Page, # Page whose `<head>` the tags go in
    title:str, # The page's browser title
):

Description, Open Graph and Twitter card <meta> tags for page

This site turns on both services, with a Twitter image. guide.md has its own description, which keeps only its text; faq.md has none, so its preview tags use the site's:

social = Site([Page('guide.md', '---\ntitle: Guide\ndescription: How to *start*\n---\n'), Page('faq.md', '# FAQ\n')],
              title='demo', description='A demo site', open_graph=True,
              twitter_card=dict(site='@demo', image='https://example.com/card.png', image_alt='A card'))
guide,faq = social.by_path['guide'],social.by_path['faq']
gtags,ftags = social.meta_tags(guide, 'Guide - demo'),social.meta_tags(faq, 'FAQ - demo')
assert '<meta name="description" content="How to start">' in gtags
assert '<meta property="og:description" content="A demo site">' in ftags
assert not any('name="description"' in t for t in ftags)
assert '<meta name="twitter:card" content="summary_large_image">' in gtags
assert '<meta name="twitter:image:alt" content="A card">' in gtags
L(gtags)
['<meta name="description" content="How to start">', '<meta property="og:title" content="Guide - demo">', '<meta property="og:description" content="How to start">', '<meta property="og:site_name" content="demo">', '<meta name="twitter:title" content="Guide - demo">', '<meta name="twitter:description" content="How to start">', '<meta name="twitter:image" content="https://example.com/card.png">', '<meta name="twitter:image:alt" content="A card">', '<meta name="twitter:site" content="@demo">', '<meta name="twitter:card" content="summary_large_image">']

posts/launch.md sets its own image, a path relative to the post, as for listing cards. Its previews show that image as an absolute URL built from site-url, with the page's image-alt. The site's alt text described the site's image, so it is not used. Without site-url the path cannot become a URL, and the page falls back to the site's image:

launch = Page('posts/launch.md', '---\ntitle: Launch\nimage: cover.png\nimage-alt: The launch party\n---\n')
cards = Site([launch], title='demo', site_url='https://example.com/demo/', open_graph=True,
             twitter_card=dict(image='https://example.com/card.png', image_alt='A card'))
ltags = cards.meta_tags(launch, 'Launch - demo')
assert '<meta property="og:image" content="https://example.com/demo/posts/cover.png">' in ltags
assert '<meta name="twitter:image:alt" content="The launch party">' in ltags
nourl = Site([launch], title='demo', twitter_card=dict(image='https://example.com/card.png'))
assert '<meta name="twitter:image" content="https://example.com/card.png">' in nourl.meta_tags(launch, 'Launch - demo')
L(ltags)
['<meta property="og:title" content="Launch - demo">', '<meta property="og:image" content="https://example.com/demo/posts/cover.png">', '<meta property="og:image:alt" content="The launch party">', '<meta property="og:site_name" content="demo">', '<meta name="twitter:title" content="Launch - demo">', '<meta name="twitter:image" content="https://example.com/demo/posts/cover.png">', '<meta name="twitter:image:alt" content="The launch party">', '<meta name="twitter:card" content="summary_large_image">']

posts/recap.md sets its own Twitter card. Its title and card-style replace the site's, and the site's site handle still applies. Its open-graph: true turns Open Graph on for this page only:

recap = Page('posts/recap.md', '---\ntitle: Recap\nopen-graph: true\ntwitter-card:\n  title: The year in review\n  card-style: summary\n---\n')
rtags = Site([recap], title='demo', twitter_card=dict(site='@demo')).meta_tags(recap, 'Recap - demo')
assert '<meta name="twitter:title" content="The year in review">' in rtags
assert '<meta name="twitter:card" content="summary">' in rtags
assert '<meta name="twitter:site" content="@demo">' in rtags
assert '<meta property="og:title" content="Recap - demo">' in rtags
L(rtags)
['<meta property="og:title" content="Recap - demo">', '<meta property="og:site_name" content="demo">', '<meta name="twitter:title" content="The year in review">', '<meta name="twitter:site" content="@demo">', '<meta name="twitter:card" content="summary">']

Supported settings

Octavo reads only the settings listed here: SITE_KEYS from octavo.yml, PAGE_KEYS from frontmatter, and BLOCK_KEYS from the blocks nested under some of those keys. The names use underscores, as snake_keys returns them. unknown_keys lists the settings Octavo ignores, so that build can warn about site settings it doesn't recognise, and a tool that converts another site to Octavo can report what won't carry over.


unknown_keys

def unknown_keys(
    settings:dict, # Settings with underscore keys, as `snake_keys` returns them
    keys:set, # `SITE_KEYS` or `PAGE_KEYS`
):

Keys in settings that Octavo doesn't read, with those inside a block as block.key

This site misspells toc-depth. The blog page asks for Quarto's code-fold for the whole page, and its listing for Quarto's sort menu; Octavo has neither:

test_eq(unknown_keys(snake_keys({'title': 'demo', 'toc-dept': 2}), SITE_KEYS), ['toc_dept'])
blog = snake_keys({'title': 'Blog', 'code-fold': True, 'listing': {'sort': 'date desc', 'sort-ui': True}})
test_eq(unknown_keys(blog, PAGE_KEYS), ['code_fold', 'listing.sort_ui'])

Building the site

build writes each page as HTML and GitHub-flavored Markdown. Listings are appended in memory before rendering; source files remain unchanged.

Set css to add stylesheets after the built-in styles, and js to add module scripts after Octavo's own (see head_tags). Pass head for additional HTML in every page’s <head>.

Pagefind indexes the rendered <main> content and writes search files under pagefind/. A search-indexing failure raises an exception.

Pass out or configure output to choose the destination. The build deletes the existing output directory before writing. Do not point it at a directory containing files you need to keep.

The return value maps HTML page paths to unresolved document links. build also warns about site settings that Octavo doesn't read, as listed by unknown_keys.


Site.build

def build(
    out:str=None, # Directory to write the site into; defaults to `output` in `octavo.yml`
    head:tuple=(), # Extra chunks inserted verbatim into every page's `head`
):

Write every page as HTML and GFM, with the redirects, generated files, assets, and sitemap

Let's build our test site. Its toc-depth: 2 counts the page title as the first level, so the config page's table of contents lists its ## section but not the ### one:

out = root/'_site'
test_eq(built.build(), {})  # no unresolved links
assert not (out/'drafts').exists()
html = (out/'api/config.html').read_text()
assert '<link rel="icon" href="../favicon.png">' in html
toc = html[html.index('class="page-toc"'):html.index('<main')]
assert 'Reading' in toc and 'Search order' not in toc
assert '<pagefind-modal-trigger compact>' in html
search_files = L((out/'pagefind').iterdir()).attrgot('name')
for nm in ['pagefind.js','pagefind-component-ui.js','pagefind-component-ui.css']: assert nm in search_files
{'pages': len(list(out.rglob('*.html'))), 'search_files': len(search_files)}
{'pages': 3, 'search_files': 14}

The homepage links to api/config.html in HTML and api/config.md in Markdown. The old configuration.html URL redirects to api/config.html.

assert 'href="api/config.html"' in (out/'index.html').read_text()
md = (out/'index.md').read_text()
test_eq(md, (root/'index.md').read_text().replace('api/01_config.md', 'api/config.md'))
assert 'url=api/config.html' in (out/'configuration.html').read_text()
print(md)
---
title: Home
---

A_name starts with [config](api/config.md).

```python
items = ["a_b"]
```

\[x_1 + y_2\]

A site created without root copies no source assets. This example also contains a link to old.md, which is absent from the page list and appears in the build’s missing-link report.

stray = Page('stray.md', '---\ntitle: Stray\n---\n\nSee [the old page](old.md).\n')
loose = Site(list(built.pages)+[stray], title='nbdev')
loose.build(Path(tempfile.mkdtemp()))
{'stray.html': ['old.md']}

Generated files

Pass GeneratedFile objects in files to include text produced in memory. The build writes each file verbatim and excludes its source path from copied assets. Destinations that collide with pages, copied assets, or other generated files raise ValueError.

(root/'images/circles.svg.py').write_text('print("<svg/>")\n')
svg = GeneratedFile('images/circles.svg.py', 'images/circles.svg', '<svg/>\n')
gen_site = Site(list(built.pages), root=root, files=[svg], **built.settings)
test_eq(gen_site.build(), {})
test_eq((root/'_site/images/circles.svg').read_text(), '<svg/>\n')
test_eq((root/'_site/images/circles.svg.py').exists(), False)
# fail on duplicate file
with expect_fail(ValueError): Site(list(built.pages), root=root, files=[svg, svg], **built.settings).build()
# fail on conflict with page
with expect_fail(ValueError): Site(list(built.pages), root=root, files=[GeneratedFile('x/y.py', 'api/config.html', 'z')], **built.settings).build()
# fail on conflict with source asset
with expect_fail(ValueError): Site(list(built.pages), root=root, files=[GeneratedFile('x/y.py', 'images/logo.svg', 'z')], **built.settings).build()
L(str(o.relative_to(root/'_site')) for o in sorted((root/'_site').rglob('*')) if o.is_file())
['_octavo/octavo.css', '_octavo/octavo.js', 'api/config.html', 'api/config.md', 'configuration.html', 'index.html', 'index.md']

Custom layout

Use page-layout: custom, as in Quarto, for a page that needs its own layout, such as a landing page. Set it in that page’s frontmatter:

page-layout: custom
css: index.css
js: index.js
pagetitle: Welcome to my project

The page keeps the site header and its navigation, but uses a full-width content area without the sidebar, generated title block, table of contents, or “View as Markdown” link. It also omits the default article typography (prose styling), so you can style the content yourself. A markdown version is still generated.

css adds a stylesheet for this page, loaded after the site-wide stylesheets. Here, index.css is a path relative to the site root. Later styles can override earlier rules of equal specificity.

js adds a module script for this page, loaded after the site-wide scripts. Its path is also relative to the site root.

pagetitle sets the browser tab title without changing the page’s title in navigation or listings. These settings are independent: you can use css, js and pagetitle on standard pages too.

home = Page('index.md', '---\ntitle: Home\npagetitle: Home page\npage-layout: custom\ncss: index.css\njs: index.js\n---\n\n# Welcome\n')
custom = Site(list(built.pages)+[home], root=root, **built.settings, css='styles.css', js='site.js')
test_eq(custom.opts(home).page_layout, 'custom')
test_eq(custom.build(), {})
htm = (root/'_site/index.html').read_text()
assert '<header class="site-header">' in htm and '<title>Home page</title>' in htm
assert '<main class="custom">' in htm and '<main class="prose">' not in htm
assert 'title-block' not in htm and 'class="page-toc"' not in htm
assert '<details class="site-nav" open>' not in htm
assert 'View as Markdown' not in htm
assert 'styles.css' in htm and 'index.css' in htm and htm.index('styles.css') < htm.index('index.css')
assert htm.index('src="site.js"') < htm.index('src="index.js"')
plain = (root/'_site/api/config.html').read_text()
assert 'title-block' in plain and '<main class="prose">' in plain
assert plain.index('View as Markdown') < plain.index('title-block')
assert 'href="config.md">View as Markdown</a>' in plain
assert plain.count('href="../styles.css"') == 1