Wikis
W / Documentation

Wikit

The template every wiki here runs on.

Takes a GitHub wiki or the markdown in a repository, renders it once and serves it as a fast, searchable site — exactly like the ones you see here. One folder, one config.php, no database.

Download Wikit .zip
Version
2026-10-15
Size
1.4 MB unpacked · 478 files
Requires
PHP 8.5 · git · intl, mbstring
In five minutes
  1. 01

    Download and unpack

    One folder with everything inside — libraries included.

    unzip wikit-2026-10-15.zip
    mv wikit wiki.example.com
  2. 02

    Set config.php

    Repository, name, one colour. The rest follows.

    repo.owner   = 'you'
    repo.name    = 'notes'
    brand.accent = '#2F6F8F'
  3. 03

    First crawl

    Clones the wiki, renders the pages, builds the index.

    php bin/sync.php
  4. 04

    Keep it fresh

    Cron every fifteen minutes, or a GitHub webhook.

    sudo ./bin/cron.sh install --host
Documentation

A PHP front-end for a GitHub wiki — or for the markdown inside any GitHub repository. It clones the git repository, renders every page once, indexes it for search, and serves the result as a static-feeling site in the International Typographic Style — light and dark.

Built to be forked per repository: copy the folder, edit config.php, run the sync. Nothing else needs touching.


What it does

Crawls git clone/pull of the wiki (<repo>.wiki.git) or of the repository itself
Renders CommonMark + GFM (tables, task lists, strikethrough, autolinks, footnotes) and ```mermaid diagrams
Understands wikis _Sidebar.md navigation, _Footer.md, [[Gollum links]], relative page links, raw <img src="diagram.svg">, GitHub alerts (> [!NOTE])
Indexes per-heading sections → a JSON index on disk (or MySQL FULLTEXT)
Serves pages, home, contents, search page, ⌘K command palette, JSON search API, sitemap, media
Browses /files — the repository's own tree: folders, sizes, dates, syntax-labelled file viewer with line numbers, image preview, links to GitHub blob/raw
Knows git each page shows its real last-edit date and author from the wiki's history

Page HTML is rendered at sync time, so a page view is one indexed row and zero markdown parsing.


Fork it

cp -r wikit wiki.myproject.com        # wikit/ = the unpacked download
cd wiki.myproject.com
cp config.local.example.php config.local.php
  1. config.php — the only file you have to edit:
    • site.* — name, tagline, description, locale, timezone
    • repo.* — owner, name, mode (wiki or repo), optional path, branch, home_page
    • brand.* — one accent colour drives the whole palette; logo_icon is any Iconify name, or point logo_svg at your own file
    • type.* — font families + the Google Fonts spec (or '' to use local fonts only)
    • theme.* — default mode, noise texture, corner radius
    • features.* — toggle search, TOC, backlinks, prev/next, marquee, sitemap…
    • site.language — cs, en or de ship in lang/; i18n in config.php overrides single strings
  2. config.local.php — DB password, sync token, webhook secret, PAT for private wikis.
  3. Storage — nothing to set up by default (db.driver = 'file'). Switch to 'mysql' only if you want it; see Does it need a database? below.
  4. First crawl:
    docker exec php-server php /var/www/wiki.myproject.com/bin/sync.php
    

Wiki mode vs repository mode

repo.mode = 'wiki' (default) crawls github.com/<owner>/<repo>.wiki.git: flat pages, _Sidebar.md navigation, wiki edit links.

repo.mode = 'repo' crawls the markdown inside the repository, nested folders and all. Set repo.path to narrow it ('docs'), or leave it empty for everything.

wiki mode repo mode
Files flat, repository root recursive; node_modules, vendor, dist, build, .github, test(s) skipped
Slugs Cesta-paketu system/docker; a folder's README.md/index.md becomes the folder itself
Navigation _Sidebar.md one group per top-level folder, index page first
Links wiki names resolved against the linking document's own folder, ../ included
Edit link /wiki/<Page>/_edit /edit/<branch>/<path>

The file browser (features.file_browser) works in both modes — in a wiki it lists the attachments, in a repository it is the point. The tree is captured at sync time, together with a last-modified date per path taken from a single bounded git log walk rather than one process per file. Text files render inline with line numbers up to content.file_max_bytes (512 KB); images preview; anything binary or larger links out to GitHub. A page whose slug is literally files still wins over the browser.

Everything opens inside the app: listings, the viewer, and /raw/<path>, which streams a file straight from the clone as an attachment (neutral content type, nosniff, path contained by realpath) — so downloads do not depend on GitHub being reachable or the repository being public. Oversized and binary files, which cannot be shown inline, offer that download instead of a dead end.

Two proxy details, both learned the hard way:

Your own vhost: the extension deny rule must be anchored to a single path segment (^/[^/]+\.(md|json|lock|…)$). Unanchored, it also matches /files/portainer_apps.json and the viewer 403s on exactly the files people came to read.

Somebody else's proxy: hardened edges routinely refuse any request whose path ends in .yml, .sh, .conf, .env, .md, .ini, .log, .sql. That kills a file browser outright, and you may not own the box. So file URLs put the filename in a query — /files/docker/compose/adguard?f=docker-compose.yml — leaving the path extension-free. Directories keep plain paths; they have nothing to trip on. Set content.safe_file_urls = false for bare pretty paths when no such rule is in the way. Changing it needs a re-sync, since links inside articles are baked at crawl time.

Mermaid

GitHub renders ```mermaid fences as diagrams, so this does too. The library is imported only on pages that contain one, and re-renders on the theme toggle so a diagram never sits in the wrong palette. If the CDN is blocked or the diagram is malformed, the original code block comes back rather than an empty frame. features.mermaid toggles it; mermaid.cdn / mermaid.light / mermaid.dark choose the source and the themes.

