Development
More actions
This page explains how Wikven is built, for people who want to work on it. Wikven is a MediaWiki extension plus a handful of maintenance scripts that turn a directory of wikitext into a static website; it does not add a new rendering engine, it drives MediaWiki itself and then captures the result. If you are writing an extension or skin that has to survive a build rather than working on Wikven itself, Writing an extension is the page for that.
Architecture
A Wikven build has four moving parts:
- The extension (
extension.json,includes/). It registers Wikven's configuration and a few hooks that adapt MediaWiki's output for a static host: hiding the edit/history UI and the sidebar's wiki-only links, swapping footer links for the configuredWikvenFooterUrl/WikvenEditUrl/WikvenHistoryUrl, and rewriting internal URLs so they point at.htmlfiles instead ofindex.php?title=. - The maintenance scripts (
maintenance/). These are the build pipeline, described below. WikvenSettings.php. TheLocalSettings.phpthe build runs under: it loads the configuration, detects the image backend, and applies Wikven's defaults.- The
bin/entrypointscript andDockerfile. They package MediaWiki plus the extension into an image whose default command runs one build.
Building the image
docker build --tag wikven .
The Dockerfile is FROM mediawiki. It adds Composer (for fetching third-party components at bake time) and the image tools rsvg-convert and ImageMagick's JPEG and WebP delegates, fetches the three extensions Wikven bundles beyond MediaWiki's own (SifterSearch, Translate and UniversalLanguageSelector), copies the extension into /var/www/html/extensions/Wikven, copies WikvenSettings.php into the MediaWiki root, and sets the bin/entrypoint script as the image's entry point.
Running a build
docker run --rm \
-v "$(pwd)/docs:/workspace/src" \
-v "$(pwd)/dist:/workspace/dist" \
wikven
The container mounts the source at /workspace/src and writes the rendered site to /workspace/dist. Both paths derive from WIKVEN_WORKDIR (default /workspace), which also has a .cache/ for ephemeral state; the same layout lets the standalone binary point everything at a writable host directory.
The bin/entrypoint script:
- installs a throwaway MediaWiki into an SQLite database (no web server; the admin account it creates is never used),
- fetches any third-party extensions and skins (see below),
- appends
require_once 'WikvenSettings.php';to the generatedLocalSettings.php, - applies the schema updates a bundled extension asks for that the installer does not create,
- runs the build pipeline,
- hands the output back to the owner of the mounted directory, since the build runs as root.
The build pipeline
maintenance/build.php is one script playing two parts. Run with no environment set, it is the orchestrator: it boots MediaWiki once, fills a throwaway wiki with your content, and then starts a render pass per skin as a separate process, each with WIKVEN_BUILD_SKIN naming its skin. Run with that variable set, the same script is that pass. Every step of either part is a child maintenance script, so the whole build pays MediaWiki's bootstrap cost once per process rather than once per step.
The division is what makes the passes safe to run beside each other: the orchestrator does every write, and by the time a pass starts, the content is final and a pass only reads it. Passes run up to one per available processor.
Phase 1: filling the wiki
checkLuaAgainstThisBuild- Says what this site's Lua and this build's Lua make of each other, before anything else happens. A site that lists Scribunto where no engine can run it ends the build, because it asked for something this build cannot do; a site with
Module:files that never listed Scribunto is only told what those files come to, since with no engine to run them an#invokeis published as the text of the call. It runs first so the last bake is still in place when a site is refused.
clearOutputDirectory- Empties the output directory, so a bake never inherits a file from the last one.
setMainPage- Points
MediaWiki:Mainpageat the importedindexarticle, so the site root resolves toindex.html.
importImages- Uploads the image files in the source directory into the
File:namespace. It runs before the wikitext import, so pages that embed a local image render a real thumbnail instead of a broken-media placeholder.
importWikitext- Imports every
*.wikitextfile (recursing into subdirectories, so a page likeTemplate:Foo/styles.csscan be the nested fileTemplate/Foo/styles.css).File:description pages andMediaWiki:system pages are saved as the current revision so their edit hooks fire; ordinary pages are imported as old revisions. The build aborts here if the main page is missing.
setLicensesPageandsetSettingsPage- Write the two pages a build generates: the Licenses page listing what the site publishes and under which licenses, which the footer links from every page, and the Settings page carrying the reader's colour theme and skin. A source page of either name is used instead of the generated one.
dropDeadPlaceLinksanddropDeadCategoryLink- Blank the system messages behind footer and menu entries that would link pages the export does not contain (privacy policy, disclaimers, community portal).
buildTranslations- Materializes each
<translate>-marked page's translations as real pages, so the job queue and the render passes treat them like any other page. See Translating.
RunJobs- Runs MediaWiki's job queue, mainly thumbnail generation and link-table updates, one job type at a time in name order. The search-index job is held back to the very end, because it is enqueued per revision and would otherwise run once per pass of the queue and throw all but the last result away.
stampSourceHistoryandhideBuildAuthors- Date each page at the commit that last changed its source file, and credit that commit's author, so the footer's "last edited" line agrees with the top of the history it links to. Where the repository's history is out of reach — a shallow checkout, or a source directory that is not one — every page is dated at the commit being built and named to nobody.
freezePageTouched- Pins every page's
page_touched, which is a version input of any module carrying a version callback. This is the last write the database takes; everything after it reads.
Phase 2: one pass per skin
Each pass renders the whole site in its own skin, into its own directory: the first skin in skins renders into the output root, and every other into dist/<skin>/. Each gets a copy of the database to work on, because SQLite takes one writer at a time and a pass still writes to the object cache.
RebuildFileCache- Renders every content page to an HTML file. This is MediaWiki's own file cache; Wikven treats
File:as a content namespace too, so file description pages are exported without dumping every template.
retranslateChrome- Re-renders each page written in a language other than the wiki's own — the translations, and the language copies of the Licenses page the build writes — with that language as the interface language. The file cache only caches the canonical anonymous view, which would leave every one of them wearing the content language's chrome.
stripBuildStamps- Removes the request ids, render ids and timestamps MediaWiki stamps into every rendered page, which differ between bakes.
buildStyles- Re-renders each CSS module the pages reference into a local file, and renders the
site.stylesmodule (MediaWiki:Common.cssand the skin's site CSS) to its own file.
bakeWebfonts- With
WikvenBundleWebfontsset, writes UniversalLanguageSelector's webfonts for the export's languages as a plain stylesheet with the font files beside it. A no-op otherwise.
buildScripts- Dumps the JavaScript the pages need into two files:
startup-static.js(the loader manifest, with its network auto-load removed) andmodules-static.js(the full dependency closure, so every module self-executes). It seeds thesitemodule (MediaWiki:Common.jsand the skin's JS) and any default gadgets, which a live wiki pulls in implicitly.
rewriteScripts- Rewrites the cached HTML to load the local bundle instead of
load.php: it swaps the async startup tag for the static files plus an explicitmw.loader.load(), links the site styles last so they win the cascade, and removes the search box, which needs a live API, unless SifterSearch is providing a static index.
fillMinervaMenu- Writes the site's own navigation into Minerva's main menu in each rendered page. Minerva builds that menu from its own definitions and never reads
MediaWiki:Sidebar, and nothing in PHP can add an entry to it.
storeImages- Copies every referenced image (from Wikimedia Commons via InstantCommons, or from local uploads) to a content-hashed local file and rewrites the URLs, so nothing is fetched over the network at view time.
rename- Gives the namespace-prefixed cache files readable names (for example
ns6%3A...becomesFile:...), and reparents each page's relative links by its own depth.
resolveTranslationLinks- Rewrites
Special:MyLanguage/links over the finished tree, now that each translation is at a known path, so a link leads to the language of the page it sits on.
buildSitemap- With
WikvenSiteUrlset, writessitemap.xmlnaming every page the pass exported, at absolute URLs. A crawler needs absolute URLs, and a build has none to give until a site says where it will be published, so this writes nothing otherwise. A page that asks not to be indexed is left out, since a sitemap is an invitation to index and naming such a page would contradict the page itself; where no page is left, no file is written. Only the pass rendering into the output root writes one, for the same reason: the other passes are previews whose pages carry that refusal.
A pass that renders a non-default skin also copies the search index into its own directory, so a search from that copy answers inside it.
Configuration
Configuration is layered. default.yml ships Wikven's baseline MediaWiki settings; the site's .wikven.yaml is merged on top. WikvenSettings.php reads both with MediaWiki's own YAML settings parser, applies the merged config map, loads the listed extensions and skins, and points $wgLogos at the uploaded WikvenLogos files. It also detects the thumbnailing backend at run time, ImageMagick and rsvg when available, otherwise the built-in GD library plus native inline SVG, so the same build works in the image and in a binary that has only GD.
Why these steps exist
A live wiki relies on load.php, an action API, and a database; a static host has none of these. Each pipeline step removes one such dependency: the file cache replaces the renderer, buildStyles/buildScripts/rewriteScripts replace load.php, storeImages replaces the thumbnail/Commons endpoints, and the extension's hooks remove the UI that would call the API. What is left is plain HTML, CSS, JS, and images.
Third-party extensions and skins
Names in extensions/skins that are not bundled with Wikven are fetched at build time by maintenance/fetchExtensions.php, from a source declared in the WikvenRepositories map (a tarball, a Git repository, or a Composer package). This runs after install but before WikvenSettings.php is wired in, so the components are on disk by the time MediaWiki loads them.
Standalone binary
The same MediaWiki + Wikven tree is embedded into a single FrankenPHP executable (see binary.Dockerfile), so a site can be built with no Docker. The binary extracts its app to a writable temporary directory at startup and runs the same pipeline, which is why all writable paths are kept under WIKVEN_WORKDIR. Its build and serve subcommands are registered by a small Caddy module in caddy/, and bin/build.php is the entry point they run.
Tests
Four suites, each covering something the others cannot:
# PHPUnit, run against a MediaWiki install (CI does this with quibble).
php tests/phpunit/phpunit.php extensions/Wikven/tests/phpunit/unit
php tests/phpunit/phpunit.php extensions/Wikven/tests/phpunit/integration
# Linters and coding standards.
composer phpcs
composer test:comments # the comment budget: tests/comments/Budget.php
composer test # minus-x: file modes and shebangs
# Assertions on a site you have already baked into dist/. Plain PHP, so no install.
composer test:bake
# Browser tests, against a site you have already baked into dist/.
npm ci
npm run test:e2e
- Unit tests (
tests/phpunit/unit/) cover the helper classes that are pure string or path work. - Integration tests (
tests/phpunit/integration/) cover the hooks and the classes that need services around them. - The bake assertions (
tests/bake/) read a site that has already been baked and say whether it is complete and self-contained: noload.phpleft in the CSS, a search bundle in every skin copy, every print-footer link resolving. What is this site's rather than Wikven's -- where it is published, which pages it keeps out of the index, which skins it builds -- is named intests/bake/docs.php, so the same command reads any site's bake.smoke.ymlbakes this documentation site with the real image, runs them, and diffs the whole site against a second bake, because two bakes of one source have to be byte-identical. - Browser tests (
tests/e2e/, Playwright) drive the baked site in Chromium: the skin switcher, the settings page, search in each skin, and Minerva's menu.
The e2e run serves dist/ under a /wikven/ path prefix, the way GitHub Pages does, so the same command works locally and in CI. Point it elsewhere with WIKVEN_BASE_URL.
Continuous integration
lint.ymlruns the formatters and linters (Biome, golangci-lint, mago, rumdl, taplo, typos, yamllint, zizmor, and phpcs), holds every source comment to a word budget, and checks that this site documents every Wikven configuration variable and every step of the build.test.ymlruns the PHPUnit suites, phan and coverage against a real MediaWiki, and builds and tests the Caddy plugin.semantic-pull-request.ymlreads the pull request title, which becomes the squash commit's subject: it has to follow Conventional Commits, and a change to the repository rather than to Wikven —docs/, the readmes,.tf/,.github/— cannot call itselffeatorfix, which release-please would read as something to put in the changelog and move the version for.smoke.ymlbakes this site and asserts the output, in a browser and byte for byte.translations.ymlreports out-of-date translations indocs/, through the check-translations action.docker-image.ymlbuilds the image, and publishes it only when a release or a nightly asks it to: a push tomainbuilds and stops, which is what keeps the layer cache the other image jobs read from going cold. A publish goes from the one build to bothghcr.io/chaotic-ground/wikvenandquay.io/chaotic-ground/wikven, and reads every tag back afterwards, so one that reaches one registry and not the other fails rather than passes. quay.io authenticates through a robot account federated to this repository's OIDC identity, so there is no registry token in the repository secrets.deploy-docs.ymlbuilds thedocs/directory with the image and deploys the result to GitHub Pages, so this site is itself a Wikven build;pr-preview.ymldoes the same per pull request, into a subdirectory that is torn down when the pull request closes.binary.ymlbuilds the standalone binary, on pull requests that touch it and for each platform on a release.release-please.ymlcalls it to attach the binaries to a version release, andnightly.ymlcalls it daily to publish a datednightly-YYYY-MM-DDpre-release. Both create the release as a draft, attach the binaries, then publish it, because GitHub's immutable releases lock a release's assets once it is published.nightly.ymlpublishes the image under that same dated tag, and:latestwith it until a released version takes that over.updatecli.ymlmoves the version pins the image and the docs site carry.tofu.ymlcompares the repository's own settings in.tf/against what GitHub has and comments the difference on a pull request; it applies nothing that would change GitHub, only the imports that adopt what is already there, so a merged change to.tf/is live once somebody runstofu applyand not before.image-diff.ymlcomments on a pull request that repins a container image, linking the commits the two images were built from against each other. A digest names a build and compares to nothing; the images themselves say which commit they came from.
Versions follow Conventional Commits, which release-please uses to generate the changelog and bump the version.