Skip to content

feat!: migrate from Franklin to Zola - #146

Merged
adrhill merged 33 commits into
mainfrom
ah/zola-experiment
Aug 15, 2026
Merged

feat!: migrate from Franklin to Zola#146
adrhill merged 33 commits into
mainfrom
ah/zola-experiment

Conversation

@adrhill

@adrhill adrhill commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Migrates MoJuWo from Franklin.jl to Zola, a single-binary static site engine. Pretty much all functionality of the current site is kept.

How the site is built now

  1. A Julia preprocessor (MoJuWoPreprocessor) executes the code blocks in the authored pages under src/ and writes the results to the gitignored content/ directory.
  2. Zola renders content/ into the static site using the Tera templates in templates/.

The preprocessor

MoJuWoPreprocessor replays Xranklin's executable code fences (> julia, ? help, ] pkg, ; shell, ! plain) and replaces each with its echoed input and ANSI-colored HTML output, mimicking the REPL.
Everything else passes through untouched.
Each page runs in a sandbox module on its own Malt.jl worker whose load path is fixed to the page's Project.toml,
and fences execute in a gitignored scratch directory so they never touch the repository.
Fence output is captured with IOCapture.jl and converted to HTML with ANSIColoredPrinters.jl,
emitting the same .sgrNN spans as Xranklin so the existing CSS keeps working.
These three packages plus the REPL, Pkg, and Logging stdlibs are its only runtime dependencies. See MoJuWoPreprocessor/README.md for details.

Highlights

  • Reliable page execution: each page runs in a persistent, isolated Malt.jl worker, and pages are preprocessed concurrently. Strict mode is the default: the build fails on page errors and on error-level logs inside code fences.
  • Developer workflow: everything runs through Julia app subcommands (julia --project=MoJuWoPreprocessor -m MoJuWoPreprocessor serve|preprocess|build|check|clean). serve watches src/, re-runs only the edited page, and live-reloads the browser.
  • Search: full-text site search via Pagefind.
  • CI and deployment: every push runs the full pipeline (preprocess, zola build, Pagefind indexing, link check). Pushes to main deploy through the GitHub Pages artifact flow instead of a gh-pages branch. Broken internal links fail the build, broken external links only warn.

adrhill and others added 30 commits August 13, 2026 16:34
Documents the planned Franklin/Xranklin → Zola migration in `PLAN.md`,
structured as five reviewable commits: preprocessor tool
(`tools/preprocess/`), Zola scaffold, content migration to `src/`,
CI switchover, and Franklin removal.

Records accepted tradeoffs (header anchor scheme change, dropping the
deployed demo packages, unused tag machinery) and risks. Based on a
proof of concept reproducing all four REPL modes (`julia>`, `help?>`,
`pkg>`, `shell>`) as ANSI-colored HTML via `REPL.helpmode`,
`Pkg.REPLMode.pkgstr`, and `ANSIColoredPrinters`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tools/ZolaPreprocessor/ executes the Xranklin-style code fences
(>, ?, ], ;, !) of authored pages and emits Zola-ready markdown with
ANSI-colored HTML pre blocks (via ANSIColoredPrinters), replaying REPL
semantics: one sandbox module per page, per-expression echo, ans,
trailing-; and nothing suppression, REPL-style error rendering that
never fails the build, environment-aware pkg prompts, and colorized
static julia-repl fences.

Per review, no Xranklin commands are reimplemented; conventions replace
them: a Project.toml next to a page auto-activates that environment
(instead of \activate{}), fences run in a per-page scratch cwd under
_workdir/ (instead of the sitepath shim), and the TOC will come from
Zola's native page.toc in the template (instead of \toc). Leftover
\toc/\activate{} lines are dropped with a warning. PLAN.md updated
accordingly.

The CLI follows the Julia app conventions ((@main) entry point, [apps]
in Project.toml): julia -m ZolaPreprocessor on 1.12+, main.jl on 1.11.

Tests: reference-output comparison of a fixture page exercising every
mode (regenerate with test/update_references.jl), plus test/linting.jl
running Aqua, ExplicitImports, and JET
(julia +1.11 --project=tools/ZolaPreprocessor -e 'using Pkg; Pkg.test()').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Zola 0.23.3 site skeleton next to the still-working Franklin setup; no
content changes. The generated content/ and public/ stay gitignored.

- zola.toml: metadata from config.md, /feed.xml feed, github-dark
  highlighting (giallo), data-lang on <pre> for the language chips
- templates/: base/page/index/404, RSS 2.0 feed template, sidebar with
  per-page section links generated from page.toc (replaces both the
  hardcoded sidebar anchors and the in-content \toc), and Tera 2
  components (tldr/advanced/vscode) replacing the Franklin commands
