Jump to content
Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

Development

From Wikven
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 configured WikvenFooterUrl/WikvenEditUrl/WikvenHistoryUrl, and rewriting internal URLs so they point at .html files instead of index.php?title=.
  • The maintenance scripts (maintenance/). These are the build pipeline, described below.
  • WikvenSettings.php. The LocalSettings.php the build runs under: it loads the configuration, detects the image backend, and applies Wikven's defaults.
  • The bin/entrypoint script and Dockerfile. 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:

  1. installs a throwaway MediaWiki into an SQLite database (no web server; the admin account it creates is never used),
  2. fetches any third-party extensions and skins (see below),
  3. appends require_once 'WikvenSettings.php'; to the generated LocalSettings.php,
  4. applies the schema updates a bundled extension asks for that the installer does not create,
  5. runs the build pipeline,
  6. 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 #invoke is 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:Mainpage at the imported index article, so the site root resolves to index.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 *.wikitext file (recursing into subdirectories, so a page like Template:Foo/styles.css can be the nested file Template/Foo/styles.css). File: description pages and MediaWiki: 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.
setLicensesPage and setSettingsPage
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.
dropDeadPlaceLinks and dropDeadCategoryLink
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.
stampSourceHistory and hideBuildAuthors
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.styles module (MediaWiki:Common.css and the skin's site CSS) to its own file.
bakeWebfonts
With WikvenBundleWebfonts set, 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) and modules-static.js (the full dependency closure, so every module self-executes). It seeds the site module (MediaWiki:Common.js and 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 explicit mw.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... becomes File:...), 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 WikvenSiteUrl set, writes sitemap.xml naming 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: no load.php left 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 in tests/bake/docs.php, so the same command reads any site's bake. smoke.yml bakes 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.yml runs 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.yml runs the PHPUnit suites, phan and coverage against a real MediaWiki, and builds and tests the Caddy plugin.
  • semantic-pull-request.yml reads 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 itself feat or fix, which release-please would read as something to put in the changelog and move the version for.
  • smoke.yml bakes this site and asserts the output, in a browser and byte for byte.
  • translations.yml reports out-of-date translations in docs/, through the check-translations action.
  • docker-image.yml builds the image, and publishes it only when a release or a nightly asks it to: a push to main builds 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 both ghcr.io/chaotic-ground/wikven and quay.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.yml builds the docs/ directory with the image and deploys the result to GitHub Pages, so this site is itself a Wikven build; pr-preview.yml does the same per pull request, into a subdirectory that is torn down when the pull request closes.
  • binary.yml builds the standalone binary, on pull requests that touch it and for each platform on a release. release-please.yml calls it to attach the binaries to a version release, and nightly.yml calls it daily to publish a dated nightly-YYYY-MM-DD pre-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.yml publishes the image under that same dated tag, and :latest with it until a released version takes that over.
  • updatecli.yml moves the version pins the image and the docs site carry. tofu.yml compares 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 runs tofu apply and not before.
  • image-diff.yml comments 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.