Wiki Module

The wiki system is built into main. It gives staff a markdown page tree with MediaWiki-style [[Page Name]] linking, automatic page creation from unresolved links, and a generated table of contents.

A wiki page is a plain Slug. There is no separate model, no separate URL resolution, and no separate content type. A wiki page is a Slug with two extra fields set, rendered through a wiki layout instead of the usual template trio. That means wikis inherit everything the slug system already does — path nesting, edit history, sitemap participation — for free.


For staff users

Starting a wiki

Append wiki/create/ to any path in your site:

/wiki/create/                     → a wiki at the site root
/handbook/wiki/create/            → a wiki under /handbook/
/products/gearbox-9/wiki/create/  → a wiki under one catalog item

Whatever path you prefix becomes the parent. The page you create becomes the root of a brand new wiki tree.

You don't plan a wiki's location in advance — you navigate to where it belongs and create it there. If one item in a dynamic content section turns out to warrant its own documentation tree, a spec library or a service history, you make it in place without touching the catalog or its schema.

The base path must resolve to an existing page that isn't already part of a wiki. Pointing wiki/create/ at a page that's already in a wiki gives a 404 — use create/ instead (below).

One special case worth knowing: a wiki started from / gets a genuine top-level page. A wiki named "docs" created at the site root resolves at /docs/, not /home/docs/. The home slug answers / but isn't a namespace everything at the root lives under.

Adding pages

From any wiki page, New Page Here creates a child of that page. The route is the page's own path plus create/:

/docs/installation/create/        → a child of /docs/installation/

New pages inherit the wiki root from their parent, so the whole tree stays one wiki however deep it goes.

Editing

Every wiki page has an Edit button in its header, at the page's path plus edit/:

/docs/installation/edit/

The editor has two fields: the page name and the markdown body. Renaming a page changes its URL, since the name is the path segment.

Edits are history-tracked through the same mechanism as every other Slug — user attribution and a pre-change snapshot on every save. See History and Auditing.

Writing

Standard markdown, rendered by markdown-it. Headings, lists, code fences, tables, emphasis, links.

Headings automatically get anchor ids and populate a table of contents rendered in the page sidebar, in document order. Duplicate headings get numbered anchors so links stay unique.

Linking

Double brackets link another page in the same wiki:

See [[Installation]] to get started.
See [[Installation|the setup guide]] for details.

The pipe form gives custom display text, MediaWiki-style. Names are slugified on resolution, so [[Getting Started]] finds the page at getting-started/.

Links resolve within the current wiki tree only. A [[ ]] link finds pages under the same wiki root — it won't reach into a different wiki or a non-wiki page.

If a [[ ]] target doesn't exist, the link renders in a distinct "missing" style with a tooltip, and clicking it opens the create form with the name prefilled.

The new page becomes a child of the page containing the link — not a child of the wiki root. The page that links to something is the natural place to hang it.

So structure emerges from how you actually write: draft a page, link the pages it should lead to, save, then follow the red links to fill them in.

A practical consequence: where you first link a page decides where it lives. If two pages should both reference a shared page, create it from the one that ought to own it, then link to it from the other.

[[ ]] inside a code span or fenced block is never treated as a link, so you can document the syntax itself without it firing.

Page furniture

Each rendered page shows a breadcrumb up to the wiki root, the table of contents, its child pages, and a maintainers table — everyone who has ever edited the page, each with their most recent activity, newest first. The original author appears there too, badged as creator.

Permissions

Create and edit currently require login only (LoginRequiredMixin), not staff status — the same gap the slug editor has. See Known gaps.

Rate limits: 10/min on starting a wiki, 20/min on page create and edit.


Technical

Where it lives

| Path | Contents | |---|---| | main/models/slug.py | wiki_root, wiki_body, is_wiki_root, is_wiki_page | | main/views/wiki.py | The three views, render_wiki_page(), link resolver, maintainers query | | util/wiki_markdown.py | markdown-it instance, [[ ]] rule, TOC extraction, sanitization | | main/urls.py | Six route patterns | | templates/wiki/ | page.html, create.html, edit.html | | main/migrations/0018_slug_wiki_body_slug_wiki_root.py | The two fields |

The model

Two fields on Slug:

wiki_root = models.ForeignKey("self", null=True, blank=True,
                              on_delete=models.SET_NULL,
                              related_name="wiki_pages")
wiki_body = models.TextField(blank=True)

wiki_root is set on every page in a wiki, including the root itself, which points at itself. That denormalization is deliberate: membership and tree identity resolve in one query instead of walking parent ancestry on every render.

Two properties read it:

slug.is_wiki_page   # wiki_root_id is not None
slug.is_wiki_root   # wiki_root_id == self.id

wiki_body is kept separate from render_template and json, which non-wiki slugs still use. A wiki page ignores the template trio entirely.

Self-referential creation works in a single save. The root sets page.wiki_root = page before save() — the UUID primary key is already populated in memory by its default=, and Postgres checks the non-deferred FK constraint at end-of-statement, by which point the row exists.