- static/: CSS copied as-is plus new ansi.css (ANSI palette for the
  preprocessor's REPL blocks + code chrome), KaTeX with explicit $
  delimiters, favicon/logo, CNAME matching the live gh-pages branch
- Makefile: preprocess/serve/build/check/test/clean

Verified with zola build && zola check on placeholder content
exercising components, fences, ANSI passthrough, math gating, 404
and the feed. PLAN.md updated for the 0.23 realities (components,
zola.toml, src/_index.md, Tera-templated content risk).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the authored pages into src/ (homepage as _index.md, 404.md dropped in
favor of the template), convert the 60 admonition commands to Tera 2
component calls, delete the \toc/\activate{} directives, rewrite the
sitepath fences to plain relative paths in the scratch cwd, and fix the one
cross-page link. The preprocessor now wraps emitted <pre> blocks in
{% raw %} so fence output can never break Tera's content templating, and
the Makefile runs Julia with --startup-file=no so a Revise-loading
startup.jl cannot poison section-environment precompilation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-resolve tools/ZolaPreprocessor and the section environments under
Julia 1.12 (JET 0.9.20 -> 0.12.1, Cthulhu 2 -> 3, JuliaFormatter
2.4 -> 2.12). Fallout handled:

- JET 0.12 removed test_package's target_defined_modules config; use
  target_modules = (ZolaPreprocessor,) instead (test/linting.jl).
- Julia 1.12 renders the help-mode signature indent outside the .sgr36
  span; reference output regenerated.
- --startup-file=no is no longer needed: JET 0.12 depends on Revise
  itself, so the default environment and the section envs resolve
  identical JuliaInterpreter/Revise versions. Verified with a full
  preprocess run with the Revise startup.jl enabled.

The root Franklin env and Deploy.yml stay on 1.11 until the CI
switchover (PLAN.md Commits 4-5); main still deploys Franklin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace tlienart/xranklin-build-action with an explicit pipeline:
setup-julia 1.12 + cache, instantiate tools/ZolaPreprocessor,
make preprocess, Zola 0.23.3 via taiki-e/install-action, zola build,
zola check, and a peaceiris/actions-gh-pages deploy on push to main only.

The 11 known-broken external links are whitelisted individually in
zola.toml's [link_checker] (curl-verified: 1 intentionally fake example
URL, 1 bot-blocking 403, 3 dead pages, plus skip_anchor_prefixes for
GitHub's user-content- id prefix and genuine anchor drift), so
zola check still fails on any new breakage, external or internal.
Full pipeline verified locally: build, serve, check all pass; routes,
ANSI spans, KaTeX gating, admonitions, CNAME and feed.xml spot-checked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- cpfiffer renamed their GitHub account to just-cameron (profile + bootcamp repo)
- learnxinyminutes-docs renamed julia.html.markdown to julia.md
- pkgdocs renamed the anchor to #Test-specific-dependencies
- julialang.org moved the release anchors to /downloads/manual-downloads/
- Drop the VS Code sysimage callout: the extension removed the feature
  entirely in August 2024 (docs page deleted, useCustomSysimage gone)

The whitelist keeps only the intentionally fake myuser URL, intel.com
(403 to non-browser clients), and the github.com anchor skip
(user-content- id prefix makes README anchors uncheckable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dev workflow now lives in the app itself:
julia --project=tools/ZolaPreprocessor -m ZolaPreprocessor <command>

- preprocess <srcdir> <outdir>: the previous positional CLI, now a subcommand
- serve: preprocess, run zola serve, then poll src/ mtimes and re-preprocess
  changed pages so Zola live-reloads; pages execute in-process, so warm
  re-runs skip package loading and take seconds
- build / check: preprocess, then the zola command, propagating its exit code
- clean: remove content/, public/ and the workdir
- everything after -- is passed to Zola (e.g. serve -- --port 1112 --open)

CI calls the preprocess subcommand directly and keeps its separate zola
build/check steps so the pages execute only once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Remove config.md, utils.jl, _layout/, _css/, _libs/, _rss/, _assets/
(favicon.png and logo.svg were copied to static/assets/ in the Zola
scaffold; the rest was unreferenced), the root Xranklin environment,
and the __site/__cache gitignore entries. Drop the stale hljs comment
in static/css/franklin.css that pointed at the deleted _css/custom.css.

The README/CONTRIBUTING updates for Commit 5 follow separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces peaceiris/actions-gh-pages with actions/upload-pages-artifact and
a separate deploy job using actions/deploy-pages, dropping the contents:
write permission. Requires flipping Settings > Pages > Source to
"GitHub Actions" once; the gh-pages branch can then be deleted.

Also trims PLAN.md down to the remaining work (the README/CONTRIBUTING
updates), deleting the completed Commit 1-4 sections and resolved risks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The preprocessor drops Franklin's `\toc`, so the migrated pages lost their
top-of-page table of contents. The authored markdown now marks the spot with
`<!-- toc -->` (the same places `\toc` held) and the `with_toc` component
splices in a flat list of the level-2 headings from `page.toc` — shortcodes
cannot access the TOC (getzola/zola#584), so the swap happens template-side
on the rendered content. The dead `.franklin-toc` CSS selector is renamed
to `.toc`, matching the emitted markup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A sidebar button (or `/`, Ctrl/Cmd+K) opens a search dialog with two
result tiers: section headings collected from each page's table of
contents, so results link straight to the anchor (Zola's search index
is page-level only and every page here is long), and full-text page
matches from the elasticlunr index, fetched lazily on first use.
Arrow keys move the selection, Enter follows it, and Escape clears
the query before closing the dialog.

Enables `build_search_index` in `zola.toml`; Zola emits the JSON index
and `elasticlunr.min.js` at the site root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Zola's elasticlunr index is page-level, and every page here is long;
Pagefind's sub-results link each full-text match to the heading anchor
it sits under and ship ready-made excerpts, replacing the vendored
`makeTeaser`.

- `js/search.js` lazily imports `/pagefind/pagefind.js` and dedups
  sub-results against the headings tier
- templates scope the crawl via `data-pagefind-body` (with explicit
  title metadata, since titles live in front matter) and exclude the
  TOC via `data-pagefind-ignore`
- CI runs `npx pagefind --site public` after `zola build`; the
  preprocessor's `build` command does the same when `pagefind` is on
  the PATH and only warns when it is not
- `zola.toml` stops emitting the elasticlunr index

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI cannot reliably reach every external site (bot blocking, rate
limits, transient outages), so `external_level = "warn"` keeps flaky
links from failing the build; broken internal links still error. This
replaces the `skip_prefixes`/`skip_anchor_prefixes` whitelist, which is
no longer needed once external links are non-fatal.

Zola swallows the warnings at its default log level, so the workflow
runs `zola check` with `RUST_LOG=info` and surfaces any broken external
links in the job summary plus a workflow warning annotation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fence code can log an error without throwing one -
Base does exactly that when a package extension fails to load -
which strict mode previously let through silently.
An ErrorLogRecorder logger now collects error-level messages
during fence capture and records them as fence errors,
so they fail the build unless the fence is marked allow-error.

Surfaces the StructUtilsTablesExt extension-load error on
sharing/index.md (pkgtemplates1), to be fixed separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each page now executes on its own persistent Malt.jl worker process
whose load path is fixed at spawn:
the page environment, the preprocessor's environment, the stdlib.
Loaded packages, extension triggers, and global state can no longer
leak between pages, and each page sees exactly the environment its
Project.toml declares — the load-path leak that made the optimizing
page's StructUtils extension trigger fail the sharing page's build
is structurally impossible now.

Workers spawn lazily, stay warm across re-preprocesses in serve,
and are reaped explicitly after the build commands.
FenceSyntaxError travels back as a tagged value because Malt
transports exceptions as printed messages only.

Full preprocess cost goes from ~39s to ~51s on a warm machine,
since packages now load once per page instead of once per session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Aqua's deps_compat check requires a compat entry for the Test stdlib,
which PkgTemplates does not generate for MyAwesomePackage.
A hidden fence now patches Test = "1" into the generated
Project.toml — what a real package author should do anyway —
so the aqua fence passes without its temporary allow-error flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Worker isolation made the pages independent, so process_tree now runs
every page's worker at the same time and writes the outputs in sorted
page order once all pages have finished.
A full local build drops from ~51s to ~24s,
bounded by the slowest page instead of the sum of all pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
adrhill and others added 2 commits August 15, 2026 12:52
The pid matches the `[Worker <pid>]:` prefix Malt puts on forwarded
worker output, so build-log lines can be correlated with their page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@adrhill
adrhill marked this pull request as ready for review August 15, 2026 10:57
@adrhill adrhill changed the title Draft: migrate from Franklin to Zola feat!: migrate from Franklin to Zola Aug 15, 2026
@adrhill

adrhill commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

@gdalle approved at the JuliaCon 2026 hackathon. ;)

@adrhill
adrhill merged commit 0bddb42 into main Aug 15, 2026
2 checks passed
@adrhill
adrhill deleted the ah/zola-experiment branch August 15, 2026 11:20
adrhill added a commit that referenced this pull request Aug 15, 2026
Follow-up to the Zola migration (#146), which kept the old Franklin look to keep the diff small.
This PR refreshes the design while trying to keep the "old 2015 blog post" look. ;)

* **Dark mode**: The site now has light and dark themes,
instead of the previous inconsistent theme.
* **CSS refactor**: `franklin.css`, `poole_hyde.css`, `custom.css`, `ansi.css` and `search.css`
(and the inline `<style>` block in `base.html`) are replaced by two files:
  * `css/site.css` — everything but code blocks
  * `css/code.css` — block chrome, language chips, ANSI colors
* **Scrollspy**: the section currently being read is highlighted in the sidebar's section list.
  It marks the last heading above the reading line,
  reusing each heading's `scroll-margin-top` so the threshold matches
  both the desktop and mobile-topbar offsets.
* **In-content TOC** is now hidden whenever the sidebar is on screen,
  since the sidebar already lists the same headings.
  It reappears in the mobile layout, where the sidebar is behind the drawer.
* **Mobile layout**: The stacked mobile layout (sidebar dumped above the content) is replaced by
a fixed top bar with a hamburger button, the site title, and a search button.
* **Logo**: the logo got some minor tweaks and is now also used as the favicon.
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.

1 participant