- Python 44.9%
- CSS 34.7%
- HTML 14%
- JavaScript 4%
- Just 1.8%
- Other 0.6%
| deploy/rc.d | ||
| lugdoc | ||
| tests | ||
| tools | ||
| .gitignore | ||
| .python-version | ||
| config.toml | ||
| justfile | ||
| package-lock.json | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
lugdoc
A documentation site for community groups, originally built for the Canberra LEGO Users Group. Documents are written in AsciiDoc, stored in a separate content repository, and rendered server-side. Features include group-based visibility controls, magic link email authentication, full-text search, version history, diffs, PDF export, and a theming system.
Built with FastAPI, HTMX, Jinja2, asciidoctor, and Tailwind CSS v4.
Requirements
| Requirement | Notes |
|---|---|
| Python 3.12+ | |
| uv | Package and virtualenv management |
| asciidoctor | Ruby gem — gem install asciidoctor. Required for document rendering. |
| Chromium | For PDF export; must be a system install |
| Git | For version history and content refresh |
| tailwindcss | Standalone binary — only needed locally when changing templates |
Local development
1. Clone and install:
git clone <app-repo-url> lugdoc-py
cd lugdoc-py
uv sync
2. Point to a content repo:
git clone <docs-repo-url> ../sample-docs
3. Create config.local.toml (gitignored, overrides config.toml):
[site]
name = "My Docs (dev)"
port = 8000
base_url = "http://localhost:8000"
theme = "default"
[content]
docs_repo_path = "../sample-docs"
cache_ttl_seconds = 5
[features]
show_drafts = true
[auth]
stub = true # replaces magic link flow with a user picker — no email needed
users_file = "users.toml"
session_secret = "dev-secret-not-for-production"
4. Create users.toml:
[[user]]
email = "you@example.org"
groups = ["committee"]
5. Start the dev server:
just dev
Available at http://localhost:8000. With stub = true, the sign-in page shows a
dropdown of all users from users.toml — select one and submit to sign in instantly.
Content repository
The docs repository is separate from the app repository. Minimal structure:
docs-repo/
nav.toml ← section ordering and labels
positions.toml ← organisational positions (optional)
docs/
section/
my-doc.adoc
image.png ← assets co-located with documents
Document front matter
Every .adoc file needs these header attributes. Files without :slug: are ignored.
= Document Title
:slug: unique-kebab-case-slug
:state: published
:visibility: public
:section: section
:authors: Author Name
:owner: position-id
:updated: 2026-01-01
:description: One sentence summary for search results.
| Attribute | Notes |
|---|---|
:slug: |
Globally unique, kebab-case. Never change — this is the stable URL key. |
:state: |
draft or published |
:visibility: |
public, private (any authenticated user), or comma-separated group names. Multiple groups are a union — belonging to any listed group is sufficient. Omitting fails closed: inaccessible to everyone. Prefer explicit group names over private. |
:section: |
Controls nav placement. Supports multiple levels: shows/woden |
:authors: |
Comma-separated list of document authors |
:owner: |
Optional. Position id from positions.toml (e.g. asset-coordinator) |
:updated: |
ISO date YYYY-MM-DD |
:description: |
Shown in search results and index pages |
nav.toml
Controls the order and contents of the navigation. Items appear in the order they are
listed in the file — no order field needed.
Each [[item]] is one of:
section = "path"— a section (top-level or nested, identified by path)doc = "slug"— a single document placed explicitly between sectionsdoc = "*"— a catch-all placeholder for all remaining unsectioned docs
[[item]]
section = "governance"
label = "Governance" # optional label override
[[item]]
doc = "welcome" # single doc placed between sections
[[item]]
section = "operations"
[[item]]
section = "operations/finance"
[[item]]
section = "operations/hr"
[[item]]
doc = "*" # remaining unsectioned docs go here
# omit to hide unsectioned docs from the nav
Sections not listed are appended alphabetically after any explicitly listed ones.
Docs with no :section: only appear in the nav if explicitly listed by slug or via doc = "*".
positions.toml
Defines organisational positions for document ownership and group membership. Optional.
[[position]]
id = "president"
label = "President"
doc = "committee-roles" # optional: slug of the role description doc
groups = ["committee"] # optional: groups automatically granted to the holder
[[position]]
id = "secretary"
label = "Secretary"
vacant = true # suppresses "no user assigned" warning
groups = ["committee"]
[[position]]
id = "general-committee-member"
label = "General Committee Member"
max_holders = 8 # optional: max simultaneous holders (default 1)
groups = ["committee"]
Assign a user to a position in users.toml via position = "president". Multiple users
can share a position when max_holders is set above 1.
Groups granted by a position are computed live on every request — no re-login needed when
positions.toml changes.
Authentication
lugdoc uses magic link email authentication. Users are defined in a users.toml file
that lives outside both repositories (it contains email addresses).
[[user]]
email = "bruce@wayne-enterprises.com"
groups = ["gotham-show"] # explicit groups (optional if position grants all needed groups)
position = "batman" # optional: links user to a position in positions.toml
A user's effective groups are the union of their explicit groups and any groups defined
on their position in positions.toml. Both are re-evaluated on every request — removing a
user from users.toml or changing a position's groups takes effect immediately with no restart.
Theming
Themes live in lugdoc/themes/<name>/. A theme is just a directory containing a
theme.css file of CSS custom property definitions — no build step required. Themes
are loaded directly by the browser as a <link> tag and are never bundled into the app.
| File | Purpose |
|---|---|
theme.css |
CSS custom property tokens (required) |
pdf.css |
PDF overrides; layered on top of default/pdf.css, so only rules that differ from the default need to be defined (optional) |
fonts.css |
@import url() lines; injected as <link> tags at startup (optional) |
public/logo.* |
Auto-detected as the theme logo (optional) |
Set the active theme in config.toml:
[site]
theme = "mytheme"
To keep a custom theme in its own repository, symlink it into lugdoc/themes/:
# on the server, alongside the app clone:
git clone <theme-repo-url> /var/lugdoc/mytheme
ln -s /var/lugdoc/mytheme /var/lugdoc/app/lugdoc/themes/mytheme
This lets you git pull the theme independently from the app. The app directory
structure becomes:
/var/lugdoc/
app/lugdoc/themes/
default/ ← ships with the app
mytheme/ ← symlink → /var/lugdoc/mytheme/
mytheme/ ← your theme repo
Adding or updating a theme requires no rebuild — changes to theme.css take effect
on the next page load.
Admin CLI
The just recipes wrap a CLI tool that reads your config automatically:
just check-docs # audit visibility: unset fields, unknown groups, use of 'private'
just check-owners # audit :owner: fields, vacant positions, and overfilled positions
just check-groups # check for groups in docs not assigned to any user
just list-groups # list all group names used across docs
just list-users # list all users and their groups
just check-user email=you@example.org # show what a specific user can and can't see
just pull-content # manually git pull the docs repo
All check-* commands exit non-zero on issues — suitable for CI.
CSS
lugdoc/static/dist.css is pre-compiled and committed to the repository. You only need
the tailwindcss binary if you change template utility classes:
just css-build # recompile dist.css; commit the result
just css # watch mode during development
Themes are pure CSS custom properties loaded at runtime — adding or changing a theme never requires a CSS rebuild.
Production deployment — FreeBSD
Directory layout
/var/lugdoc/
app/ ← app repo (git clone)
docs/ ← content repo (git clone)
themes/ ← custom themes (optional; may live elsewhere)
config.toml ← production config (outside repos, survives git pull)
users.toml ← user definitions (outside both repos — contains PII)
/usr/local/etc/rc.d/lugdoc ← service script
/var/log/lugdoc.log ← application log
1. Install dependencies
pkg install python312 uv rubygem-asciidoctor chromium git
2. Create service user and directories
pw useradd lugdoc -m -s /usr/sbin/nologin -c "lugdoc service"
mkdir -p /var/lugdoc
chown lugdoc /var/lugdoc
3. Clone repos and create virtualenv
su lugdoc -c "git clone <app-repo-url> /var/lugdoc/app"
su lugdoc -c "git clone <docs-repo-url> /var/lugdoc/docs"
cd /var/lugdoc/app && su lugdoc -c "uv sync"
4. Deploy config files
config.toml and users.toml are kept outside the app repo so they survive git pull.
Minimum production config.toml:
[site]
name = "My LUG Docs"
port = 8000
base_url = "https://docs.example.org"
theme = "default"
[content]
docs_repo_path = "/var/lugdoc/docs"
cache_ttl_seconds = 60
# webhook_secret = "change-me" # uncomment to enable POST /hooks/refresh
[features]
show_drafts = false
[pdf]
chromium_path = "/usr/local/bin/chromium"
[auth]
users_file = "/var/lugdoc/users.toml"
session_secret = "a-long-random-string-change-this"
session_lifetime_days = 30
[email]
smtp_host = "smtp.example.org"
smtp_port = 587
smtp_user = "noreply@example.org"
smtp_password = "..."
from = "My Docs <noreply@example.org>"
5. Install and enable the rc.d service
cp /var/lugdoc/app/deploy/rc.d/lugdoc /usr/local/etc/rc.d/lugdoc
chmod +x /usr/local/etc/rc.d/lugdoc
sysrc lugdoc_enable="YES"
service lugdoc start
To override defaults in /etc/rc.conf:
lugdoc_enable="YES"
lugdoc_port="8000"
lugdoc_user="lugdoc"
lugdoc_dir="/var/lugdoc/app"
lugdoc_log="/var/log/lugdoc.log"
lugdoc_config="/var/lugdoc/config.toml"
6. Caddy reverse proxy
In the Caddy jail, add a site block pointing at the lugdoc jail's internal IP:
docs.example.org {
reverse_proxy <lugdoc-jail-ip>:8000
}
Caddy handles TLS automatically via Let's Encrypt.
Example Bastillefile
ARG LUGDOC_REPO=https://git.example.org/you/lugdoc-py.git
ARG DOCS_REPO=https://git.example.org/you/docs.git
PKG python312 uv rubygem-asciidoctor chromium git
CMD pw useradd lugdoc -m -s /usr/sbin/nologin -c "lugdoc service"
CMD mkdir -p /var/lugdoc
CMD chown lugdoc /var/lugdoc
CMD su lugdoc -c "git clone ${LUGDOC_REPO} /var/lugdoc/app"
CMD su lugdoc -c "git clone ${DOCS_REPO} /var/lugdoc/docs"
CMD cd /var/lugdoc/app && su lugdoc -c "uv sync"
COPY config.toml /var/lugdoc/config.toml
COPY users.toml /var/lugdoc/users.toml
CMD cp /var/lugdoc/app/deploy/rc.d/lugdoc /usr/local/etc/rc.d/lugdoc
CMD chmod +x /usr/local/etc/rc.d/lugdoc
SYSRC lugdoc_enable=YES
SERVICE lugdoc start
config.toml and users.toml are copied from the host (not the repo) so secrets
are never committed to version control.
Updating
App code change
just deploy
# expands to: git pull && uv sync && service lugdoc restart
Run inside the jail, or via SSH:
ssh user@host 'cd /var/lugdoc/app && just deploy'
Content change
Push to the docs repo. If webhook_secret is configured, the site updates automatically.
To pull manually:
just pull-content
Config change
Edit /var/lugdoc/config.toml directly — it lives outside the app repo.
A restart is required to pick up changes (config is loaded once at startup):
service lugdoc restart
users.toml and positions.toml changes take effect immediately with no restart.
Content refresh webhook
To trigger an instant content refresh on push to the docs repo:
- Set
webhook_secretinconfig.tomland restart the service - In your git host (Forgejo, GitHub, GitLab), add a webhook:
- URL:
https://docs.example.org/hooks/refresh - Content type:
application/json - Secret: same value as
webhook_secret
- URL:
The endpoint accepts POST /hooks/refresh with Authorization: Bearer <secret>.
The git host's HMAC signature is not verified — only the Bearer token is checked —
so any host that supports custom webhook URLs works.
To test manually:
curl -X POST https://docs.example.org/hooks/refresh \
-H "Authorization: Bearer <your-secret>"