Sites and pages

A documentation site with 30 pages repeats its header, footer and theme list 30 times, and sooner or later one copy falls out of date. In chimera you set those parts once on a Site and give each Page only its own content.

Pages

A Page takes a title, its content and an optional file name. Without one, the file is named after the title in lowercase with dashes: Page("Getting started") writes getting-started.html.

const home = Page("Home", { h1("Welcome"), Paragraph("Start here.") }, "index.html")
const faq = Page("FAQ", h1("FAQ"))
faq:add(h2("Is it free?"), Paragraph("Yes."))

add appends more content later.

Nested pages

Pages can hold pages of their own, and those go in a folder named after their parent. A parent called index.html shares its folder with its children instead:

const docs = Page("Docs", h1("Docs"), "docs/index.html")
    :add_page(
        Page("Install"), -- docs/install.html
        Page("Themes")   -- docs/themes.html
    )
const guide = Page("Guide")  -- guide.html
    :add_page(Page("Setup")) -- guide/setup.html

This page is guide/sites-and-pages.html, a child of the Guide. Nested pages get breadcrumbs above their content like the ones at the top of this page, and site:breadcrumbs(false) turns them off.

PageLink points at a Page object rather than a path. When the page renders, the link works out the relative path from its own folder. Moving a page never breaks the links to it. The text defaults to the page's title:

PageLink(faq)                  -- "FAQ"
PageLink(docs, "Read the docs")
PageLink("docs/install.html")  -- a path from the site's root

Because every link is relative, the built site also works from disk over file://. You don't need a server to check it.

Tables of contents

TableOfContents() lists its page's headings and the pages under it. It's built when the page renders, from whatever the page holds at that moment. The box at the top of this page is one; on wide screens it moves into a column beside the text.

TableOfContents()                    -- this page's h2 and h3 headings, and its subpages
TableOfContents("page", 2)           -- h2 headings only
TableOfContents("site", 3, "Pages")  -- every page of the site, for a sidebar

The site

A Site holds the top-level pages and everything they share. build writes them all, creating folders as it goes, and returns the paths it wrote:

const site = Site("My docs")
    :describe("Guides and reference for my library")
    :default_dark_theme(catppuccin.Mocha)
    :default_light_theme(catppuccin.Latte)
    :add_page(home, docs, faq)

for _, path in site:build("out") do
    print(`wrote {path}`)
end

Two pages with the same path are an error rather than a silent overwrite. describe sets the summary search engines show, and a page can override it with its own describe.

Unless you say otherwise, every page gets a header with a menu of the top-level pages and a theme switcher, plus a footer with the site's copyright. Pass your own to replace either one, or none to leave it out:

site
    :header(Header { Menu { PageLink(home, "My docs"), PageLink(docs), ThemeSwitcher() } })
    :footer(none)
    :sidebar(TableOfContents("site", 2, "Pages"))

SiteFooter builds a footer like this site's with link columns, a copyright line and legal links:

const footer = SiteFooter()
    :link_column("Docs", { Link("Install", "#"), Link("Themes", "#") })
    :link_column("Community", { Link("Discord", "#"), Link("GitHub", "#") })
    :copyright("Ada Lovelace", "Built with chimera.")

Readers of an API reference usually know a name, or half of one. They remember readFileSync but type read file, or they misspell it raedfilesync on the way. site:search() puts a box in the header that finds pages, sections and anything your own elements add while the reader types. It needs no server and works over file:// too:

site:search()

build writes the index the box searches, search-index.js, next to the pages. A site with its own header adds a Search() to it instead, and the index gets written all the same. Results show up under the box grouped by page, with the matching words marked. Arrow keys move through them and Enter opens one, and / or Ctrl+K jumps to the box from anywhere on the page.

How it matches

Every word of a query has to match somewhere in an entry: its title, its keywords, the title of its page or its text. Each word is tried several ways, and the best one counts:

A query with capital letters ranks names written in the same case higher. Pass Search your own list of matchers to drop some or change how much each one counts. A matcher left out never matches, and weigh sets how much each part of an entry counts:

site:search(Search()
    :matchers({ Exact(200), Prefix(), Words(), Typos(25, 1) })
    :weigh({ page = 0 })
)

What it shows

An API reference's search might show functions only, five at a time, without the text around each match. These settings cover that:

Search()
    :placeholder("Search the API")
    :kinds({ "function", "type" })
    :limit(5)
    :min_score(20)
    :snippets(false)
    :group_by_page(false)
    :shortcut("s")

Pages are the kind "page" and their sections are "section". Other kinds come from your own elements.

Your own entries

An element that stands for something readers search for, like a function in a reference, says so in search_entries. The index adds its entries and skips its children. Give the element its id before the page renders so the result has somewhere to land:

class ApiFunction implements Component
    function tag(): string
        return "div"
    end
    name = ""
    summary = ""
    function __init(self, name: string?, summary: string?)
        self.name = name or ""
        self.summary = summary or ""
        self:id((string.gsub(self.name, "%W+", "-")))
    end
    function search_entries(self): { SearchEntry }?
        return {
            {
                kind = "function",
                title = self.name,
                anchor = self:get_id(),
                text = self.summary,
                keywords = { "read" },
            },
        }
    end
end

Your own matchers

Matching runs in the reader's browser, so a matcher of your own is a class with a little JavaScript. source returns a function that scores a query word against a name from 0 to 1. Pages store the matcher by its class name, so parsed pages find it again:

class Suffix(weight: number = 30) implements Matcher
    function source(self): string?
        return [=[
            function (term, field) {
                return field.lower.endsWith(term.lower) ? 1 : 0;
            }
        ]=]
    end
end

site:search(Search():matchers({ Exact(), Prefix(), Suffix() }))

Per-page changes

For anything the site doesn't set itself, customize runs a function on each page's Document just before it's written:

site:customize(function(document, page)
    document:link_stylesheet("https://fonts.googleapis.com/css2?family=Inter")
    if page.title == "Home" then
        document:describe("My library: fast, typed and small")
    end
end)

chimera only writes pages. Copy images and other files into the output folder yourself with fs.copy.