A folder's article shows the folder

In repository mode, an article that sits in a folder ends with what else is in that folder — files and subfolders, sibling markdown left out because those are pages and already in the navigation. system/docker/README.md is six words of prose followed by the two scripts it is talking about; the root README is followed by the repository's own top level. Nothing to write in the markdown, and it stays right as files come and go. Toggle with features.folder_contents.

Files inside articles

An article can pull a real file in instead of carrying a copy that rots:

![[docker/compose/adguard/docker-compose.yml]]      the whole file
![[docker/compose/adguard/docker-compose.yml#L10-L12]]   an excerpt, real line numbers kept
![[docker/compose/adguard/]]                        a directory listing

Paths resolve against the article's own folder (../ included). Images embed as images, binaries and oversized files show a notice, a missing target says so instead of failing silently, and a directive inside a fenced code block is left alone so you can document the syntax. Expansion happens during the sync, so a page view is still one stored row.

A plain markdown link to a non-markdown file that exists in the repository — [the compose file](docker/compose/adguard/docker-compose.yml) — becomes a file chip linking into the browser, instead of a dead link to a page that was never there. Toggle with content.embeds.

Languages

One site speaks one language. Every interface string lives in lang/<code>.json and site.language picks the file:

cp lang/en.json lang/fr.json      # translate the values, keep the keys
# config.php:  'language' => 'fr'

cs, en and de are included. An unknown code falls back to en, and dates use site.locale (ICU) — empty means "same as the language". To change a single string without touching the file, list it under i18n in config.php; it wins over the JSON.

Mounting under a hub (<hub>/<name>/)

Several wikis can share one host: each fork sits in <hub>/wikis/<name> and the hub's nginx vhost mounts it at /<name>/ (only assets/ from disk, everything else to that fork's index.php). For a new one:

  1. cp -a the template to <hub>/wikis/<name>, and in its config.php set site.base_url to the hub's origin and site.base_path to /<name>/ (sync pre-renders links with it, so re-sync after changing it).
  2. Set hub.url => '/', hub.label to the hub's name and hub.source => dirname(__DIR__, 2) . '/config.php'. That adds the thin accent strip above the topbar, with the hub's tagline and a button that rolls down a small menu listing every wiki on the hub (name and tagline). Its text is black or white, whichever contrasts more with brand.accent.
  3. Add a location block for /<name>/ to the hub's vhost (copy an existing one), and add the wiki to the hub's wikis array ('url' => '/<name>/', 'data' => __DIR__ . '/wikis/<name>').
  4. bin/cron.sh install --container-root /var/www/<hub>/wikis/<name>. Pass the same flag to bin/cron.sh run, because the default container path assumes a top-level vhost.

How the crawl works

It is a git mirror, not an HTML scraper. The first sync runs git clone https://github.com/<owner>/<repo>.wiki.git into data/repo; every later sync is git fetch + git reset --hard, so it only transfers what changed. Then every page is re-rendered and the index is replaced in one transaction. Nothing happens on a page view — requests never touch git or the markdown parser.

Does it need a database?

No. The default file driver keeps the whole wiki in data/index as JSON and searches it with a scored scan. For this wiki — 22 pages, ~15 000 words — that is a 692 KB index and a 2.7 ms search request end to end, and it stays comfortable into the low thousands of pages. Matching is diacritic-folded, so prepinani finds "MAC, ARP a přepínání" and sit finds "Kdo je kdo v síti".

Switch db.driver to 'mysql' when you actually want SQL: a very large wiki, or querying the index from something else. Tables are created on the first sync; the two drivers are behind one interface, so nothing else changes. If MySQL is configured but unreachable at request time, the site quietly serves the file index instead of erroring.

Keeping it fresh

bin/cron.sh manages the schedule — one /etc/cron.d file per site, named after the directory, so forks never collide:

sudo ./bin/cron.sh install --interval 15   # every 15 minutes
sudo ./bin/cron.sh status                  # is it scheduled, and where is the log
     ./bin/cron.sh run                     # crawl once now, exactly as cron would
     ./bin/cron.sh logs -n 40
sudo ./bin/cron.sh remove

Options: --interval <minutes>, --container <name> (default php-server), --user <name> (default www-data), or --host to call PHP directly without docker.

Push instead of poll: point a GitHub webhook for the gollum event at https://<domain>/sync with sync.webhook_secret, or poke it manually with curl "https://<domain>/sync?token=<sync.token>".


Layout of the code

config.php              all branding, features and strings
config.local.php        secrets (git-ignored)
index.php               front controller: /, /<Page>, /search, /contents, /files,
                        /raw/<file>, /api/search, /media/<file>, /sync, /sitemap.xml
includes/
  Sync.php              the crawler: git → render → index
  Git.php               unattended git CLI wrapper
  Navigation.php        _Sidebar.md → groups, order, titles, sections
  Markdown/             CommonMark environment
    Renderer.php        HTML + TOC + sections + excerpt + word count
    LinkRewriter.php    wiki links, images, raw-HTML src/href → site URLs
    CalloutExtension.php  > [!NOTE] → labelled callout
    WikiLinkParser.php  [[Gollum]] links
    AsciiSlugger.php    diacritic-safe heading anchors
  Store/                FileStore | MysqlStore behind one interface
  Text.php              diacritic folding for accent-insensitive search
  Site.php              read-side facade for the views
  Theme.php             config → CSS custom properties
views/                  layout, home, page, contents, search, 404, setup
assets/css/wiki.css     the design system (all colour via custom properties)
assets/js/wiki.js       theme, collapsible columns, palette, scrollspy, copy
bin/cron.sh             install / status / run / logs / remove the crawl schedule
lang/                   cs.json, en.json, de.json — every interface string
data/                   git clone + file index (never served directly)

Notes

  • Search: diacritic-folded scan (file driver) or FULLTEXT boolean mode with a LIKE fallback for short queries (MySQL). Either way results are per-page and jump to the best-matching heading.
  • Renamed home pages: GitHub wikis rename Home.md freely. The sync resolves the landing page (config → common names → first sidebar entry) and aliases the old name, so existing links keep working.
  • The store falls back: if MySQL is unreachable at request time the site serves from the file index rather than erroring.
  • nginx: see nginx.conf.example; only index.php is executable, and data/, includes/, views/, vendor/, bin/ are denied.