Skip to content

Replace Zola with a std-only Rust generator, deployed by GitHub Pages actions - #14

Open
kenesparta wants to merge 120 commits into
rust-edu:mainfrom
kenesparta:main
Open

kenesparta wants to merge 120 commits into
rust-edu:mainfrom
kenesparta:main

Conversation

@kenesparta

@kenesparta kenesparta commented Nov 7, 2026 •

Copy link
Copy Markdown

The site moves from Zola to its own small generator, written in Rust with only the standard library, and GitHub Pages publishes it with GitHub's own actions. Every page stays one Markdown file under content/, now with a short YAML header, and keeps its address.

The deploy that runs today is broken. main.yml uses shalzz/zola-deploy-action@master, which now installs Zola 0.23.6, and that version stops on this repository at templates/macros.html:1 (Unknown tag). So the next push to main fails to deploy, and every pull request build fails too.

The result is live on the fork's Pages site, built by the deploy.yml of this PR: https://kenesparta.github.io/rust-edu.github.io/ For now that site shows a preview with the feeds of the pull requests named under "How to review". The next merge into the fork's main brings it back to this PR's head.

For people who edit the site

  • Every page is a Markdown file with a YAML header: title, plus date and authors for a news post. docs/editing.md says where each page lives and lists the keys.
  • The resources page is a Markdown list. GitHub shows its preview, and each heading has an anchor.
  • The menu and footer links are a few lines of site.yaml.
  • Each pull request gets a check that builds the site, checks every internal link and anchor, and checks that the addresses of urls.txt still exist. An error shows on its line under Files changed, and the check attaches the built site, drafts included, as the artifact site-preview.
  • A local preview is docker compose up, then http://127.0.0.1:1111. A reload builds the site again, or shows the error of the check.

What changes on the site

Before After
Home before Home after
  • One hand-written static/style.css of 9.2 KB replaces the 429 KB of Bulma and Sass. It has a light and a dark theme, WCAG AA contrast, a skip link and a visible focus. A button at the end of the menu switches between the two themes. Its two short inline scripts are the site's only JavaScript: without JavaScript the button stays hidden, and the page follows the system's theme. The menu wraps on small screens.
  • The 26 addresses of urls.txt still exist: every page and file of rust-edu.org today, except /js/site.js, the script of the old menu. /zulip/ still redirects to the Zulip chat, and the menu and the home page now link to it.
  • The entries of Add a new rust book #3 and Add the Embedded Rust 101 course by Wyliodrin and UPB #9 are in the resources list, with their authors credited as co-authors of the commit, so this PR supersedes both.
  • Three broken links and an email: link are fixed. The news list is sorted newest first, and the three empty subcategories of the resources page are gone.

The generator

  • crates/ holds a Cargo workspace of four crates, and Cargo.lock lists only those: edu-yaml, edu-markdown, edu-template and edu-site, which builds the binary edu. They have 7,553 lines of Rust, tests included.
  • edu check checks the site, edu build writes it into public/, and edu serve serves it locally.
  • Each parser accepts the subset of YAML, CommonMark or templates that the site uses. Anything else fails the build with file:line:col and a hint, never a guess.
  • Besides its pages, the site publishes only images, PDF and text files, LaTeX files and stylesheets. The check stops at any other file, at an SVG image with a script, an event attribute, a javascript: address or HTML, and at a symbolic link.
  • CI runs fmt, clippy and the tests in a digest-pinned Docker image, offline, and checks that two builds are byte-identical.
  • The layouts are plain HTML in layouts/. A change of layout or style needs no Rust.
  • Plan 0002 and research/ record the decisions. The README documents the build, the check, the preview and the deploy.

AI acknowledgement

