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
config.php— the only file you have to edit:site.*— name, tagline, description, locale, timezonerepo.*—owner,name,mode(wikiorrepo), optionalpath,branch,home_pagebrand.*— oneaccentcolour drives the whole palette;logo_iconis any Iconify name, or pointlogo_svgat your own filetype.*— font families + the Google Fonts spec (or''to use local fonts only)theme.*— default mode, noise texture, corner radiusfeatures.*— toggle search, TOC, backlinks, prev/next, marquee, sitemap…site.language—cs,enordeship inlang/;i18nin config.php overrides single strings
config.local.php— DB password, sync token, webhook secret, PAT for private wikis.- 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. - 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.jsonand 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. Setcontent.safe_file_urls = falsefor 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:
cp -athe template to<hub>/wikis/<name>, and in itsconfig.phpsetsite.base_urlto the hub's origin andsite.base_pathto/<name>/(sync pre-renders links with it, so re-sync after changing it).- Set
hub.url => '/',hub.labelto the hub's name andhub.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 withbrand.accent. - Add a location block for
/<name>/to the hub's vhost (copy an existing one), and add the wiki to the hub'swikisarray ('url' => '/<name>/','data' => __DIR__ . '/wikis/<name>'). bin/cron.sh install --container-root /var/www/<hub>/wikis/<name>. Pass the same flag tobin/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.mdfreely. 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; onlyindex.phpis executable, anddata/,includes/,views/,vendor/,bin/are denied.