Astucia Wiki
A flat-file, self-hosted wiki for teams. No database required — all content is stored as plain files on disk.
What it is
Astucia Wiki combines several content types in one place:
- Markdown pages — rich editor with inline and classic modes, keyboard shortcuts,
{include:ID} transclusion, and live preview
- Diagrams — embedded draw.io editor with automatic SVG preview generation
- Structured lists — spreadsheet-style tables with typed columns, named views, filters, and CSV/XML/JSON export
- Team chat — per-file chat threads with real-time polling, emoji reactions, pinned messages, and #mentions
- AI assistants — per-space AI users (Claude, GPT, or any OpenAI-compatible model) that respond to #mentions in chat and can read and write wiki pages
- AI agent jobs — scheduled or on-demand jobs that run an AI user against a prompt and optionally write the result to a wiki page, plus one-off
/aiJob requests queued from the chat prompt for long, reasoning-heavy work that answers back in the thread when it finishes
- MCP server — exposes the wiki's tools over the Model Context Protocol so external AI agents and MCP clients can list, search, read, write, tag, and traverse related pages
- MCP client — the same protocol in the other direction: register an external MCP server once and your AI users can call its tools alongside the built-in
wiki_* ones, so an AI user can search vendor documentation, query a ticket tracker, or read another Astucia Wiki while answering
- Knowledge graph — interactive graph of how pages connect, combining explicit
pageid links, folder hierarchy (parent/child/sibling pages), and shared tags; view the whole space or focus on one page's neighbourhood, with backlinks and related-page discovery
- File attachments — drag-and-drop upload stored per-page, plus dedicated file-library folders
- Full-text search — keyword search and tag cloud across all content; optional SQLite FTS5 engine adds relevance ranking, highlighted snippets, prefix matching, and cross-space search; an advanced search builder saves reusable queries as pages
- Page chat — each Markdown page can have its own chat thread alongside the content
- AI memory — AI users can remember what they learn, as ordinary Markdown pages in the space's
memory/ folder: one fact per page, listed back to them on every run, and readable, correctable and deletable by anyone who can read the space
- Navigation & discovery — file tree and folder-browse panes, per-page table of contents, backlinks, and "my mentions" / "my comments" views
- Page sharing — email a link to any page (when email is configured)
- Daily updates — opt-in daily digest email summarising pages created or updated in the last 24 hours across the spaces you can access
- Mobile support — responsive layout with a dedicated mobile Markdown editor and compact toolbar; switch between desktop and mobile views from the sidebar
- Spaces — isolated workspaces for different teams or projects, with per-user access control
- Personalization — per-user preferences (font, font size) and a saved/favourite pages list
- Localization — UI available in English, Danish, Swedish, Spanish, French, and German; users pick their language from the sidebar
- Git integration — optional version history per file; pages and diagrams auto-commit on save, chats and lists support manual snapshots
Authentication is optional. When enabled it uses OIDC (tested with Auth0) with three roles: admin, editor, and reader.
Quick start
git clone https://github.com/madsrg/astucia-wiki.git
cd astucia-wiki
composer install
cp config.php.txt config.php # edit config.php before starting
php -S localhost:8000
Open http://localhost:8000. No database, no build step.
Docker
One container (nginx + PHP-FPM + cron), with all pages, settings and logs on the host so
a backup is a tar of one directory and deleting the container loses nothing.
The image, the current tags and how to run it live on Docker Hub:
madsrotwitt/astucia-wiki
DOCKER.md covers configuration, management, cron, backup/restore, and
putting it behind nginx with Let's Encrypt.
Configuration
config.php.txt is a template committed to the repository. Copy it to config.php and fill in your values before first use. Because config.php is excluded from version control, running git pull to update the wiki will never overwrite your live configuration.
The minimum required settings are PAGES_DIR (where wiki content is stored) and APP_TITLE. Everything else — authentication, email, logging — is optional and disabled by default.
Authentication
Authentication is optional and off by default. AUTHENTICATION in config.php selects the method:
| Value | Behaviour |
|---|
'off' | No authentication — every visitor has full admin access (the default) |
'oidc' | OIDC / OAuth2 only (tested with Auth0, Keycloak, Okta) |
'otp' | One-time codes by email; users are added manually to users.json |
'both' | OIDC and OTP side by side; each user record carries an auth field |
For 'oidc' or 'both', also set the four OIDC constants (OIDC_PROVIDER_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI). AUTHENTICATION_ENABLED still exists as a legacy shim, but it is derived from AUTHENTICATION — don't set it by hand.
Bootstrapping the first admin
Because you have server-side access during setup, the easiest way to create the first admin account is:
- Enable authentication and open the wiki in a browser.
- Log in with your identity provider. You will land on an "Access pending" page — that is expected. The wiki has written your OIDC
sub, name, and email into user_requests.json on the server.
- On the server, open
user_requests.json and find your entry. Copy the sub, name, and email into users.json (create it if it doesn't exist), add "role": "admin" and "spaces": null:
{
"users": [
{
"sub": "<paste sub from user_requests.json>",
"name": "Your Name",
"email": "[email protected]",
"role": "admin",
"spaces": null
}
]
}
- Log in again — you are now admin and can approve or deny all future user requests from the Admin panel.
No need to look up your sub ID in the provider dashboard; the wiki captures it automatically on first login. After bootstrap, users.json is managed entirely through the Admin panel.
AI assistants and system users
The admin panel (gear icon, Users tab) lets you create two kinds of non-human users.
AI users
An AI user is a bot that participates in chat threads. When a regular user #mentions it, the wiki sends the conversation to a language model and posts the reply automatically. The bot's role controls page-writing access: an editor AI user can create and overwrite pages; a reader AI user can reply in chat and read pages but cannot write.
Configuration per AI user:
| Field | Description |
|---|
| Name | The #mention handle that triggers the bot |
| Provider | One of the entries in llm_providers.json — openai (Chat Completions, default), openai-responses (OpenAI Responses API), or anthropic |
| API URL | Leave blank for the provider's default endpoint, or set a custom endpoint for self-hosted / compatible models |
| Model | e.g. gpt-4o, claude-opus-4-8, or any model string accepted by the endpoint |
| API key | Stored server-side only — never sent to the browser |
| System prompt | Optional instructions that set the bot's persona or constraints |
| Context messages | How many recent chat messages to include in each request (default 20) |
| Max tokens | Maximum length of the model's reply (default 4096) |
| Temperature | Sampling temperature, 0–2 (default 0.7) |
The available providers come from llm_providers.json in the project root. Each entry maps a provider id to a response family (openai-chat, openai-responses, or anthropic — the three wire formats the wiki knows how to build and parse), a display label, and a default endpoint. To add a provider variant that speaks one of those families (a different endpoint, a compatible service), add an entry to that file — no code change. The wiki also self-corrects common OpenAI drift automatically: it picks max_tokens vs max_completion_tokens per endpoint and retries if the API asks for the other, and drops an unsupported custom temperature for reasoning models.
AI users also receive a service token (wk_ai_…) that can be used to call the API on their behalf from external scripts.
AI users (and MCP clients — see below) can use the tools the wiki exposes:
wiki_list_pages — lists all pages in the current Space, including their tags
wiki_search_pages — searches pages by topic, recency, and/or tags
wiki_read_page — reads the content of any .md, .list, or .chat file
wiki_write_page — creates or overwrites a .md page (editor role only)
wiki_related_pages — finds pages related to a given page via the knowledge graph (links, folder hierarchy, and shared tags)
wiki_add_tags — adds tags to a page without removing existing ones (editor role only)
wiki_set_tags — sets or clears the tags on any page (editor role only)
System users
A system user is a headless account for external integrations (scripts, CI pipelines, other services). It has no AI config — it authenticates via a service token (wk_sys_…) sent as Authorization: Bearer <token> and can call any API action its role permits.
Create a system user in the admin panel, copy the generated token, and use it in your integration. Tokens can be regenerated at any time.
MCP server
The wiki also speaks the Model Context Protocol. Point any MCP client (Claude Desktop, IDE agents, custom tooling) at mcp.php and it can call the same wiki_* tools listed above over JSON-RPC 2.0 (Streamable HTTP transport). Authentication uses the same service tokens as the REST API — an AI user's wk_ai_… token or a system user's wk_sys_… token as Authorization: Bearer <token>. Add ?space=SpaceName to target a specific Space (omit for the default). Role and per-Space access control apply exactly as they do everywhere else. An in-app MCP Tool Explorer (admin/editor sidebar) lets you try the tools interactively.
MCP client
The wiki is also an MCP client, so the tools an AI user can reach are not limited to the
wiki's own. Register a server once in Admin → MCP Servers (stored in
WIKI_SYSTEM_DATA/mcp_servers.json) and tick it on each AI user that should be allowed to
call it; its tools then appear beside the wiki_* tools in the same agent loop. This works
on all three AI paths — an inline chat reply, a one-off /aiJob, and a scheduled agent job.
- Transport — JSON-RPC 2.0 over HTTP (the Streamable HTTP handshake:
initialize, any
Mcp-Session-Id the server assigns, notifications/initialized, then tools/list and
tools/call). Stateless servers that return no session id work unchanged.
- Authentication — an optional token, sent as
Authorization: Bearer <token> by
default. Both parts are configurable, so a server wanting X-API-Key: <raw token> is a
matter of setting the header name and clearing the scheme. Arbitrary extra headers can
be sent alongside it, for a gateway such as a Cloudflare Access tunnel that needs
CF-Access-Client-Id / CF-Access-Client-Secret. Test Connection on the server form
lists the tools it advertises.
- Remote tool names are namespaced as
servername__toolname, so a remote tool can never
collide with a built-in wiki_* tool or with another server's tool of the same name —
which matters immediately if you point one Astucia Wiki at another's mcp.php.
- Instructions live with the server, not with each AI user. A server carries Shared
instructions describing itself ("dates are ISO-8601", "call
list_projects before
get_project") that are sent to every AI user that enables it; an AI user can add a note
covering only what is true of that AI ("you are read-only here"). Both are sent when both
are filled in, server first — so ten AI users sharing a server need one copy of the text,
edited in one place.
src:<slug> forces a server. Putting src:jira in a chat message or an agent-job
prompt restricts that reply to that server's tools — a deterministic alternative to the
free-text instructions, which only hint.
- Saved searches can query an MCP server. A
.search page can run against a remote
server instead of the local index; the search tool and its query argument are auto-detected
(a tool named like search/find/query, an argument named query) or named explicitly.
Marking a server as "This server is an Astucia Wiki" additionally enables tag: and
updated: filters and renders native page results, which is federated search across two
wikis.
AI agent jobs
An agent job runs an AI user against a prompt in the background, with the wiki tools available, so it can read and write pages on its own. There are two kinds:
- Scheduled jobs — created in Admin → AI → Agent Jobs, running daily, weekly, or monthly. Use these for recurring work: a weekly summary page, a nightly link audit. "Run now" also runs one on demand.
- One-off jobs — queued by any editor or admin straight from a chat prompt with
/aiJob #AiUser your request. Use these for a request that is too slow or too involved for an inline chat reply: the AI user is asked to reason at length, so the job may take many minutes. Submitting only confirms acceptance ("job accepted — starts in about N minutes"); the answer arrives as a normal chat message in the same thread when it is done, and optionally by email (My Preferences → notify me when my AI jobs finish). Readers cannot queue jobs, and each person may have three waiting at a time.
Both kinds are executed by one CLI script run from cron. Without this entry, scheduled jobs never fire and one-off jobs are accepted but never start (the chat prompt will say so, and Admin → AI shows a warning):
sudo -u www-data crontab -e
*/2 * * * * php /path/to/run_ai_agent_jobs.php >> /var/log/wiki-agent-jobs.log 2>&1
Keep AGENT_JOB_RUNNER_INTERVAL_MINUTES in config.php equal to the cron interval — it is what the "starts in about N minutes" estimate is calculated from, so a mismatch produces a wrong estimate. Each run executes at most three one-off jobs; the rest wait for the next tick. AGENT_JOB_RUNNER_SLOTS (default 2) is how many runs may overlap: with one slot the queue is serial, so a slow job delays every job behind it, and each extra slot is another simultaneous LLM call. Per-run logs are written to LOG_DIR/agent-jobs/, viewable in the admin panel, and failures are emailed to ADMIN_EMAIL.
Which model an AI user runs matters here: reasoning-capable models (Claude Opus/Sonnet 4.6 and newer, OpenAI o-series and GPT-5) are asked to think at length for one-off jobs, while older models simply run the prompt normally. Per-model request rules live in llm_providers.json under model_rules and can be adjusted without code changes.
AI memory
An AI user can keep what it learns. Switch it on in two places — Space settings → AI decides whether that space keeps memories at all, and Admin → AI Users → Learning decides whether a given AI keeps any (with a wiki-wide default for "use the house setting"). Nothing else to configure: no separate memory store, no vector database, no service token.
A memory is a page. They live in a memory/ folder inside the space, one fact per page, created the first time something is remembered:
Team/
memory/
Deploys go out on Thursday afternoons.md
Rita owns the billing integration.md
Each one carries who formed it and when, and is tagged rather than filed into subfolders — a wrong tag still turns up in search, while a wrong folder is a fact that is never found again. The AI sees the list of titles in every run, so it knows what it knows, and reads the full text of one only when it matters.
Because memories are pages, you can open the folder and read exactly what an AI believes, correct it, or delete it; the changes are in git like any other edit. They are deliberately kept out of ordinary search results, the knowledge graph and mention notifications, so a few hundred remembered facts do not drown everything else.
Two properties worth knowing:
- Memories never leave the space they were learned in. There is no shared pool — an AI working in one space cannot read what was learned in another, which keeps the push to remember things from quietly becoming a way around Space permissions.
- Nothing is remembered silently. The AI is told it may record decisions, preferences and conventions, and every write is an audited page write. Switching Learning off for one AI overrides the wiki-wide default, so an AI you exempt stays exempt.
Realtime updates
Turned on with ENABLE_REALTIME, the wiki pushes changes over a single SSE connection instead of leaving the UI to poll: a chat message, a page edit or a finished agent job reaches an open browser in a fraction of a second rather than after the next tick. Every poller keeps its timer as a slower safety net, so ENABLE_REALTIME=false behaves exactly like every release before the feature existed.
Push is served by a Mercure hub. The Docker image ships one and starts it automatically. On a bare install, tools/install-mercure.sh does the whole job — it downloads the release and verifies its checksum, creates a locked-down service account, generates the shared key, writes the hub config and a hardened systemd unit, starts it and checks that it answers:
sudo ./tools/install-mercure.sh 1.1.5
It shows what it is about to do and waits for a key press (MERCURE_ASSUME_YES=1 skips that for unattended runs), prints every path it writes, and leaves the config.php constants (key included) in /etc/mercure/wiki-config-snippet.php along with the nginx block you still need. Re-run it with a newer version to upgrade in place; the key is never regenerated, since config.php holds a copy.
One hub serves one wiki. Every wiki publishes the same topic names, so two wikis sharing a hub would receive each other's change events, and with one key either could sign tickets the other accepts. To run several wikis on one machine, give each its own instance:
sudo MERCURE_INSTANCE=team ./tools/install-mercure.sh 1.1.5
A named instance gets its own config directory (/etc/mercure-team/), service (mercure-team), service account, key, port (the first free one from 3001), and issuer, audience and cookie name — so copy all of its snippet into that wiki's config.php, not only the key. Each wiki also needs its own hostname, since the hub is always reached at /.well-known/mercure. Without MERCURE_INSTANCE the script works on the default instance, exactly as a single-wiki install always has. Upgrading upgrades every instance: they share one binary, so the script finds them all, regenerates each one's configuration from the settings it was installed with, and restarts the ones that were running.
The hub must be Mercure 1.0.0 or newer, and you want 1.1.5. 1.0 replaced the protocol's bespoke JWT claim with an ordinary OAuth 2.0 access token, and the wiki signs those — a 0.x hub answers 401 to every publish. The installer refuses a 0.x version rather than leaving you with a hub that looks healthy and delivers nothing, and warns if you ask for anything older than 1.1.0, the newest release fixing something reachable in this setup. (1.0.1 was tagged but never built, and 1.1.3 tagged but never released.) Upgrading an existing install means re-running the script and adding the three constants it now writes into the snippet (MERCURE_ISSUER, MERCURE_RESOURCE_ID, MERCURE_COOKIE_NAME); the hub and the wiki have to agree on all three. Docker installs need nothing: the image ships the hub and configures both sides. Because an SSE stream occupies one of the browser's ~6 connections per origin, realtime wants HTTP/2 — that is, TLS at your reverse proxy. It works over HTTP/1.1, but a few open tabs will exhaust the connection budget.
Events are hints, not payloads. An event says "this thread changed, re-read it"; it never carries the message. Reading still goes through the API and its guards, so nothing bypasses Space or role permissions, and a subscriber that missed events while disconnected is correct again as soon as it re-reads.
Subscribing from a system user
An integration subscribes with the same wk_sys_… service token it already uses for the REST API. Ask for a ticket, then open the stream with it:
# 1. Mint a subscriber ticket (scoped to the Spaces this token may read)
TICKET=$(curl -s -H "Authorization: Bearer $WIKI_TOKEN" \
"https://wiki.example.com/api.php?action=realtime_ticket" | jq -r .token)
# 2. Stream events. 'wiki/*' is a URL Pattern meaning "everything the ticket allows".
curl -N -H "Authorization: Bearer $TICKET" \
--data-urlencode 'match_urlpattern=wiki/*' -G \
"https://wiki.example.com/.well-known/mercure"
The stream then stays open, one event per change (the leading : is the hub's keep-alive):
:
id: urn:uuid:01a082bc-a23e-7e5c-a9af-02c3f1c78237
data: {"type":"page","space":"Main","path":"Notes.md","change":"create","topic":"wiki/Main/page/Notes.md","ts":1788899795}
id: urn:uuid:01a082bc-a246-7e5c-a305-fbef5f1b8a89
data: {"type":"tree","space":"Main","topic":"wiki/Main/tree","ts":1788899795}
A runnable version with reconnection and ticket renewal is in examples/watch_wiki.py — standard library only, no SSE client needed:
WIKI_URL=https://wiki.example.com WIKI_TOKEN=wk_sys_... ./examples/watch_wiki.py
There are five topics, and that set is the contract:
| Topic | Published when |
|---|
wiki/{space}/chat/{path} | a message is posted, edited or deleted, or an AI placeholder resolves |
wiki/{space}/page/{path} | a page is saved, created, renamed or deleted |
wiki/{space}/tree | the file tree changes, including from an external edit to PAGES_DIR |
wiki/user/{uid}/job | one of that user's background jobs changes state |
wiki/user/{uid}/mention | that user is mentioned |
Notes for integrators:
- The ticket is the permission. It is minted from the token's Space allowlist and the hub enforces it, so a token restricted to one Space receives only that Space's events — asking for
wiki/* cannot widen it.
- Send the ticket as a bearer header. Mercure 1.0 removed the
authorization query parameter outright, so a token in the URL is no longer accepted anywhere.
- Tickets expire (
REALTIME_TICKET_TTL, one hour by default). Mint a fresh one when the stream drops; that is also what makes revoking a Space take effect.
- Re-read on reconnect. There is no replay buffer, by design — refetch whatever you are tracking when you reconnect and you cannot have missed anything.
- The hub is normally reverse-proxied under the wiki's own origin, which is why the ticket response reports a relative
url.
Diagrams from text
Write a sequence diagram, flowchart, state chart, ER diagram or gantt chart as a fenced code block in any Markdown page and it renders as a diagram when you read the page:
```mermaid
sequenceDiagram
autonumber
Alice->>Bob: Request
Bob-->>Alice: Response
```
The Insert menu in the editor has starter blocks for sequence diagrams and flowcharts. Because the diagram is just text in the page, it lives in that page's version history, it shows up in search by its own labels, and editing it is editing the page — no separate editor and no separate file to keep in sync. A page containing only a diagram can be embedded in any other page with {include:ID}, which is the way to reuse one diagram in several places. Diagrams also render in exported static sites.
If a diagram has a syntax error, the page shows the error together with the source that caused it rather than dropping the block, so it can be fixed in place. Draw.io diagrams (.drawio files) remain available for anything better drawn by hand.
Knowledge graph
Every Space can be viewed as a knowledge graph — an interactive map of how its pages relate. It layers three kinds of relationship:
- Links — explicit
?pageid= references between pages (directed edges)
- Folders — the file hierarchy, so a page sits next to its parent, children, and siblings even before anyone cross-links them
- Tags — pages that share tags are drawn together
Open the whole-space map from the sidebar (graph icon), or focus on the current page's neighbourhood from the page header. Nodes are coloured by top-level folder and sized by how connected they are; each edge type can be toggled on or off, and clicking a page navigates to it. The relationships also power backlinks, related-page discovery, and the wiki_related_pages tool for AI/MCP clients.
The graph is derived from the page index and cached per Space (graph.json); it refreshes incrementally as pages change and rebuilds fully on a manual reindex — no separate maintenance step is required.
Git version history
If the content directory (or PAGES_DIR itself) is a git repository, Astucia Wiki will automatically commit changes to it:
- Pages and diagrams auto-commit on every save (per-file toggle to disable).
- Chats and lists are off by default — enable tracking per file and use the Commit snapshot button in the header to save a manual checkpoint whenever a conversation or dataset is worth preserving.
The clock icon in the header opens the full commit history for the current file.
To initialise git tracking for your content, run as the web-server user (typically www-data) so that subsequent auto-commits have the right ownership:
sudo -u www-data git -C /path/to/your/PAGES_DIR init
sudo -u www-data git -C /path/to/your/PAGES_DIR add .
sudo -u www-data git -C /path/to/your/PAGES_DIR commit -m "Initial content"
Editing content outside the wiki
Because content is plain files, you can work on it however you like — rsync a folder in, git pull on the content repository, run a script, edit with a desktop editor over a network share. Astucia Wiki notices: added, removed and externally edited pages are reconciled with the page index, the knowledge-graph cache and the search index automatically, and the file tree refreshes on its own.
The page you are looking at reloads itself when its file changes underneath — Markdown pages, lists and data pages, within about ten seconds. If you are editing, nothing is touched: you get a notice that the page changed on disk instead, because saving would otherwise overwrite that change without warning.
There is no cron job and no filesystem watcher to install — the check runs as part of ordinary requests, is skipped unless INDEX_SYNC_INTERVAL_SECONDS (default 30) has elapsed since the last look, and is shared safely between simultaneous visitors. It also runs on the MCP endpoint, so an AI client reconciles before it reads rather than answering from a stale index. Existing pages keep their IDs, so ?pageid= links and bookmarks survive.
Lower INDEX_SYNC_INTERVAL_SECONDS to react sooner, raise it to do less work on a very large content directory. ?action=indexfiles still forces a full rebuild on demand.
Localization
The UI is available in English, Danish, Swedish, Spanish, French, German, Simplified Chinese, and Hindi. Logged-in users pick their language from My Preferences; anonymous / no-auth visitors get a language selector in the sidebar instead. The choice is stored in the browser. Adding a new language requires one new file in modules/i18n/locales/ — copy en.js, translate the strings, and add the language code to SUPPORTED_LANGUAGES in modules/i18n/index.js.
Translations don't have to be complete: any key a locale is missing falls back to English, so the admin panel is intentionally English-only.
Daily updates
Users can opt in to a daily digest email from My Preferences → "Subscribe to daily updates" (shown when email is configured). Once a day it summarises pages created or updated in the last 24 hours across every space the user can access, excluding their own edits, capped at the 20 most recent changes and grouped by space.
Delivery is driven by a CLI script run from cron — the same pattern as AI Agent Jobs. Edit the web-server user's crontab (www-data on Debian):
sudo -u www-data crontab -e
and add one entry to run the digest once a day, e.g. 07:00:
0 7 * * * php /path/to/run_daily_digest.php >> /var/log/wiki-digest.log 2>&1
A user's crontab isn't a file in their home directory — it lives in the cron spool (on Debian, /var/spool/cron/crontabs/www-data); always edit it via crontab -e rather than by hand. Running as www-data keeps file and email permissions consistent with the rest of the wiki.
Email links use APP_BASE_URL from config.php (falling back to the origin of OIDC_REDIRECT_URI), since a cron run has no web request to derive the host from.
Documentation
Full documentation, feature guides, and a step-by-step installation guide for Debian with Nginx and PHP are available at:
https://astucia.wiki
Tech stack
- Backend: PHP 8.0+, no framework; content stored as plain files (no database required; SQLite optionally used for the search index)
- Frontend: Vanilla ES modules, no bundler
- Auth: OIDC via
jumbojett/openid-connect-php (optional)
- Email: SendGrid or Mailgun via HTTP API (optional)
- Diagrams: Embedded draw.io
- Markdown: marked.js (CDN)
- Realtime: Mercure hub over SSE (optional; bundled in the Docker image)
- Static export:
export_static_site.php — generates a self-contained HTML site from all pages
License
Copyright (C) 2026 Mads Rotwitt
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
The full licence text is in LICENSE (GPL-3.0-or-later).
In short: you may use, study, modify and redistribute it, including commercially. The
obligations attach to distribution — if you give others a copy of a modified version,
they must be able to get the corresponding source under the same licence, with copyright
and licence notices intact. Hosting it as a service, where users never receive a copy, is
not distribution under GPL v3 (that would be the AGPL). See the
licence page for a plain-language summary.