Building a documentation site

You are reading one. The topbar, the left sidebar, the search box, the copy buttons on the code and the coloured callouts are poops-docs-theme โ€” a layout, a stylesheet and a script this site pulls out of node_modules. Underneath it is a plain Poops build: the theme reads the same nav tree and search-index.json that any Poops site can generate, so nothing it does is closed to you.

Two routes, and the second is not a consolation prize:

The theme Chrome you write
You write a front-matter line and two config keys a layout, a recursive macro, some CSS and JS
You get topbar, sidebar, search, TOC, breadcrumb, dark mode, copy buttons, edit link exactly what you built, nothing else
The design is the theme's โ€” tokens are overridable, the markup is not yours
It costs one devDependency, and living with its opinions an afternoon, and then maintaining it

The theme comes first below, then the pieces underneath it โ€” which is also the order you want if you are writing your own, because the tree and the index are the same either way.

The theme route

1. Install it. It peer-depends on Poops โ‰ฅ 2.0.0.

npm install --save-dev poops-docs-theme

2. Point your pages at the layout in front matter:

---
layout: poops-docs-theme/docs
---

poops-docs-theme/prose is the other one: the same topbar, one article, no sidebar and no search โ€” for a one-page project. Pick one per page; the two bundles are alternatives, not layers, and loading both means loading everything twice.

3. Compile its stylesheet and script into your output. The layout links css/docs.min.css and js/docs.min.js, so those are the names to land on:

{
  "styles": [{
    "in": "node_modules/poops-docs-theme/scss/docs.scss",
    "out": "dist/css/docs.css",
    "options": { "minify": true, "justMinified": true }
  }],
  "scripts": [{
    "in": "node_modules/poops-docs-theme/src/docs.ts",
    "out": "dist/js/docs.js",
    "options": { "minify": true, "justMinified": true, "format": "iife" }
  }]
}

justMinified drops the unminified twin, so docs.css is emitted as docs.min.css and nothing beside it. Swap in scss/prose-only.scss and src/prose.ts for the prose layout. To skip compiling altogether, copy the theme's own dist/css and dist/js โ€” it ships both built.

4. Generate what the layout reads. The sidebar is the nav tree and the search box is search-index.json. Both are markup options, and without them the layout renders a docs site with no navigation and a search field that finds nothing:

"markup": {
  "options": {
    "nav": { "out": "nav.json", "root": "docs", "collections": "index" },
    "searchIndex": "search-index.json"
  }
}

The topbar

Everything in the bar comes out of site:

"markup": {
  "options": {
    "site": {
      "brand": "Poops",
      "brandMark": "๐Ÿ’ฉ",
      "repo": "https://github.com/stamat/poops",
      "branch": "main",
      "links": [{ "title": "Changelog", "url": "changelog" }],
      "iconLinks": [
        { "title": "npm", "url": "https://www.npmjs.com/package/poops", "icon": "npm" }
      ]
    }
  }
}
Key What it puts in the bar
brand the title, falling back to site.title. Links to the site root, or to brandUrl.
brandMark the emoji beside it โ€” also the tab icon, drawn inline, so there is no favicon file to make.
repo the GitHub button, and with branch the edit link at the foot of each page. Falls back to package.homepage; omit both and the button disappears.
links labelled nav links. Site-relative urls get the page's path prefix, absolute ones open in a new tab, and the section you are in is marked with aria-current rather than hidden.
iconLinks the same list without labels โ€” a package registry, a chat room. title becomes the aria-label.
footer html, unescaped, replacing the default brand/version/license line.
theme pins light or dark and drops the switch.

Both lists take an icon: github, npm and package draw built-in marks, and anything else is printed as given, so an emoji or a pasted <svg> works too.

The links row measures itself rather than folding at a width someone typed โ€” a link that stops fitting moves into a More panel, and when the window is under 40rem the whole row becomes a drawer. The sidebar does the same at 60rem. Neither is modal: Tab reaches every link and Escape closes what is open. The rest of the theme's config โ€” pinning the colour scheme, overriding the tokens, embedding live samples โ€” is in its README.

Typing the pages

The jsonld filter types a dateless page as WebPage. Documentation is TechArticle, and it is one setting for the whole site rather than a line in every page's front matter:

"markup": { "options": { "site": { "jsonld": { "@type": "TechArticle" } } } }

The navigation tree