URL routing

Six patterns, all above the catch-all:

wiki/create/                      → WikiRootCreateView  (base_path='')
create/                           → WikiPageCreateView  (base_path='')
edit/                             → WikiPageEditView    (base_path='')
<path:base_path>/wiki/create/     → WikiRootCreateView
<path:base_path>/create/          → WikiPageCreateView
<path:base_path>/edit/            → WikiPageEditView

The three bare patterns exist because home's get_absolute_url() is special-cased to / with no path segment of its own, so a <path:...> prefix can never match a wiki hung directly off home.

They match by suffix, so they must sit above the catch-all but are otherwise order-independent relative to it.

Note what this means for the rest of the site: create/ and edit/ are now reserved suffixes on every path. A non-wiki slug named edit nested under another page becomes unreachable, since the wiki route matches first.

Rendering

URL resolution is unchanged. SlugView.get() resolves the path exactly as it always did, then delegates:

if current_slug.is_wiki_page:
    from .wiki import render_wiki_page
    return render_wiki_page(request, current_slug)

The import is function-local to avoid a circular import — wiki.py needs Slug, and slug.py needs render_wiki_page.

wiki.py also duplicates the path-walk as _resolve_slug_path() rather than importing SlugView._resolve_slug, for the same reason. If you change resolution semantics, change both.

_wikilink_resolver(current_slug) returns a closure capturing the page being rendered:

def resolve(target):
    page = wiki_root.wiki_pages.filter(name=slugify(target)).first()
    if page:
        return page.get_absolute_url(), True
    create_url = current_slug.get_absolute_url() + f"create/?name={target}"
    return create_url, False

The lookup is scoped to wiki_root.wiki_pages — the reverse accessor of the denormalized FK — which is why cross-wiki links don't resolve. The create URL is built from the linking page, which is what makes missing links create children of the page that referenced them.

Resolution is recomputed on every render; nothing is stored. Move a page and inbound links follow automatically.

The markdown layer

util/wiki_markdown.py builds its own MarkdownIt instance, independent of the {{ text|markdownit }} filter that ordinary slug content still uses.

[[ ]] is implemented as a real markdown-it inline tokenizer rule, registered before the link rule — not a regex pre- or post-pass. That's what gives it correct code-span and fence handling for free: backticks and fences are consumed as their own tokens earlier in the same pass, so the rule never sees bracket-shaped text inside them.

The render rule takes its resolver from env rather than importing the views, so the module has no dependency on how URLs are built. With no resolver configured it renders inert rather than guessing a URL.

_extract_toc() walks the token stream, assigns unique anchors, and sets the id attribute directly on each heading_open token — so anchors come out of the normal render with no second parse of the HTML.

Output is sanitized with nh3, matching the markdownit filter, with the allowlist extended for the class and title attributes the wikilink rule adds and the id attributes on headings. Without that extension nh3's defaults strip them and both features break silently.

Names and display

Slug.save() only lowercases name; it doesn't slugify. Ordinary pages get short one-word names from whoever creates them. Wiki titles are naturally multi-word, so the wiki views slugify on write — clean URLs, no %20 — and reconstruct a readable label with display_name(), which turns hyphens back into spaces. There is no separate title field.

That round trip is lossy: a page created as "Set-Up Guide" displays as "Set Up Guide".

Maintainers

_editors_for() queries SlugHistory for the page, excluding null users, newest first. last_edit is the most recent row. Maintainers are deduplicated per user, keeping each person's latest activity.

The original author is appended separately, because save_history() is never called on create — a creator who never edited has no history row at all, and shows their creation date instead of an edit date.


Known gaps

Login-only permissions. All three views use LoginRequiredMixin without a staff check, matching the existing TODO on the slug editor. Any authenticated user can create and edit wiki pages. Gate at the proxy or add StaffRequiredMixin until it's fixed upstream.

create/ and edit/ are reserved suffixes site-wide, on every path, not just inside wikis.

No delete. There is no wiki page delete view. Removing a page means the Django admin or the slug delete route — which has its own broken redirect, see Slugs and Pages.

Orphaning on root deletion. wiki_root is on_delete=SET_NULL. Delete a wiki root and every page in that tree has wiki_root nulled, becoming a non-wiki slug with a populated wiki_body that nothing renders. The content survives; the wiki doesn't.

Duplicate path-walk logic in slug.py and wiki.py, as above.

Resolution is per-link, per-render. A page with many [[ ]] links issues one query each. Fine at documentation scale; worth an eye if a page carries hundreds.

Headings with inline markdown show raw markup in the TOC — a deliberate simplification rather than rendering nested inline content out of context.


Next

Pages Here

No sub-pages yet.

Page Info

Wiki: Docs

Created on Sep 21, 2026 by Stuart Anderson

Maintainers

Editor Last Activity
Stuart Anderson creator Sep 21, 2026