A documentation site for community groups, originally built for the Canberra LEGO Users Group.
  • Python 44.9%
  • CSS 34.7%
  • HTML 14%
  • JavaScript 4%
  • Just 1.8%
  • Other 0.6%
Find a file
2026-05-07 18:24:05 +10:00
deploy/rc.d batman 2026-04-14 19:33:50 +10:00
lugdoc theme system improvements 2026-05-07 18:24:05 +10:00
tests batman 2026-04-14 19:33:50 +10:00
tools batman 2026-04-14 19:33:50 +10:00
.gitignore ignore custom theme directories; only default and naysayer ship with the app 2026-04-15 19:06:17 +10:00
.python-version batman 2026-04-14 19:33:50 +10:00
config.toml batman 2026-04-14 19:33:50 +10:00
justfile batman 2026-04-14 19:33:50 +10:00
package-lock.json batman 2026-04-14 19:33:50 +10:00
pyproject.toml overhaul pdf generation 2026-04-14 21:46:13 +10:00
README.md support for some custom styles useful in meeting minutes 2026-04-15 17:35:34 +10:00
uv.lock overhaul pdf generation 2026-04-14 21:46:13 +10:00

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 sections
  • doc = "*" — 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:

  1. Set webhook_secret in config.toml and restart the service
  2. 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

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>"