Skip to content

The website build — searchable pages from the same Markdown

The whole repo publishes as a searchable website at https://masiarek.github.io/star-voting-library/ — every teaching page, glossary entry, and generated per-election page, with instant full-text search (the search box is the reason the site exists; GitHub's own rendering never gives readers one).

There is no separate docs source. The site is built from the repo root itself (mkdocs.yml + the mkdocs-same-dir plugin), so the same Markdown that GitHub renders is what the site serves — nothing is copied, nothing can drift. Non-Markdown files (.yaml sources, _tabulated .txt mirrors, images) are carried through unchanged, so "run this file" links keep working.

How it deploys

.github/workflows/docs.yml builds the site on every push to master and deploys it to GitHub Pages. One-time setup (repo admin): Settings → Pages → Build and deployment → Source: GitHub Actions**. After that it's fully automatic.

Local preview

uv run --group docs mkdocs serve

(or ... mkdocs build, which writes the static site into site/ — gitignored, never commit it). The docs toolchain is pinned in pyproject.toml's docs dependency group and resolved by uv.lock, so the preview and CI run the exact same versions. Note: mkdocs-same-dir and mkdocs-redirects are deliberately capped at the last releases that don't pull in the MkDocs-impersonating properdocs package — investigate before raising those pins.

The conventions that make it work

  • Every content folder has a README.md (the existing house rule). MkDocs turns each one into that folder's index.html, which is what makes the repo's folder-style links (../center_squeeze_bv2137/) resolve on the website too.
  • Plain .html URLs (use_directory_urls: false). The repo's cross-links were authored for GitHub's file-relative rendering; pretty directory URLs would shift every page one level deeper and 404 the folder-style links. Don't flip this back without fixing hundreds of links.
  • The homepage opens on the library, not on the pitch (changed 2026-08-04). The big "Vote your heart. No more spoilers." headline, the CTA buttons and a four-stop "New to STAR?" path were all above the fold; a visitor met the sales copy before learning what the site was. The CTA buttons went first (they duplicated the path's own stops), the tagline and the path moved into their own section further down, and on 2026-08-04 that section was removed outright — the ballot image is the one graphic that stayed up top, and routing readers is Start Here's job. The old grab-bag "Learn more" list — eight loosely-related links, mostly 07_Concepts pages — was replaced by "Browse the library" at the bottom: the same ten sections as the left sidebar, in the same order, one line each.
  • Keep any future homepage markup dual-renderable. The retired path row was a <div class="star-path" markdown="1"> in readme.md — a card grid on the site via site_extra.css, and on GitHub, where the class is ignored, a plain stacked list. That degradation was deliberate: readme.md is one source with two front doors, so anything added to it has to read well in both. (Both the div and its CSS block are gone with the section.)
  • Hidden from the sidebar, still built (not_in_nav in mkdocs.yml): CLAUDE.md, CONTRIBUTING.md, THIRD_PARTY_LICENSES.md, and readme.md (its content is already the homepage). Links to them keep working; they just no longer appear in the left nav ahead of the teaching content.
  • The generated case pages are hidden from the sidebar too (*_pages/, added 2026-08-02). There are 456 of them against ~650 hand-written pages, so in the nav they were the majority of the tree — and they are the one part of the library nobody navigates by scrolling: you reach a case from its folder README (all 82 are index-complete, enforced by check_repo_hygiene.py) or from CATALOG.md, both of which say what a case teaches where the sidebar could only show its filename. Same reasoning that already keeps them out of the search index. Measured effect: a case page went from 65 nav links to 16 and shrank ~41% (52 KB → 31 KB), and the whole built site went 132 MB → 117 MB — the nav is emitted into every page, so trimming it pays on all ~1,770 of them. Cost: MkDocs derives the footer's prev/next arrows from the nav, so case pages lose them; build_yaml_pages.py already ends every page with a "More cases in this set" list of every sibling plus up-links, so sibling traversal moved into the content rather than disappearing. Keep that footer block if you keep this exclusion.
  • Excluded from the site: dot-dirs, site/, AGENTS.md (a three-line pointer to CLAUDE.md — kept off the site), _demo_dropbox/ staging, and generated ballot printouts — see exclude_docs in mkdocs.yml.
  • Sidebar section labels are cased by a build hook (mkdocs_hooks.py, wired in via hooks:). Section labels are the one title nobody authors — MkDocs derives them from the folder name, turning _ into a space and capitalizing the result only when the folder name is all lowercase. So 01_STAR arrives as "01 STAR" untouched while rr_tiebreaks became "Rr tiebreaks" and star_vs_rr_divergence became "Star vs rr divergence": one acronym, three renderings, down a single sidebar. Renaming the folders would fix it at the source and is what the numbered top-level folders already do — but it moves every URL underneath them, and this repo's URLs are permanent (see the redirect rule below); a build-time label costs no redirect_maps entry. Page titles are untouched: those come from each file's own # H1. Four rules, applied in this order:
    • unpad() — a leading _ becomes a leading space, and .capitalize() then lands on that space and does nothing, so every _main folder shipped a blank-looking " main" indented out of line with its siblings. They now read "Main"; the underscore keeps doing its sorting job on disk. (The 2026-08-02 reorganization renamed the five method folders' _main to 02_Examples, so only the two under method_comparisons/ still rely on this.)
    • SECTIONS — whole-label replacements, for folder names that aren't a cased version of what the reader should see. Currently the five SCREAMING_SNAKE divergence buckets under method_comparisons/divergence_review/cases/, which now carry the same human names build_divergence_index.py already prints for them. Those are copied from its BUCKET_TITLE rather than imported, because the docs build must not depend on the engine's tooling being importable — test_nav_labels.py fails if the copy ever drifts, and pins the casing rules with the cases that were actually wrong on the live site.
    • PHRASES / TERMS — whole-word acronyms and proper nouns (rr, irv, star, rcv, stv, bv, condorcet, borda, iia, sntv), so the folders above read "RR tiebreaks", "RR vs IRV plurality", "STAR vs RR divergence". PHRASES runs first and is also where punctuation a folder name can't carry gets put back — "Ranked Robin", "Split Cycle", "Post-it", "Hands-on". Every one of those matches the H1 of the folder's own README.md, which is the naming authority; check there before inventing a label. Adding an entry is a single line — keep it narrow, it matches whole words (STARVote, starvote and the election-ID folders are safe, since bv2138 never matches bv, but an ordinary English "star" in a folder name would get shouted at too).
    • capitalize_first() — the capitalize step is all-or-nothing, so one capital anywhere in the folder name suppresses it for the whole label. That is why reporting_BV and silly_two_cand_STAR used to start lowercase under sentence-cased siblings; only the first character moves, and the deliberate capitals further in survive.
  • A hook is deliberately not a plugin: it is one file in the repo and adds nothing to the docs dependency group, so uv.lock and the properdocs pin audit stay untouched.

Search ranking is tuned — don't restore the defaults (2026-08-07)

The search box is the reason the site exists, and Material's stock settings rank badly at this repo's size. Three knobs are set explicitly in mkdocs.yml; each was measured by rebuilding the index offline with lunr and recording where the obviously-wanted page landed for 15 real queries. Median rank went 20 → 4. The comments in mkdocs.yml carry the per-knob reasoning; the short version:

  • separator — the default [\s\-]+ splits on whitespace and hyphens only, so punctuation stays welded to the word. A trailing comma — what you get pasting a phrase out of a sentence — makes Ballot expressiveness, a different token from Ballot expressiveness, and the page falls from #25 to #264. We split on ordinary punctuation instead, keeping decimals intact (\.(?!\d)). This is Material's own recommended separator minus its camelCase clause, which measured identical here and would have chopped up STARVote / BetterVoting / RankedRobin.
  • pipeline — added trimmer (worth 3 places on "exhausted ballots"). stemmer is deliberately absent: it was measured and it is harmful here (median 4 → 10; "exhausted ballots" stops matching its own page). Material appends a trailing wildcard to every query term, so prefix matching already does stemming's job and stemming on top of it mangles the prefixes.
  • fields boosts — the default tag boost is 1,000,000× against title's 1,000×, which assumes tags are rare and distinctive. Here they are neither: 9 broad tags over 507 pages. Because of that trailing wildcard, a search for ballot prefix-matches the ballots tag and lifts all 94 tagged pages above everything else — including pages with the word in their title. Tags now sit at 100, restoring the sane order title > tags > text.

The failure this fixes is subtle and worth recognizing: the page was always indexed and always published — it was simply buried. Two separate reading sessions filed it as "search is broken" before it was traced. If someone reports a page that "isn't in search," check its rank before checking the build; and note that generic query words hurt more than they help, since Range Voting finds the Range page at #1 while Range Voting Voting Method was pushing it to #8.

Known nits (accepted for v1)

  • Anchor slugs differ from GitHub's for headings with &/em-dashes (GitHub's #properties--criteria style). Those links land at the top of the correct page instead of the exact section — about 30 across the repo.
  • Search index covers the ~650 hand-written pages. The generated *_pages are already excluded from it — build_yaml_pages.py stamps search: exclude: true into every page it writes, because the case dumps were 44% of a 9 MB index and crowded the teaching pages out of the results. They are excluded from the nav for the same reason; the two exclusions should stay in sync.
  • ~1,200 unrecognized relative link INFO lines — benign, don't "fix" them. These are folder links (666 written …/dir/, 546 as bare …/dir) rather than file links, so MkDocs declines to rewrite them and leaves them as-is, suggesting …/dir/README.md instead. They are not broken. GitHub Pages serves a directory URL from its index.html (and 301s the no-slash form to the slash form), and GitHub's own file view resolves a folder link to that folder's README — which is exactly why the repo writes them this way, since links must work in both places. Spot-checked live 2026-07-29: 12 of 12 sampled across both forms returned HTTP 200, and check_repo_hygiene.py passes them too. Rewriting 1,200 links to README.md form would be a very large diff for no user-visible gain, so the house answer is: leave them, and filter these lines out when reading build output (mkdocs build 2>&1 | grep -v "unrecognized relative link"). The messages are INFO, not WARNING — the build stays warning-free.
  • Raw .yaml pages show × where the file says × — the browser's fault, not the file's (diagnosed 2026-08-09). GitHub Pages serves .yaml as bare content-type: text/yaml with no charset, so the browser falls back to its locale default (windows-1252) and decodes the two UTF-8 bytes of × (U+00D7 = C3 97) as two separate characters: C3Ã, 97. Hence count_separator: "×" on a file whose bytes are correct — verified byte-for-byte off the live URL. × is the glyph that gets noticed, but the em dash is the bigger exposure. Of the repo's 661 .yaml files, 24 distinct non-ASCII characters appear, and the two that dominate are (U+2014, in 590 files) and × (U+00D7, in 475, from the house count_separator) — so it is mostly prose in scenario_description that mangles, not the options block. A 3-byte character garbles worse than a 2-byte one: becomes —, becomes →, becomes ≥. Worth knowing that the repo's own bytes are clean — a sweep for stored double-encoding found none, and every accented character in the tree is a real one (Château du Baffy, Matthäus, Solórzano, Muñoz, Phragmén). The mangling is only in the browser, and only on .yaml: .txt, .md.html and .json all get an explicit charset=utf-8 from Pages and render fine, so the _tabulated mirror and the generated case page are both clean. Reported upstream 2026-08-09 — GitHub community discussion #204454; tracked with the rest of our outbound reports in upstream_bug_reports.md. The case rests on GitHub's inconsistency with itself: .html, .txt, .json, .js and .css all get charset=utf-8 from the same Pages host, and the same .yaml file gets one from raw.githubusercontent.com — and github/pages-gem#52 reported this identical failure for .js back in Feb 2014, which was fixed. Two things to keep straight if you follow it up: the ask is specifically text/yaml; charset=utf-8 and not application/yaml (RFC 9512 registers that one with optional parameters "N/A" and encoding considerations "binary", so it carries no charset and would make browsers download the file instead of showing it — worse for us than the mojibake); and the way to check whether it's fixed is curl -sI on any .yaml on the site, not a glance at the discussion. Not fixable from here in the meantime — GitHub Pages allows no header control (no _headers file), and the alternatives are worse than the symptom: a UTF-8 BOM on 474 files would ride into every _tabulated echo, and renaming the extension would move permanent URLs. The house answer is the linking convention that already exists — lead with the generated .md page, demote the raw .yaml to the "run this file" link — plus this note, so the next person who sees × doesn't go looking for a mojibake'd source file.
  • The build is warning-free as of 2026-07-29. It used to emit 5 warnings for img/REPLACE_*.png — uncaptured screenshot placeholders on the Ranked Robin case pages — which rendered as broken images on the site; those shots are now captured (05_Ranked_Robin/*/img/<bvid>_*.png). If REPLACE_* warnings reappear, they mean the same thing: a page references a screenshot nobody took yet. Placeholders that are commented out (as on teaching_runoff_reversal.md and the two STAR_reporting pages) don't warn and don't break the site — they're inert until someone captures the image.

The rename (2026-07-16)

This repo was renamed from masiarek/YAML to masiarek/star-voting-library on 2026-07-16, before the first Pages deploy — so the site URL was born correct and no stale Pages links exist. All github.com/masiarek/YAML/... deep links (in Google Docs, Slack, the Substack posts) keep working via GitHub's automatic redirects. Two standing rules: never create a new repo named YAML under this account (it would sever those redirects), and if the repo is ever renamed again, update site_url / repo_url / repo_name in mkdocs.yml — the Pages URL moves on rename and old Pages URLs do not redirect.