Both routes need this one. Add nav to the markup config and Poops builds a nested navigation tree from your pages' front matter and URL structure โ€” guide/index.md becomes a parent node; guide/getting-started.md becomes its child.

{
  "markup": {
    "in": "src/markup",
    "out": "dist",
    "options": {
      "nav": { "out": "nav.json", "collections": "index", "home": true },
      "searchIndex": "search-index.json",
      "sitemap": "sitemap.xml"
    }
  }
}

The tree is exposed two ways:

  • as the nav global on every page (built in a pre-pass, always current),
  • and as nav.json for client-side rendering.

Tip

Render the sidebar from the nav global, not from nav.json loaded via data. The global always reflects the current build; the loaded file would be one build behind.

Front matter that shapes the tree

Field Effect
order Number that sorts a page among its siblings. Unordered pages fall to the bottom, alphabetically.
navTitle Sidebar label that overrides title.
nav: false Hide the page from the sidebar (still indexed and in the sitemap).

So a hand-authored sequence wins over alphabetical: give your intro order: 0, the next section order: 1, and so on.

Rendering the sidebar

The theme does this for you. Writing your own: the tree is arbitrarily deep, so render it with a self-recursing macro, and prefix each url with relativePathPrefix so links resolve from any depth:

{% macro navtree(items) %}
<ul>
  {% for item in items %}
  <li>
    {% if item.url != null %}
      <a href="{{ relativePathPrefix }}{{ item.url }}">{{ item.title }}</a>
    {% else %}
      <span>{{ item.title }}</span>
    {% endif %}
    {% if item.children %}{{ navtree(item.children) }}{% endif %}
  </li>
  {% endfor %}
</ul>
{% endmacro %}

{{ navtree(nav) }}

Warning

Use item.url != null, not if item.url. The homepage node's url is an empty string โ€” a valid link โ€” while synthesized section nodes have no url at all. A plain truthiness check wrongly demotes the homepage to a <span>.

Admonitions (info / tip / warning)

Poops parses GitHub-style alert blockquotes during markdown render (via marked-github-alerts). Author them as:

> [!TIP]
> This becomes a green "Tip" callout. Markdown **inside** it still renders.

> [!WARNING]
> A red "Warning" callout.

> [!INFO]
> A blue "Info" callout.

They render as alert <div> blocks with type classes (-tip, -warning, etc.), so markdown inside the callout still works. The theme styles all five flavours already. Without it, include the default styles once:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/marked-github-alerts/styles.css">

Copy buttons on code

The theme's script does this. Otherwise it is another few lines of JS: wrap every <pre> and inject a Copy button that calls navigator.clipboard.writeText. No build step, no dependency โ€” it runs on the rendered output.

searchIndex writes a search-index.json โ€” every page's front matter plus auto-extracted keywords. The theme's script fetches it and filters by title/description/keywords as you type; that is the search box at the top of this page. On your own chrome the index is the same file and the filtering is yours to write.

Info

The search index strips internal fields (content, layout, โ€ฆ) and, per page, keeps up to maxKeywords keywords. Provide your own keywords array in front matter to override the auto-extracted ones.

"Edit this page on GitHub"

Every page carries page.filePath โ€” its source file path relative to your project root, with posix separators (e.g. src/markup/docs/index.md). That is exactly the path GitHub's editor expects, so an edit link is one line in your layout โ€” and it is the line the theme already writes from site.repo and site.branch:

{% set repoUrl = site.repo or package.homepage %}
{% if page.filePath and repoUrl %}
<a href="{{ repoUrl }}/edit/{{ site.branch or 'main' }}/{{ page.filePath }}">โœ๏ธ Edit this page on GitHub</a>
{% endif %}

Set the repo and branch in your site data (or let it fall back to package.homepage and main):

{
  "markup": {
    "options": {
      "site": { "repo": "https://github.com/you/your-repo", "branch": "main" }
    }
  }
}

Don't reconstruct the path from page.url โ€” that is the output URL (.html, and index.md collapses to a directory), so it can't be reversed to the .md source. Use page.filePath.

The result

Two config keys and a line of front matter, if the theme's design suits you. The same two keys, a recursive macro and a sprinkle of vanilla JS, if it does not. No separate documentation framework either way: the tree, the index and the sitemap belong to the markup pipeline, and the chrome around them is a choice.

Next: A blog with collections.