I made this with Claude Code, Anthropic's coding agent, using the Claude Opus 5.5 model since 2026-10-28. Claude Code wrote most of the generator's code and tests, the layouts and the stylesheet, the docs, the research notes, the commit messages and this description, and it drafted Plan 0002 from goals and decisions I set. It also did the checks that the pull requests report: the comparisons with what Zola 0.22.1 deployed, the security review behind kenesparta#30 to kenesparta#32, and the mutation tests. I wrote three commits myself: 068bfca, the Open enum in edu-template, in kenesparta#15; and 2f012d3, the README's "How it works", and 8d5d63b, which moved template parsing into crates/template/src/parse.rs, both in kenesparta#17. Each change was its own pull request on my fork (kenesparta#2 to kenesparta#35, and kenesparta#37), with green CI, and I read each one before it was merged. My feedback went into the sessions or into my own commits, so those pull requests have no review comments. The changes to the logo and the tab icon in kenesparta#37 were made with Claude Code using the Claude Fable 5.2 model. The August plan that Plan 0002 replaces (tag archive/initial-project-plan on my fork) was drafted with Claude Fable 5. I used Gemini CLI only for reviews and questions: none of the committed code or docs is its output. AGENTS.md holds the instructions that the agents followed.

How to review

These are 85 commits from 35 pull requests, each merged into main of the fork (kenesparta#2 to kenesparta#35, and kenesparta#37). kenesparta#30 to kenesparta#32 come from a review of the security of the generator and of how easy it is to add a news post, kenesparta#33 adds AGENTS.md and a Makefile, and kenesparta#34, kenesparta#35 and kenesparta#37 give the site a new look. Merging with a merge commit keeps them as they were merged on the fork. All the actions are GitHub's own and pinned by commit SHA. Dependabot proposes updates of the actions weekly. It also proposes updates of the Rust image together with rust-toolchain.toml, in one pull request.

Eleven pull requests on the fork join this PR as they merge, in order: kenesparta#38 to kenesparta#47, then kenesparta#49.

Checklist for the admin who merges

deploy.yml needs one setting that an admin or a maintainer (GitHub's Maintain role) can change, and a second one, which only an admin can change, keeps other branches from deploying:

  • An owner of the organization verifies rust-edu.org for it: organization settings → Pages → Add a domain, then a TXT record at the DNS host, as GitHub shows it, then Verify. The record can take up to 24 hours to show. Then nobody else can claim the domain if it is unlinked for a moment during the switch.
  • Settings → Pages → Build and deployment → Source: GitHub Actions, instead of Deploy from a branch. Custom domain still says rust-edu.org, and Enforce HTTPS stays on.
  • Settings → Environments → github-pages → Deployment branches and tags: only main. The environment dates from the Zola deploys and allows every branch today, and deploy.yml can also be run by hand on any branch. On the fork, where Pages started with GitHub Actions, GitHub set this rule itself.
  • Merge this PR with a merge commit, right after these changes.
  • Actions → Deploy to GitHub Pages: the run of the merge is green, in about a minute.
  • https://rust-edu.org/ shows the new site: https://rust-edu.org/style.css is about 9 KB instead of 429 KB, and https://rust-edu.org/zulip/ still redirects to the chat.
  • Insights → Dependency graph → Dependabot shows that .github/dependabot.yml was checked.

Rollback

Settings → Pages → Source: Deploy from a branch, gh-pages, / (root). Pages then serves the last Zola build, which gh-pages still holds. The branch stays for two weeks after the merge.

Reverting the merge alone does not bring the old deploy back. The main.yml it restores installs Zola 0.23.6, which fails as described above.

kenesparta and others added 30 commits October 28, 2026 15:33
Four short notes back Plan 0002: an inventory of what the Zola site
uses today, a design for a std-only Rust generator, Docker builds with
an official-actions Pages deploy, and what to keep from the August plan.
A small plan of reviewable steps: pin the broken deploy action first,
make content Markdown-only and drop Bulma while Zola still deploys,
then build a std-only Rust workspace next to Zola and switch Pages
over in one final step.
shalzz/zola-deploy-action@master now installs Zola 0.23.6, which stops at
templates/macros.html:1 with "Unknown tag", so every PR build and the next
deploy fail. Pin the action to v0.22.1 and actions/checkout to v7.0.1 by
commit SHA, give each job only the token permissions it needs, and name
Zola 0.22.1 in the README for local builds.
fix: pin the Zola deploy action to v0.22.1
Every step now lands as a reviewed PR on the fork's main, and the finished
work goes upstream as one PR (new step 9), so Part B no longer waits for
upstream's agreement. Mark step 1 as done and record the step 2 choices:
resources as a Markdown list without the 3 empty subcategories, the
entries of upstream PRs #3 and #9 ported with credit, and Zulip linked
through /zulip/.
docs: add Plan 0002 and its research notes
Nine of the eleven content files now start with a YAML header (---) that
uses only keys Zola and the planned generator both read. News posts list
their authors in the page-level authors key instead of [extra] author,
and the byline joins them with "and", so it reads as before. The news
section gains a title. The resources and Zulip files follow in their own
commits.
Zola already turns _ into - in URLs, so /news/call-for-participation/
and /news/campus-tour/ stay the same; the files now match them.
The 3-level TOML data becomes headings and a list item per resource
("[Title](url) — Author. Description."), so adding one is a one-line
edit with a GitHub preview. Headings get real ids, which the old #slug
links lacked, and the inline code in a description now renders. The
three empty subcategories (Macros, Networking, Web) are dropped, and
resources.html and macros.html are deleted. The intro note keeps its
text and links straight to the editor for this file.
Ported from upstream PR rust-edu#3, which added the
entry to the TOML data this branch replaces.

Co-authored-by: ZhangHanDong <27893+ZhangHanDong@users.noreply.github.com>
Ported from upstream PR rust-edu#9, which added the
entry to the TOML data this branch replaces.

Co-authored-by: Alexandru Radovici <2838177+alexandruradovici@users.noreply.github.com>
content/zulip.md becomes the section content/zulip/_index.md, because
Zola only honours redirect_to on sections; Zola's built-in redirect
replaces redirect.html. The menu and the home page link to /zulip/
instead of zulip.rust-edu.org, which answers with a redirect to plain
http://, so the Zulip host lives in one file.
The announcement button (a div with Bulma classes and class="is", which
matches no CSS rule) becomes a plain link, and the contact address a
Markdown mailto link. content/ now holds no HTML tags.
Three links between pages resolved under the current page and 404ed
live (cfp-2022.html, paper-format-latex/, code-of-conduct); they are now
root-relative. The contact link used an email: scheme wrapped in &lt;&gt;
and becomes a plain mailto link. Two http://rust-edu.org links point to
/ instead.
The GOALS list in the announcement switched from * to - for its last
item, which splits it into two lists; it now uses * throughout. Two pages
repeated the title as a second h1 in their content; those headings
become h2 (their ids stay the same). Two invisible zero-width spaces
inside a campus-tour quote are removed.
Without sort_by, Zola lists a section's pages in no set order; the live
/news/ shows 2022, 2026, 2025. sort_by: date lists them newest first.
Zola turns a relative link such as proceedings.pdf into an absolute
https://rust-edu.github.io/... URL, which costs a 301 to rust-edu.org.
The five file links on the workshop pages are now root-relative, so all
links between pages and files in content/ use the same form.
docs/editing.md maps each URL to its file and lists the header keys,
the Markdown in use, the link forms that work on every page, the common
changes (a news post, a resource, a menu link), proposing a change on
github.com, and a local preview with Zola 0.22.1.
base_url was https://rust-edu.github.io, so the stylesheet, logo and
every permalink went through a 301 to rust-edu.org, and sitemap.xml and
robots.txt advertised the github.io host; it is now rust-edu.org. title
and description sat under [link_checker], where Zola ignores them, and
description gets the site's tagline. build_search_index (misplaced under
[search], no search UI), render_emoji (no emoji codes in content),
minify_html (the default) and stale comments go. Menu links end in /,
which saves a redirect on GitHub Pages. The built pages are otherwise
unchanged.
static/style.css (7.6 KB, served at the same /style.css) replaces the
Sass build of Bulma and Bulmaswatch (429 KB, Bulma included twice, plus
a runtime Google Fonts import). It holds every color in one token block
with a dark theme by prefers-color-scheme, uses the system font stack,
styles the HTML that Markdown produces without classes, links in brand
green #0b7261 (underlined inside text), and meets WCAG 2.1 AA contrast
in both themes. The logo is inverted in the dark theme, since its
lettering is black.

The templates drop the Bulma markup: a header, nav, main and footer
with a skip link and visible focus; no JavaScript, so the mobile menu
wraps instead of collapsing; no nested link around the logo, no
target="_blank"; news dates use the same "Jul 11, 2022" format as the
pages; and the content is no longer run through Markdown a second time.
home.html (unused), sass/ and static/js/site.js are deleted.
Nothing uses ext/bulma or ext/bulmaswatch since style.css is plain CSS,
so the submodules, .gitmodules and the checkout's submodules option go.
A fresh clone builds with no extra step; the README and docs/editing.md
lose the submodule step, the README its stale troubleshooting section
(including a config snippet that was invalid TOML), and it now points to
the editing guide and to where the layout and style live.
refactor: plain CSS, no Bulma, no submodules
A virtual workspace for the std-only site generator: resolver 3,
edition 2024 and Rust 1.99.1, pinned in rust-toolchain.toml (minimal
profile plus rustfmt and clippy) and as rust-version; publish = false,
and license CC0-1.0 like the rest of the repository (plan decision 6).
The workspace lints forbid unsafe code and warn on unwrap, expect,
panic, todo, unimplemented, dbg!, printing to stdout, mod.rs files,
glob imports and lossy casts; clippy.toml allows unwrap, expect and
panic in tests. Cargo.lock is committed and lists no dependencies.

crates/site is edu-site, with the edu binary (plan decision 5). It
knows two commands so far, help and version; any other command line is
a usage error that exits with status 2. Arguments are read with
args_os, so one that is not valid Unicode is an error, not a panic.
The other crates of the plan (edu-yaml, edu-markdown, edu-template)
arrive with their first code.
The Dockerfile's check stage runs cargo fmt --check, clippy with
warnings denied, the tests and a release build, all --locked --offline
and with no network, in rust:1.99.1-slim-trixie pinned by digest. The
same command runs the checks on a laptop:

    docker build --target check .

The .dockerignore is an allowlist, so .git, target/ and public/ never
reach the build.

ci.yml runs that build on pull requests and pushes to main with a
read-only token, after two guards: Cargo.lock lists no crate from a
registry or git, and the Dockerfile's FROM tag names the Rust version
of rust-toolchain.toml. The runner is ubuntu-latest, since the checks
run inside the pinned image. The README says how to run the checks;
Zola still builds and deploys the site.
Decisions 2 and 3 (links in brand green, no JavaScript) were applied
in step 3; decisions 5 and 6 (the edu names, and CC0 for the code too)
were taken on 2026-11-01 for step 4. Steps 0, 2 and 3 are marked done.
Step 4 now adds each crate with its first code instead of creating
four crates at once, empty ones included, so its done-when asks that
Cargo.lock list only our own crates.
feat: generator workspace, edu command and Docker CI
The first half of the YAML subset of research 0002 §3: `key: value`
maps, maps indented under `key:`, plain, single-quoted and
double-quoted scalars, and comments. Every scalar stays a string for
its consumer to type. Anything outside the subset (tabs, anchors,
aliases, tags, flow collections, block scalars, values over several
lines, `: ` in a plain value) is an error at a line and column, the
column counted in characters. Lists, page headers and `edu check`
follow in the next PR.
The YAML subset with its tests comes to about 690 lines, over the
600-line limit of a PR, so it lands in two halves where research 0002
§9 already split it: maps, scalars and errors first; lists, page
headers and `edu check` next. Part B grows to about 4,850 lines in
about twelve PRs.
feat: edu-yaml, the YAML subset's maps, scalars and errors
ci: deploy the site on each push to main, and remove Zola
…t with a dot

The generator followed a symbolic link among the files of the site, so
`edu build` copied whatever the link pointed to, inside or outside the
repository, into the built site. A link in `content/`, `static/` or
`layouts/`, a linked folder of them, `site.yaml` or `urls.txt` now
stops the check, naming the link. Files and folders whose names start
with `.`, such as `.DS_Store`, are no longer part of the site.

A planned site is now written by its own function, so the test of a
copy that fails no longer needs a broken link to make one fail.
…eets

Pages have no HTML, but the build copied any other file of `content/`
and `static/` as it was, so an `.html` or `.js` file would have run its
scripts on the site as one of its pages. The check now stops at a file
of another type than the site publishes, and names the types it does
publish; an HTML file gets a hint to write the page in Markdown.

The test sites that clashed with `static/index.html` and
`static/sitemap.xml` now clash with `.txt` files instead.
A heading with a letter outside ASCII, such as `## Taller en Perú` or a
heading in Chinese, failed the check with "an id is made of ASCII
letters and digits", although the guide says to type `é` as it is. Its
id now keeps its letters and digits in any script, in lowercase, as
GitHub keeps them: `taller-en-perú`. Every heading of the site is ASCII,
so no id changes.

The check of links decodes the `%` escapes of an id, so a link to
`#taller-en-perú` finds it. An error about a link shows it as the page
writes it, `/news/café/` instead of `/news/caf%C3%A9/`, at its line and
column instead of at the top of the page.
Some editors on Windows start a UTF-8 file with U+FEFF, which shows as
nothing. A page saved that way failed the check with "expected a line
`---` that opens the header", though its first line reads `---`. The
YAML parser now leaves the mark out, for pages and for `site.yaml`.
`edu check` names the file, line and column of each mistake, but in CI
that line sat inside the log of the Docker build, and the pull request
only said that the check failed. The step now also writes each such
line as an annotation, which GitHub shows on that line under *Files
changed* and in the summary of the checks. `docs/editing.md` says so,
and that the check of a first pull request waits until a maintainer
lets it run.
Zola no longer builds the site, and a contributor has no reason to know
it. The messages for a `~` and for `{…}` at the end of a heading now say
that GitHub may read `~` as strikethrough while the site would not, and
that `{…}` after a heading is not supported.
fix: refuse symbolic links, and publish only images, PDF, text, LaTeX and CSS files
fix: accept headings in any language and a byte order mark, and show errors on the pull request
A browser that opens an SVG image at its own address runs the image's
scripts as if it were a page of the site. The check now scans each SVG
image, and stops at a `script` or `foreignObject` element, an event
attribute such as `onload` or an animation of one, a `javascript:`
address or the XHTML namespace; and at what could hide one of them from
the scan: a reference other than XML's own five, a processing
instruction other than the XML declaration, a `<!DOCTYPE>` with
declarations, or markup that does not close. The site's banner passes
as it is.
GitHub Pages sends an SVG image without a charset, so a browser reads
it in the encoding that its XML declaration names, where the same bytes
can be other characters than the UTF-8 text that the scan reads. The
check now stops at a declaration that names another encoding than
UTF-8, and at a control character other than a tab or a line end,
which XML does not allow either.
fix: publish an SVG image only if it has no way to run a script
@BartMassey

Copy link
Copy Markdown
Member

@kenesparta This looks great! Have some things though.

Presumably the new site gen was AI-generated to some degree? If so, please make there's an AI acknowledgement in the PR and elsewhere so that folks know what tools were used and how.

We're sure we want to maintain our own static site generator forever, right? That's no small commitment.

The original plan was not to merge a PR, but rather to just rename and archive this repo, then move your fork into place here. If you'd rather do it by PR, maybe that makes more sense though.

The Atom feed is going to need to happen fairly quickly — I follow this site this way, and I suspect a few others do too.

All that said — thanks very, very much for this big piece of work! Let me know what you think here and we'll get this merged or whatever as soon as we can.

AGENTS.md holds the instructions, and CLAUDE.md and .gemini/settings.json point
Claude Code and Gemini CLI to it. The Makefile gives one command for the checks,
which also run format and lint, since CI stops at those as well.
docs: add one set of instructions for AI coding agents, and a Makefile
…hoto

The editing guide, README.md and AGENTS.md now name the `hero` list of
site.yaml, which holds the next steps under the home page's heading,
and the editing guide says that the home page shows the site's
description as that heading. A new item says how to save a photo: 960
pixels wide, in the 16:9 shape, as WebP of at most 90 KB. The
stylesheet's header no longer says that the body is the column, which
is not so in this design.
…of the editing guide

README.md and AGENTS.md now name the `hero` key of site.yaml, as the
previous commit said they did. The editing guide no longer says that
the title is every page's `#` heading, since the home page shows the
site's description there; its image example uses a .webp file, as the
photo item advises; it says that the check follows the links of
site.yaml, not only the menu's; and the photo item says how far a
portrait photo moves the text. Two comments, in site.yaml and in the
stylesheet, now say what their lines do.
feat: a simpler, modern look, WebP photos and a lighter logo
…ions

Headings take three colors built from the crab's orange, the brand's
color: titles take it as rust; section headings take blue, the split
complement of orange that the teal of links does not take; and
sub-sections take violet, blue's neighbor. Each has a contrast of at
least 5.5:1 on the page, in both themes. A bold line right above a
photo, such as a place on the campus tour, takes the sub-section color,
and when it follows text it keeps the space above it. The home page's
statement keeps the text color beside the crab. On the campus tour,
"Where We've Been" becomes a heading like the post's other sections.
The button sits at the end of the menu. A short script in the head sets
the choice, kept in localStorage, before the page paints, so it holds
on every page and on the next visit; until the button is used, the site
follows the system's theme. Without JavaScript the button stays hidden
and the site still follows the system's theme, and printing stays light.
The dark colors now apply when the system asks for them and the button
has not chosen light, or when the button has chosen dark.

These few inline lines in layouts/layout.html are the site's only
JavaScript, which README.md now says. Plan 0002 chose none (decision 3);
the site stays whole without it.
From 46rem up, the bullets and numbers of a list hung in the margin left
of the column, so that the text of each item lined up with the
paragraphs. On screen they read as out of line. Lists now keep their
indent of 1.5rem at every width, with their bullets inside the column.
…button

feat: colored headings, a light and dark theme button, and bullets inside the column
The dark theme inverted the whole logo with a filter, which made the
black cap white, paled the crab and inverted its eyes. The filter is
gone. The header now holds the logo twice and the stylesheet shows the
one for the theme: `#dark` in the banner's address turns its lettering
to the light paper's color, and the crab and the cap keep their own
colors. A prefers-color-scheme rule inside the banner would not do,
because in Safari it follows the system's theme and not the theme
button. Without `#dark` the lettering stays black, so a site that
embeds the banner shows the lettering it showed before, and so does a
printed page.

The cap stays black in both themes. A fine line in the light paper's
color runs around its two parts: it disappears on the light theme and
sets the cap off on the dark one. It takes the place of the white
shapes and strokes behind the cap. The tassel is gone, since it hung
behind the crab and only three fragments of it showed. The cap sits
one unit lower, so that its line covers a notch of the shell's edge at
its left corner. The crab is unchanged.

The banner goes from 3,897 to 2,752 B with gzip, and style.css from
2,831 to 2,883 B. Where the stylesheet does not load, both logos show.
The icon was a crab drawn for the tab alone. It is now the banner's
own symbol, the crab under its black cap, without the lettering and
centered in a square. The line around the cap has the light paper's
color, so it sets the cap off on a dark tab and is hardly seen on a
light one, and the icon needs no rule for the dark theme. At 16 px the
line is about half a pixel of a 2x screen; on a dark tab of a 1x
screen the cap's edge is faint.

The icon goes from 349 to 1,932 B with gzip.
feat: a logo that keeps its colors in the dark theme, and a tab icon drawn from it

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants