Manual testing¶
How to test Folia by hand: the automated checks, a build of the public example site, and the pages it writes, checked in a browser. Anyone can do all of it from a clone of this repository.
Real-site parity, comparing Folia's build of the reference site with Jekyll's, is separate and optional: it needs the site owner's private inputs, which are not in this repository (see real-site parity). A good build of the example site is no proof of parity with the real site. It shows that Folia builds every kind of content, not that it writes what Jekyll wrote.
Prerequisites¶
| Tool | Needed for | Check |
|---|---|---|
Rust, stable, with rustfmt and clippy (rustup component add rustfmt clippy) |
building and testing; the workspace uses edition 2024 | cargo --version, cargo clippy --version |
cargo-deny (cargo install cargo-deny --locked) |
just deny |
cargo deny --version |
just |
every recipe | just --version |
| git | Folia reads dates from the site's git history, so a site must be a git repository | git --version |
| uv | shellcheck, Ruff and MkDocs, pinned in the justfile and fetched on first use | uvx --version |
Node.js, with npx |
markdownlint, pinned in the justfile | node --version |
| Ruby and Bundler | just check-parity (StandardRB, just baseline-negative), and Jekyll baselines |
ruby --version, bundle --version |
| Python 3 | the parity scripts and just serve (standard library only) |
python3 --version |
| Dart Sass, exactly 1.100.0 | only sites with Sass stylesheets; the example site has none | sass --version prints 1.100.0 |
| Docker | only just toolchain-compare and just toolchain-negative |
docker --version |
Installing them on a fresh Ubuntu 24.04¶
These commands were run, in order, in a new ubuntu:24.04 container as a non-root user on
2026-11-08. The guide's automated checks and
example site then passed in a fresh clone: just check exited 0 (about a
minute after the toolchain was installed), the example site built and just serve
served its pages, feeds, sitemap and search script, and answered 404 for a missing page.
What was tested: the clone was of the branch feat/known-links-register at 450c125, which
contained the coverage-classification and known-links changes (PRs #32 and #33) before they were
merged; it was not a clone of main. The tools those PRs changed are not on the path of the
steps above, but this is the commit that was tested.
The system packages gave Ruby 3.2.3 and Node.js 18.19, older than the versions the author uses (Ruby 3.3, Node 22); StandardRB, markdownlint and everything else passed with them.
sudo apt-get update
sudo apt-get install -y git curl ca-certificates build-essential pkg-config xz-utils \
python3 ruby-full nodejs npm
curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal && . "$HOME/.cargo/env"
rustup component add rustfmt clippy
curl -sSf https://just.systems/install.sh | bash -s -- --to "$HOME/.local/bin"
curl -LsSf https://astral.sh/uv/install.sh | sh
cargo install cargo-deny --locked # compiles it: about a minute
sudo gem install bundler --no-document
curl -sSL -o sass.tgz \
https://github.com/sass/dart-sass/releases/download/1.100.0/dart-sass-1.100.0-linux-x64.tar.gz
tar xzf sass.tgz && mkdir -p "$HOME/.local/sass" && mv dart-sass/* "$HOME/.local/sass/" \
&& ln -sf "$HOME/.local/sass/sass" "$HOME/.local/bin/sass"
export PATH="$HOME/.local/bin:$PATH" # also add it to your shell profile
Dart Sass is needed only for sites with stylesheets (just check does not need it; the ignored
tests do). Docker is not needed for anything in this guide. On another system, install the same
tools by your package manager and check the versions in the table.
Environment variables:
FOLIA_SASS: the Dart Sass program, when it is notsassonPATH.FOLIA_SITE,FOLIA_CONFIGand the otherFOLIA_*variables name a real site's private inputs (see a site's private inputs). Nothing in this guide needs them before real-site parity; a recipe that needs one refuses to run without it.
Automated checks¶
just check # every check below but the parity ones, as CI runs them
just check-parity # the parity-only checks (need Ruby)
Or one at a time:
| Command | What it checks |
|---|---|
just fmt-check (just fmt fixes) |
Rust formatting |
just clippy |
Clippy, pedantic, warnings as errors, every target |
just test |
every unit and integration test of the workspace |
just doc |
the API docs, warnings as errors |
just deny |
advisories, licences and sources of the dependencies |
just file-length |
no source file over 1,000 lines |
just lint-sh, just lint-md, just lint-py |
shellcheck, markdownlint, Ruff |
just check-parity (just lint-rb, just baseline-negative, just accept-negative) |
StandardRB, and the baseline and acceptance scripts refuse what they must, on throwaway repositories |
just docs-build |
this documentation, with MkDocs in strict mode |
CI runs the same recipes in the jobs Checks lists, so a branch that
passes just check (and just check-parity) passes CI.
Fixture tests build throwaway sites in the temporary directory, each its own git repository, and need nothing else. Two are worth knowing by name:
cargo test -p folia --test example_site # builds examples/minimal-site (below)
cargo test --workspace --locked -- --ignored # also compiles Sass: needs Dart Sass 1.100.0
The example site¶
examples/minimal-site
is a small public site with every kind of content Folia builds. Its configuration is
_folia/folia.toml, where folia build looks by default, and its templates are in
_folia/templates.
| Source | What it tests | Output |
|---|---|---|
index.md, about.md (with a redirect_from) |
pages, permalinks, redirect stubs | index.html, about.html, me.html, redirects.json |
404.html |
an HTML page and the error layout | 404.html |
_posts/*.md |
posts: dates, categories, tags, a table of contents, highlighted code, previous and next | blog/2026/…/*.html |
blog/index.html, blog/categories.html, blog/tags.html |
the blog index, category archives, the categories and tags pages ([blog]) |
blog/index.html, blog/category/*/index.html, blog/categories.html, blog/tags.html |
_micro/{notes,likes,bookmarks,rsvps}/*.md |
one micro entry of each kind | micro/, likes/, bookmarks/, rsvps/ |
blog/micro.html |
the micro listing ([[micro.listings]]) |
micro.html |
_garden/topic/*.md |
garden notes, wiki-links, backlinks, a broken wiki-link | dg/topic/*.html, assets/json/notes_graph.json |
[feeds] |
the Atom feeds | blog/feed.xml, blog/tag/*.xml (a tag with a space too), micro/feed.xml, feed/micro/*.xml, dg/feed.xml, feed.xml (combined; feed.xml page) |
| every page | the sitemap | sitemap.xml |
assets/js/search.js, _folia/search/search.js, search.html ([search]) |
the search script and a page that uses it | assets/js/search.js, search.html |
assets/ |
static files | copied as they are |
Build it¶
Build into a directory outside the site that is absent, empty, or one a previous Folia build wrote (see the destination). A failed build exits non-zero and leaves the destination as it was:
just build-example /tmp/folia-example
# or: cargo run --locked -p folia -- build --site examples/minimal-site --dest /tmp/folia-example
folia --version (or -V) prints the package version, folia 0.2.0.
It prints one warning, which is expected: the example has a broken wiki-link on purpose.
warning: _garden/topic/index.md: the wiki-link [[Missing note]] names no note, so it is shown as broken
Built 18 pages, 1 redirect stubs, 16 generated files and 2 static files into /tmp/folia-example.
Folia also says which documents it skipped, and why (skipped: <source>: <reason>): a document
it does not support yet (a template that fails is an error, not a skip). The example has
none; a new one, after a change, is a bug in that change, or in the example.
Inspect the output¶
find /tmp/folia-example -type f | sort
cat /tmp/folia-example/redirects.json
python3 -m json.tool /tmp/folia-example/assets/json/notes_graph.json
head -20 /tmp/folia-example/sitemap.xml
head -5 /tmp/folia-example/assets/js/search.js
What to look at, by feature:
- Pages:
about.htmlhas the page's body;me.htmlis a stub redirecting tohttps://example.org/about, the configuration's[site] url. - Posts: each post has its date, categories, tags, a table of contents, highlighted code, and links to the previous or next post.
- Micro entries: each kind has its template (
page.htmlfor notes,like.html,bookmark.html,rsvp.html), andmicro.htmllists all four, newest first. - Garden:
dg/topic/leaf.htmllistsA topicamong its backlinks;dg/topic/index.htmlshows[[Missing note]]as a broken link;notes_graph.jsonhas both notes and their edges. - Listings:
blog/index.htmllists both posts;blog/category/journal/index.htmllists both,blog/category/notes/index.htmlone;blog/categories.htmlandblog/tags.htmlgroup them. - Feeds: each feed's
<updated>is its newest entry's date, not the build time; every tag has a feed (two words.xml, linked astwo%20words.xml), no category does;feed.xmllists the newest posts, notes and micro entries together, with summaries and no content. - Sitemap: every page but
404.html, by URL; a post's<lastmod>is its last modification (its date until its source is committed). - Search:
assets/js/search.jsstarts withvar documents = [, one record per page and entry, followed by_folia/search/search.js, unchanged. A listing's body (/micro,/blog/categories.html) is itsbodyblock's text, without the navigation and footer; a redirect stub's is empty.
Change it¶
Edit a source, build again, and compare. Folia reads last-modified dates from committed
history only, so a page's modified date changes only once its source is committed. To try
changes without touching this repository, copy the site and make it its own repository:
cp -r examples/minimal-site /tmp/my-site
git -C /tmp/my-site init --quiet && git -C /tmp/my-site add . && git -C /tmp/my-site commit --quiet -m site
cargo run --locked -p folia -- build --site /tmp/my-site --dest /tmp/my-site-out
To try Sass, add a stylesheet with front matter, such as assets/css/main.scss starting with two
--- lines: Folia compiles it to main.css, with its source map, and needs Dart Sass 1.100.0 for it.
Browser checks¶
Serve a build locally:
just serve /tmp/folia-example # http://127.0.0.1:8000/; just serve DIR PORT for another port
just serve (scripts/serve.py) serves /about from about.html and /blog/ from
blog/index.html, as the host does, and 404.html for anything else. It is for local checks only.
- [ ] Navigation: every link in the menu opens a page:
/,/about,/blog/,/micro,/dg/topic/index,/blog/categories.html,/blog/tags.html,/search. - [ ] Links: a post's previous and next links, the categories in the footer, a garden note's wiki-links and backlinks.
- [ ] Redirects:
/meredirects tohttps://example.org/about(the configured site URL, not the local server). - [ ] Layout: at a phone's width (the browser's responsive mode) and a desktop's: no page scrolls sideways but code blocks.
- [ ] Assets:
/assets/css/style.cssis applied (code is coloured), the author's avatar shows in a post's byline and as the tab icon; the browser's console and network panel show no 404. - [ ] Search: on
/search, typingminijinjalists the bookmark and the/microlisting that names it;/search?q=leafsearches on load. Its results link to the configured site URL, so they leave the local server. - [ ] Feeds:
/blog/feed.xml,/micro/feed.xml,/feed/micro/likes.xmland/dg/feed.xmlopen as XML. - [ ] Sitemap:
/sitemap.xmllists the pages, and not404.html. - [ ] Listings:
/microlists the four micro entries, newest first;/blog/both posts. - [ ] Error page:
/no-such-pageshows the 404 page, with status 404.
To look at pages without a browser window, take screenshots with headless Chrome, at a desktop and
a phone width, while just serve runs:
google-chrome --headless=new --window-size=375,800 --screenshot=/tmp/post.png \
http://127.0.0.1:8000/blog/2026/01/10/hello-folia
Real-site parity (optional)¶
Only for a developer the site owner has given its private inputs. These are the owner's data: they live outside this repository, in a directory of their own, and nothing of them is ever committed here.
Set up the evidence directory¶
Keep everything in one directory beside the checkouts, for example ../folia-web-evidence/:
folia-web-evidence/
README.md provenance: where each file came from, what is verified, what is not
pins.env the FOLIA_* variables, pointing into this directory
config/ Folia's configuration for the site (FOLIA_CONFIG)
ledgers/ the deviation ledger and the expected warnings
baselines/<label>/ Jekyll baselines (FOLIA_BASELINES)
references/ the recorded reference build (FOLIA_A0_REFERENCE)
toolchain/ the Jekyll lockfiles and images (FOLIA_TOOLCHAINS)
runs/ the reports of the runs that support a decision
Check out the reference site (FOLIA_SITE) at the baseline's commit, with its Folia templates in
its _folia/templates, then load the pins in the shell that runs the recipes:
set -a; . ../folia-web-evidence/pins.env; set +a
Generate or refresh a Jekyll baseline¶
just baseline <label> # a new baseline of the site's checkout
just baseline <label> --lockfile <lockfile> --lockfile-sha256 <sha256> --at <time> # pinned
just baseline-verify <label> # build twice, and without the dump plugin, and compare
just compare <a> <b> # compare two baselines
The parity tools reference explains each step and what a baseline holds. A baseline is never overwritten: a new commit of the site is a new label.
Build and compare¶
just build /tmp/site-out # the whole site, with FOLIA_CONFIG
just routes <label> # every output path, against the baseline ; with
# FOLIA_LEDGER, an approved `folia-only` entry allows an output Jekyll never wrote
just markdown <label> # every Markdown body, with the ledger's accepted differences
just liquid # no Liquid left in the site's Markdown
just pages <label> [out] # every file Folia writes, against Jekyll's
just determinism [out] # two builds, byte for byte, and the recorded reference
just accept <label> [out] # every check, into one evidence folder
just pages keeps its build and reports in out (by default a new directory in the temporary
directory, which it prints):
| File | What it says |
|---|---|
dom-report.md, dom-report.json |
every whole-document difference, and the ledger's verdict on it |
coverage.md, coverage.json |
every output of the baseline and its class, the verdicts clean build and exact parity, and the Folia-only outputs (coverage classes) |
warnings.json |
each warning Folia gave, and the expected-warnings entry that matches it |
links.json |
internal links that resolve to nothing |
build.json, sha256.txt |
the build's record (site and Folia commits, configuration and templates hashes) and manifest |
The determinism reference¶
just determinism builds the site twice, compares the two builds byte for byte, and compares the
first with a reference: a recorded output manifest (<sha256> ./<path>, the sha256.txt of a
kept build). The reference defaults to A0's (FOLIA_A0_REFERENCE, a slice-era build made when the
check was introduced), which is only the right one for A0's baseline; another is chosen with
FOLIA_REFERENCE, declared the baseline's by FOLIA_REFERENCE_LABEL.
A reference goes stale when Folia learns to write more outputs: the check then reports "written, not in the reference" for each. That is not a failure of determinism (the two builds are still identical), and the old entries may all still match, which is evidence nothing in them changed. Do not let it stay an unexplained failure:
- Check what the comparison says: if any file is "in the reference, not written" or "bytes differ", a Folia change is to approve or fix first.
- If the differences are only additions, record a new reference from a reviewed build: copy its
sha256.txtinto a new directory of the evidence (references/<label>-<release>/), beside a README that says which Folia commit, site commit, templates, configuration and baseline time made it, and why. - Point
FOLIA_REFERENCEat it andFOLIA_REFERENCE_LABELat the baseline's label, inpins.env. - Never overwrite or delete the old reference: it is history.
From then on, any change to Folia's output bytes fails the check until its reviewed result is recorded the same way.
Read the ledgers¶
- Deviations (
FOLIA_LEDGER): each accepted entry explains one kind of difference from Jekyll, on the outputs it lists, bound to the hashes of the baseline's file and of the Folia file that was reviewed. A difference no entry explains fails the run. An entry whose output changed is stale: it fails too, and the owner must review the new output before its hashes are updated. Only the owner approves an entry (owner approvals). - Expected warnings (
FOLIA_EXPECTED_WARNINGS): each entry matches one warning Folia gives, with the decision that makes it expected. A warning no entry matches fails the run, and so does an entry no warning matches: remove an entry once its warning is fixed.
Stale or missing evidence¶
| Symptom | Cause |
|---|---|
FOLIA_CONFIG is not set: … |
the pins are not loaded in this shell |
…/BASELINE.json: No such file or directory |
no baseline has that label in FOLIA_BASELINES |
<site> is at <commit>, but the baseline was built from <commit> |
check out the site at the baseline's commit |
<site> has uncommitted changes |
tracked changes in the site; only _folia/ and the paths of parity/site-exclusions.txt may differ |
the templates in … have the tree hash …, not the expected …; nothing was built |
the site's templates changed since FOLIA_TEMPLATES_SHA256 was pinned: review the change, then pin the hash build.json records |
stale entries in dom-report.md |
a listed output's bytes changed; the owner reviews it again |
| an expected warning that matches nothing | the warning was fixed; remove its entry |
Keep private data out of Git¶
- Keep the evidence directory outside every repository, and point at it with
pins.env; never copy a site's configuration, ledger, baseline, hash or report into this repository. .gitignorerefuses the paths the reference site's inputs once had here (parity/filopoe.toml,parity/deviations.toml,parity/evidence/,pins.env, …); it is a safety net, not a place to keep them.- Before committing, read
git statusandgit diff --staged: a site's paths, commit IDs or hashes in a change are a sign something private slipped in.
Troubleshooting¶
| Message | What to do |
|---|---|
cannot read <site>/_folia/folia.toml: No such file or directory |
pass --config, or put the configuration there |
invalid configuration …: unknown field … |
a misspelt or unsupported key: Folia refuses what it does not know |
git log -- <file> failed: fatal: not a git repository … |
the site must be a git repository: git init, then commit |
cannot write the site to <dest>: it is inside the site |
build into a directory outside the site |
Dart Sass 1.100.0 is needed to compile the site's stylesheets, and … |
sass is missing or another version: install Dart Sass 1.100.0, or set FOLIA_SASS to it |
… cannot be rendered with <template>: undefined value (in <template>:<line>) and The build failed with N problems |
the template reads a key the page does not have: templates are strict; test it with is defined. The build fails and the destination is as it was |
these templates cannot be used: <template>: missing; used by … |
a page's layout has no template: add it, or fix the page's layout |
cannot write the site to <dir>: it is not empty, and no record shows a Folia build wrote it |
Folia replaces only what it wrote: choose a new or empty directory, or remove this one yourself |
… it holds N entries Folia did not write |
the previous output has files added to it: move them out, or build elsewhere |
… .<name>.folia-new exists, left by an interrupted build or not Folia's |
Folia never deletes it: look at it, remove it, and build again |
skipped: <source>: only Markdown and HTML pages are built yet (or another reason) |
Folia cannot build that document yet; the rest of the site is still built |
warning: <note>: the wiki-link [[…]] names no note, so it is shown as broken |
fix the link, or, for a real site, record it as an expected warning |
a recipe fails with uvx, npx or bundle not found |
install uv, Node.js or Ruby with Bundler (prerequisites) |