These are the sources for unpoly.com: the site's parser, templates, stylesheets and JavaScript.
If you are looking for the source code of the Unpoly framework, visit github.com/unpoly/unpoly.
The unpoly.com site is implemented by two repositories:
unpoly/unpolyholds most of the textual content (API references, guide pages).
Written as Markdown with JSDoc-like directives.unpoly/unpoly-site(this repo) holds the HTML templates, CSS, and JS.
It also implements a parser that reads content fromunpoly/unpoly.
Documentation content is written in the unpoly/unpoly repository, next to the code it
describes, so that a change to a feature and a change to its documentation happen in the
same commit and cannot drift apart. Nothing in source/api is a page you write; those are
templates that the parser fills.
The unpoly/unpoly-site repo only holds content for a few pages, all implemented in source/:
- the start page
/support
(/install used to live here too, but is now a guide page in unpoly/unpoly;
/tutorial was retired and redirects into the Learn section.)
What maintainers need to know to write documentation content lives in unpoly's
documentation guide.
This README explains how unpoly-site is implemented.
- unpoly.com is a static site. It uses Unpoly for its frontend.
- We use Middleman, a static site generator based on Ruby.
- Page sources can be found in
source. We mostly use ERB templates. - API references and module overviews are not Middleman pages. They are parsed live from the Unpoly source code. See How API documentation is parsed.
- There is a symlink pointing to a local copy of the Unpoly source code in
vendor/unpoly-local. This code is used both to parse the documentation and to provide the Unpoly JavaScript and stylesheets for the site itself. See Local development below. - Frontend assets can be found in
source/javascriptsandsource/stylesheets. They are compiled with Sprockets, there is no Webpack or another bundler. The Sprockets integration in Middleman 4 works just like the classic Rails asset pipeline. - Helper functions and Middleman configuration can be found in
config.rb. - The site is deployed by copying the static build files to unpoly.com. We use Capistrano to build and deploy with a single command. See Deployment.
Ruby code in lib/unpoly/site parses documentation comments into an AST-like structure
(the Unpoly::Site namespace).
It scans every .js and .md file under vendor/unpoly-local/src. In a .js file it
picks up comments opening with /*-; a .md file is one documentation block in its
entirety. Each block declares what it documents (@function, @selector, @page, …),
and the parser resolves cross-references, partials and inherited params between them.
Middleman proxies set up in config.rb then create one page per parsed symbol, rendered
through source/api/feature_template.html.erb and
source/api/interface_template.html.erb.
The navigation is split into a Learn and an API section. Which pages and modules belong
where — and in which order — comes from a manifest in the Unpoly repo,
src/unpoly/pages/toc.yml, parsed by lib/unpoly/site/toc.rb. The build fails when
the manifest and the parsed documentation disagree.
Documentation changes are picked up by reloading. You need to restart the development server when you add a new symbol or page, because the proxies are built at boot.
- We use the BEM (Block/Element/Modifier) naming convention to prevent name clashes and accidental style inheritance. All components are implemented as BEM blocks. We use the terms "blocks" and "components" interchangeably.
- We use a custom BEM naming convention that only uses double dashes (
--), never double underscores (__). All names are kebab-cased. - Every block lives in a file of its own in
source/stylesheets/guide/blocks, named after the block. Files there are picked up byrequire_tree, so a new block needs no import.
A simple block is a class on a <div>, <span> or any other element:
<a href="/install" class="hyperlink">Install</a>
The children of a block ("elements" in BEM lingo) are all prefixed with the block name and
two dashes (--):
<div class="menu">
<div class="menu--title">…</div>
<div class="menu--nodes">…</div>
</div>
Do not use double underscores (__) to separate block and element names.
Where possible, only have a single nesting level (a block with many children). Where deeper
nesting has big advantages, the class name still only contains a single nesting depth
(block--last-element-depth):
<div class="feature">
<div class="feature--param">
<div class="feature--param-info">…</div>
<div class="feature--param-details">…</div>
</div>
</div>
To offer variants of a block, define modifier classes that can be added to the block class.
Modifiers are prefixed with a single dash (-):
<span class="tag -teal">required</span>
Both blocks and elements can have modifiers. In SASS a modifier is nested into the block it belongs to:
.tag
background-color: var(--color)
&.-teal
--color: #{$COLOR_GREEN_SEA}
Modifiers do NOT use prepositions (NOT like .is-teal) and they do NOT prefix the block
name (NOT like .tag--teal).
Many modifiers are generated from parsed documentation: a feature's visibility becomes
-stable, -experimental, -deprecated or -internal, and a menu node's kind becomes
-page, -group or -interface.
BEM names are not limited to templates and stylesheets. They are also built in Ruby helpers
(config.rb, lib/unpoly/site) and used by the components in source/javascripts, both
as selectors and as classes toggled at runtime. When you rename a block, grep for its name
in all four places: stylesheets, ERB templates, Ruby and JavaScript.
Every documentation page has a Markdown twin at its URL plus .md (/up.render.md), for
coding agents and LLMs. /index.md and /llms.txt are a generated index of all guide
chapters and API modules. The unpoly-docs agent skill bundles the same pages as files.
- Twins are registered in
config.rb, next to each page's own proxy, plus a short list of plain pages (/learn,/api,/changes, …). The templatesource/markdown_twin.txt.erbrenders the HTML page without its layout and converts it withUnpoly::Site::HtmlToMarkdown. - The converter is generic HTML → Markdown. It knows the site only through semantics
in the templates: headings,
role="heading",aria-label,<nav>, anddata-markdown="ignore"/data-markdown="chip". Prefer adding semantics to a template over adding a rule. The markers are listed under Markers in the templates. - Links come from a resolver (
Unpoly::Site::MarkdownLinks): absolute URLs on the web, relative file paths in the skill. A link goes to a.mdURL only when its target has a twin. A build links tohttps://unpoly.com(override withBASE_URL); the preview links to itself, so you can click through.mdpages there. - Golden files in
spec/fixtures/markdownpin the twins of the parser fixtures. A change to a template or the converter shows up as a failing spec with a diff; accept it withUPDATE_GOLDEN=1 bundle exec rspec spec/features/markdown_twins_spec.rb. - The skill lives in
source/skills/unpoly-docs/: the SKILL.md template, and the search scriptscripts/search.pywith its Python tests.scripts/outline.pyis the twin of unpoly'sbin/outline: change both together, and both must pass the fixtures inunpoly/tooling/test/fixtures/outline/.middleman buildwrites it tobuild/skills/unpoly-docs/, then checks it and packs the archives that unpoly.com serves fornpx skills add https://unpoly.comand the Claude Code marketplace (Unpoly::Site::SkillPackage). The deploy ships only the archives.SKIP_SKILL=1leaves the skill out of a quick local build. To let your own Claude Code use the skill of your last build, runbin/link-skillonce: it symlinksbuild/skills/unpoly-docsinto~/.claude/skills. - Search tests need Python 3.8+.
bundle exec rspecruns them; so doesbundle exec rake skill:test. The ranking queries run against the skill of the last build and are skipped without one. - Serving:
source/.htaccess.erbserves.mdastext/markdown, hands the twin to agents whoseAcceptnamestext/markdownfirst, and redirects a.mdURL without a file to its page.
The templates in this repository say what an element means with standard semantics
(headings, <nav>, ARIA), which screen readers and the converter both understand. Where
that is not enough, a data-* attribute takes over. Each data-* attribute serves one
consumer:
| Marker | Screen readers | Search (Pagefind) | Contents list | Markdown |
|---|---|---|---|---|
data-pagefind-body |
– | indexes these pages | – | – |
data-pagefind-ignore |
– | skips the element | – | keeps it (agents want those blocks) |
data-toc-ignore |
– | – | skips the heading | – |
data-toc-include |
– | – | lists a lower heading | – |
h2–h4 with id |
headings | sub-results | lists them | a heading of that level with {#id} |
role="heading" aria-level |
headings | – | – | heading of that level |
aria-label on a link |
the link's name | – | – | the link's text |
aria-hidden="true" |
hidden | indexed | – | dropped |
data-markdown="ignore" |
read | indexed | – | dropped |
data-markdown="chip" |
read | indexed | – | (title or text) |
<svg role="img"> with <title>/<desc> |
reads them | – | – | [Diagram: title](…) and the description |
<nav aria-label="…"> |
a landmark | – | – | a literal <nav> block of links |
data-markdown="ignore" drops chrome that only humans use, like the copy buttons or a
breadcrumb. data-markdown="chip" writes a badge as a word in parentheses, preferring its
title. Add a value only for a case these two can't express.
Every variant below expects unpoly and unpoly-site to be checked out in the same
parent folder:
projects/
unpoly/
unpoly-site/
That's because the symlink vendor/unpoly-local — committed in this repo — points at
../unpoly, and everything is read through it: the documentation content, and the Unpoly
build in unpoly/dist that the site uses for its own frontend.
Best when you are writing documentation, and still fine when you are working on the site.
Install the Ruby version from .ruby-version and run bundle install here once. Then, in
the unpoly directory:
bin/dev
This boots this site at http://localhost:4567 alongside Unpoly's own build watcher, so
unpoly/dist stays current as you edit the framework — no manual build, and no second
terminal. See
Setting up a dev environment
in that repository.
Use this when you only want to work on the site and don't need a live build of the
framework. You are responsible for unpoly/dist yourself: if it's missing or stale, run
npm run build in the unpoly directory.
- Install the Ruby version from
.ruby-version. - Install dependencies with
bundle install. - Start a development server with
bundle exec middleman server. - Test your changes on http://localhost:4567.
Same scope as above — the site alone, with unpoly/dist your responsibility — but without
installing Ruby. If you're using an editor such as VSCode and have Docker available, you
can use the DevContainer configuration provided.
- Open the project in VSCode.
- When prompted, choose to Reopen in Devcontainer.
Once inside the DevContainer, use bundle exec middleman server and bundle exec rspec as
you normally would.
The same container without VSCode. From inside the unpoly-site folder:
docker compose -p unpoly-site -f .devcontainer/docker-compose.yml run --service-ports app
Once the container is running, you can browse documentation from the outside at http://localhost:4567.
Run the test suite with bundle exec rspec. It also runs on GitHub Actions for every
push to master and for every pull request.
Tests read the documentation through vendor/unpoly-local like the site does, so they see
the content of whatever revision is checked out there. A missing @stable tag in the
Unpoly sources will fail the suite here.
spec/libcovers the parser and the objects it builds (theUnpoly::Sitenamespace) with fast unit tests. What is worth covering here is what the HTML templates call: theguide_pathof a documentable, the HTML thatMarkdownRendererproduces, the features and topics an interface offers to the menu.spec/featurescovers the site itself with Capybara feature specs. They all run in a headless Chrome (js: true), since most of what the site does — loading the menu, updating fragments instead of reloading — only happens with JavaScript. UseNO_HEADLESS=1 bundle exec rspecto watch them in a visible browser.spec/fixtures/parserholds doc comments that the parser reads in addition to the Unpoly sources, as test data for parsing rules. Note that they are parsed in a production build as well, where they become pages of their own.
We aim for a middle ground on coverage: describe public API and behavior rather than implementation details, and keep the browser tests to core journeys that a unit test cannot reach. A parsing rule is worth a fixture and one expectation; it does not need a feature spec of its own.
The frame around a page (sidebar, width mode, text column) is rendered by every response
inside .guide--torso, the root layer's main target. spec/features/frame_paths_spec.rb
checks that each page family gets the frame of a direct load however the reader arrives
(from another family, a search result, the drawer, the logo, history). Extend it when
you add a family.
For what a spec can't judge, bin/shoot drives a headless Chrome through the scripted
paths in shoot/ against a running preview, saves a screenshot per state and prints frame
probes (sidebar, menu, column, horizontal overflow, content sticking out of the column).
Run bin/shoot --list for the suites, and bin/shoot -l before / bin/shoot -l after
around a change to compare tmp/shoot/before with tmp/shoot/after.
Two rake tasks check the documentation beyond the test suite:
bundle exec rake docs:check_urlsverifies that every URL unpoly.com serves today still resolves to a page or a redirect ("no lost URL").bundle exec rake docs:learn_refslists the public selectors and events that no guide page explains yet (no@learn-ref).
- Commit and push changes in
unpoly-site. - Commit and push changes in
unpoly, which you might have changed while reviewing the documentation output. - Run
bundle exec cap v3 deployto push the changes to https://unpoly.com. Static files will be built during deployment, including a broken-link check over the whole site and the full-text search index.