---
description: Configure and build a documentation website
output-file: site.html
title: site

---



<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->

A [`Site`](site.md#site) combines pages, shared settings, and optional generated files. Use [`Site.from_dir`](site.md#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`](site.md#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.

```python
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`](pages.md#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`](core.md#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:

- `toc: false` leaves out the table of contents.
- `toc-depth` is the number of heading levels the table of contents shows, counting `#` as the first, as Quarto does. The default `3` shows `##` and `###` headings, and `1` shows none. Octavo counts from the level of a page's top sections, so on a page whose sections start at `###`, it shows one level more than Quarto.
- `sidebar: false` hides a page's sidebar and leaves the page out of the sidebar's tree.
- `page-layout: custom` gives a page a layout of its own, as described in [Custom layout](#custom-layout).
- `css`, `js` and `pagetitle` add a stylesheet, a script and a browser tab title, also described there.
- `math` sets the inline maths syntax that [`md2doc`](core.md#md2doc) reads. Set `math: dollars` in `octavo.yml` to read `$…$` as maths, as Quarto does.

## Page settings

Create a [`Site`](site.md#site) from [`Page`](pages.md#page) objects and shared settings, or use [`Site.from_dir`](site.md#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`](site.md#unknown_keys) lists them.

---

### Site

```python
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

```python
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`.

```python
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
```

```output
Site(9 pages)
```

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

```python
site.opts(config)
```

```python
{ '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`.

```python
test_eq(site.opts(directives).toc_depth, 1)
test_eq(site.opts(config).toc_depth, 2)
site.opts(directives)
```

```python
{ '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`.

```python
plain = Site(pages)
test_eq(plain.opts(config).toc_depth, 3)
plain.opts(config)
```

```python
{ '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.

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

```output
('nbdev', 'config')
```

## Site navigation

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

```python
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

```python
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.

```python
test_eq([o.title for o in site.sections], ['Getting Started','Tutorials','Explanations','API'])
site.sections
```

```output
[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`:

- `maker.html` opens another page in the same `api/` folder.
- `../tutorials/tutorial.html` goes up one folder (`../`), then into `tutorials/`.

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

```python
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.

```python
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')
```

```output
'../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

```python
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/`.

```python
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
```

```output
'<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:

```python
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
```

```output
'<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

```python
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

```python
def read_cfg(
    root:str
):
```

*Settings from `octavo.yml`, through [`snake_keys`](core.md#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`](pages.md#source_files) for the pattern rules.

````python
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
````

```python
{ '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.

```python
over = Site.from_dir(root, toc_depth=1)
test_eq(over.settings.toc_depth, 1)
test_eq(over.settings.title, 'nbdev')
over.settings
```

```python
{ '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:

```python
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), {})
```

## Additional links and the site header

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

```yaml
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

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

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

---

### Site.tools

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

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

---

### Site.home

```python
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.

```python
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
```

```output
'<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.

```python
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)
```

```output
'<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:

```yaml
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.

```python
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)
```

```output
'<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.

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

## Links in the content

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

```markdown
[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

```python
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

```python
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

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

*A [`Linker`](site.md#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
```

```python
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.

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

```output
{'/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.

```python
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
```

```output
Linker(api/config.html -> .html, 1 missing)
```

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

```python
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']}
```

```output
{'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.

```python
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:

```yaml
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

```python
def redirects():
```

*Map alias output paths to current page destinations*

---

### alias_path

```python
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.

```python
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
```

```output
{'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

```python
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`.

```python
stub = redirect_html('tutorial.html', 'tutorials/tutorial.html')
assert 'url=tutorials/tutorial.html' in stub
print(stub)
```

```output
<!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`.

```python
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/', '/']}
```

```output
{'/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`:

```yaml
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

```python
def robots():
```

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

---

### Site.sitemap

```python
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`.

```python
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])
```

```output
<?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.

```python
quiet = Site(pages, title='nbdev')
test_eq((quiet.sitemap(), quiet.robots()), ('', ''))
quiet.settings
```

```python
{'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`:

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

Each setting controls a separate choice:

- **`contents`** selects a directory relative to the listing page. Here, `posts` includes pages under its `posts/` subdirectory. By default, it selects the current directory and its subdirectories. The listing page itself is always excluded.
- **`type`** chooses `cards` (the default) or `table`. Tables show linked titles and descriptions. Cards can also show dates, authors, `categories`, and images from each listed page’s frontmatter.
- **`sort`** defaults to index pages first, then `order`, then title; missing `order` values are treated as `999`. Set `date desc` for newest first—the only explicit sort mode currently supported.
- **`feed`** defaults to `false`. Set it to `true` to generate an RSS feed at `index.xml` beside the listing page. This also requires `site-url` in `octavo.yml`. The feed contains the same selected pages, always newest first, regardless of the listing’s sort order.

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

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

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

---

### Site.categories_nav

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

*Render category filters and post counts for a listing page*

---

### Site.rss_feed

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

*RSS 2.0 feed XML for a listing `page`*

---

### Site.listing_pages

```python
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:
- **API:** `listing: {type: table}` lists pages under `api/` in a table.
- **Blog:** `contents: posts` selects pages under `blog/posts/`, `sort: "date desc"` puts the newest post first, and `feed: true` requests an RSS feed.

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.

```python
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.

```python
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)
```

```output
| Title | Description |
| --- | --- |
| [Core [API]](<core.md>) | The `core` \| functions |
```

```python
print(api_gfm)
```

```output
| 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.

```python
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)
```

```output
## [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.

```python
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
```

```output
'<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.

```python
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]
```

```output
[('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:

```yaml
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

```python
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:

```python
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)
```

```output
['<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:

```python
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)
```

```output
['<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:

```python
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)
```

```output
['<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`](core.md#snake_keys) returns them. [`unknown_keys`](site.md#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

```python
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:

```python
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`](theme.md#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.md#unknown_keys).

---

### Site.build

```python
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:

```python
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)}
```

```output
{'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`.

```python
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)
```

````output
---
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.

```python
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()))
```

```output
{'stray.html': ['old.md']}
```

## Generated files

Pass [`GeneratedFile`](pages.md#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`.

```python
(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())
```

```output
['_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:

```yaml
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.

```python
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
```
