zxh / org-web
This repository is the source and publishing pipeline for zainezq.com, a personal and family website built primarily from Emacs Org-mode files. It contains the written content, the Emacs Lisp build system, browser assets, generated indexes, playful interactive pages, and the Gitea Actions workflow that publishes the site.
This README is the central technical guide for the repository: start here for architecture, authoring, builds, frontend work, testing, and deployment.
System overview
The application has three boundaries:
- Site source (this repository): Org/Markdown content, Emacs Lisp publishing code, CSS, JavaScript, images, and build tooling.
- Static output (
output/): generated HTML, copied assets, andsearch-index.json. This is the deployable website, not the source of truth. - Runtime services (outside this repository): the authoring service and the same-origin APIs used by interactive pages. The static site remains readable without them, but the relevant dynamic features will not work.
flowchart LR
Author["Author or authoring service"] --> Source["Org and Markdown source"]
Source --> Emacs["Emacs org-publish pipeline"]
Assets["CSS, JavaScript, images, fonts"] --> Emacs
Emacs --> HTML["output/: HTML and assets"]
HTML --> Search["Python search indexer"]
Search --> Deploy["Static site at zainezq.com"]
Deploy <-->|"same-origin /api requests"| APIs["Runtime APIs outside this repo"]
Repository map
| Path | Purpose | Source or generated? |
|---|---|---|
index.org |
Home page source | Source |
blogs/ |
Dated personal writing, grouped by year/month | Source, except generated list pages |
posts/ |
Longer-lived posts; posts/career/ has its own index |
Source, except generated list pages |
home/ |
Utility and dashboard-style pages | Source, except categories.org |
play/ |
Interactive and experimental pages | Source |
lima/ |
Org or Markdown pages with special attachment handling | Source, except lima-list.org |
assets/ |
Browser code, styles, fonts, images, and content manifests | Mostly source; some manifests/scripts are generated externally |
lisp/ |
Modular Emacs Lisp publishing system | Source |
lisp/tests/ |
ERT tests for the publishing logic | Source |
tags/ |
Per-tag Org pages created during a build | Generated |
recently-updated.org |
Recent-content list created during a build | Generated |
sitemap.org and *-list.org |
Sitemap/list sources created by org-publish |
Generated |
output/ |
Complete publish output | Generated |
.org-timestamps/ |
Incremental publish cache | Generated |
.packages/ |
Repository-local Emacs package directory | Generated/cache |
build-logs/ |
Gitea build monitoring script, cache, and logs | Mixed; script is source, logs/cache are runtime data |
docs/ |
Historical/design notes for individual features | Supplemental; this README is the central engineering guide |
Build architecture
build-site.el is the entry point. It establishes absolute source/output roots, bootstraps htmlize, configures Org, loads the modules in lisp/, defines the publishing projects, and runs the build.
flowchart TD
Entry["build-site.el"] --> Core["build-core.el: packages, Org, logging"]
Core --> Modules["Load build modules"]
Modules --> Projects["build-projects.el: org-publish projects"]
Projects --> Prepare["Validate output and timestamp cache"]
Prepare --> Inputs["Generate tag pages and comment manifest"]
Inputs --> Publish["org-publish-all"]
Publish --> OrgHTML["Org/Markdown to HTML"]
Publish --> AssetCopy["Copy assets"]
OrgHTML --> Sync["Final asset sync"]
AssetCopy --> Sync
Sync --> Summary["Build summary"]
Build modules
| Module | Responsibility |
|---|---|
build-core.el |
Logging, timing, local package bootstrap, and base Org configuration |
build-assets.el |
Shared CSS/JS <head> manifest, cache-busted asset URLs, and asset synchronization |
build-html.el |
Custom HTML backend, page shell, Mermaid export, macros, body classes, tags, and comments |
build-projects.el |
org-publish-project-alist for the main site, posts, blogs, career, tags, Lima, categories, and assets |
build-runner.el |
Cache integrity, generated inputs, publishing, and build metrics |
build-lima.el |
Lima Org/Markdown conversion and neighboring attachment copying |
build-comments.el |
Comment opt-in detection, comment HTML injection, and comment-page manifest generation |
sitemaps.el |
Main Mermaid sitemap and specialized blog/post/career/category lists |
tags.el |
Tag discovery, generated tag pages, and tag markup in exported HTML |
recently-updated.el |
Recent page list generation using #+DATE with mtime fallback |
sidenotes.el |
Sidenote suppression rules for special page layouts |
The main org-main project recursively publishes every Org file, while narrower projects provide specialized sitemap behavior and options. That overlap is intentional: Org's timestamp cache prevents unnecessary repeated work in normal incremental builds.
Generated inputs and outputs
Before publishing, the build writes:
tags/*.orgfrom#+FILETAGS/#+TAGSin posts and blogs.assets/content/comment-pages.jsonfrom pages with#+COMMENTS: t.recently-updated.orgfrom the 26 most recent eligible Org pages.- sitemap/list Org files through
org-publish.
After publishing, search-index-json.py walks generated HTML, strips script/style markup with Beautiful Soup, and writes output/search-index.json. The browser builds a Lunr index from that JSON at page load.
flowchart LR
Metadata["Org metadata"] --> Tags["tags/*.org"]
Metadata --> Comments["comment-pages.json"]
Metadata --> Recent["recently-updated.org"]
Tags --> Publish["HTML publish"]
Comments --> Publish
Recent --> Publish
Publish --> Indexer["search-index-json.py"]
Indexer --> SearchJSON["output/search-index.json"]
SearchJSON --> Lunr["Browser Lunr index"]
Prerequisites
For a full local build, install:
- GNU Emacs with Org mode (Org is bundled with normal Emacs distributions).
- Python 3 with
venvsupport. pandoc, required when publishing Markdown files underlima/.- Network access on the first build if
htmlizeis not already cached in.packages/.
Python packages are listed in requirements.txt and installed into .venv by Make. The default make workflow also invokes a machine-specific permission script with sudo; use the direct build commands below on machines that do not have that script.
Build and development commands
Portable local build
emacs -Q --script build-site.el
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python search-index-json.py
The generated site is in output/. Serve it over HTTP so fetch-based features work correctly, for example:
python3 -m http.server 8000 --directory output
Then open http://127.0.0.1:8000. Dynamic /api/... features require the external runtime services or an equivalent local proxy.
Make targets
| Command | Effect |
|---|---|
make build |
Run the Emacs publishing pipeline |
make search |
Create/update output/search-index.json, creating .venv when needed |
make |
Set machine-specific permissions, remove output/, build, index search, and remove .venv |
make clean-output |
Delete the configured output directory |
make test-elisp |
Run the Emacs Lisp ERT suite |
make test |
Run tests in the sibling authoring-service repository |
make norm |
Move Emacs backup files into backups/ |
make help |
List supported targets |
Build environment variables
| Variable | Meaning |
|---|---|
SITE_OUTPUT_DIR=/path |
Publish somewhere other than output/; the Python indexer respects it too |
SITE_FORCE=1 |
Republish every file instead of using Org timestamps |
SITE_DRY=1 |
Generate/prepare inputs but skip org-publish-all |
SITE_CLEAN=1 |
Delete output and .org-timestamps/, then force a full publish |
The build checks that assets and an entry page exist in the output. If the output is missing or incomplete while timestamps remain, it clears the timestamp cache and forces a full publish to avoid a falsely successful incremental build.
Authoring content
Org pages
A typical page is:
#+TITLE: Page title
#+DATE: <2026-07-12 Sun 12:00>
#+FILETAGS: :topic:notes:
#+OPTIONS: num:nil toc:t
#+COMMENTS: t
#+SLUG: stable-page-slug
* First heading
Content goes here.
The important metadata is:
#+TITLE: displayed title and sitemap/search label.#+DATE: canonical publication time and ordering source. Recently updated falls back to filesystem modification time when absent or invalid.#+FILETAGS: colon-delimited tags used for category/tag pages and rendered under the page title.#+OPTIONS: normal Org export controls such astoc:nil,num:nil, ortitle:nil.#+COMMENTS: t: injects the comment UI and adds the page to the comment manifest.#+SLUG: stable comment API identifier; defaults to the filename when omitted.#+NO_SIDENOTES: any non-empty value adds theno-sidenotesbody class. TheNO_SIDENOTESorKANBANfile tag and a heading containingKANBANdo the same.
Use root-relative URLs (/assets/...) for shared site assets. Relative Org file links are rewritten to their corresponding HTML links by the exporter.
Supported export helpers
Mermaid diagrams can be written directly in Org:
#+begin_src mermaid
flowchart LR
A[Write] --> B[Publish]
#+end_src
The custom HTML backend emits a Mermaid container, and the shared browser initializer renders it. The build also provides these Org macros:
{{{sidenote(id,text)}}}{{{epigraph(quote,attribution)}}}and related epigraph variants{{{kbd(text)}}}{{{margimg(url,alt,caption)}}}{{{countdown(datetime,label)}}}
Where new pages belong
- Blog entry:
blogs/YYYY/MM-month/slug.org. - General post:
posts/slug.org. - Career post:
posts/career/slug.org. - Utility/dashboard page:
home/slug.org. - Interactive experiment:
play/slug.org, usually paired withassets/scripts/pages/slug.jsandassets/styles/pages/slug.css. - Lima page:
.orgor.mdunderlima/.
Do not manually maintain generated list, category, recent, tag, or output files; rebuild them from source.
Lima Markdown publishing
Markdown under lima/ is converted to temporary Org with Pandoc, given generated title/date/comment metadata, and then exported through the normal HTML backend. The first Markdown H1 becomes the page title. Sibling directories matching .attachments.* are copied beside the published page. Org files in lima/ use the same attachment-copy behavior without Pandoc.
Frontend architecture
All static browser assets live under assets/ and are copied to output/assets/. lisp/build-assets.el is the authoritative list of globally loaded styles and scripts; it appends deterministic mtime/size query strings for cache busting.
Styles
The modular CSS hierarchy is:
assets/styles/
├── themes/ design tokens and themes
├── base/ element defaults
├── layout/ site shell and layout
├── components/ reusable UI components
├── pages/ page-specific presentation
├── features/ optional feature presentation
├── utilities/ responsive/utility rules
└── vendor/ third-party CSS
Top-level CSS files are compatibility manifests that import the modular files. Prefer editing the modular source, while preserving the top-level entry points until old handwritten references have been removed.
Scripts
The intended JavaScript boundaries are:
assets/scripts/
├── application/ cross-page use-case orchestration
├── ui/ DOM presentation behavior
├── pages/ page-specific applications
├── features/ optional site-wide features
└── vendor/ vendored third-party libraries
The shared script list is loaded on every page, so each module must safely exit when its expected DOM is absent. Page modules should own page composition; reusable browser behavior belongs in ui/ or features/. Top-level duplicate filenames are legacy loaders or compatibility copies—edit the modular file referenced by build-assets.el unless a page explicitly loads the legacy path.
Third-party browser dependencies are vendored: Lunr, Mermaid, BiggerPicture, and SVG Pan Zoom. Avoid editing minified vendor files directly.
Dynamic features and service boundaries
The published HTML is static, but some browser modules rely on same-origin APIs:
| Feature | Browser module | Runtime endpoint |
|---|---|---|
| Comments and replies | features/comments.js |
GET /api/comments/:slug, POST /api/comments |
| Notes board | pages/notes.js |
/api/notes |
| Wird tracker | pages/wird-tracker.js |
/api/wird, /api/wird/motalah, /api/calibre/books |
| Competency board | pages/competency-status-board.js |
/api/competencies/items... |
| Archive World progress | pages/archive-world.js |
Device-local localStorage state |
These APIs are not implemented here. Production routing must serve output/ and proxy /api/ to the appropriate backend. When changing an endpoint contract, coordinate the static client and its external service.
The hidden-details feature uses browser localStorage/sessionStorage for visit and discovery state and may call Open-Meteo after explicit geolocation interaction. Its friendly content source is assets/content/hidden-details.json; assets/scripts/features/hidden-details.js is generated by the external hidden-narrative authoring workflow. Treat both files as a pair and do not hand-edit only the generated JavaScript.
Authoring service
The browser authoring UI is a separate sibling repository at:
/home/zaine/master-folder/org-platform/authoring-service
It creates and edits the normal source files described above, stores Lima uploads under assets/images/hzone/YYYY/MM/, and enqueues site builds after saves. It is available locally at http://127.0.0.1:8765 and, behind the production reverse proxy, at https://author.zainezq.com.
The Makefile delegates these commands to that repository:
make author/make author-startmake author-installmake author-stopmake author-restartmake author-healthmake author-statusmake author-diagnosticsmake author-logs
They depend on the sibling repository existing at the hard-coded path and use its user systemd service.
Testing and verification
Run the publishing tests:
make test-elisp
For a documentation/content-only change, a dry run validates package loading and generated inputs without rewriting all HTML:
SITE_DRY=1 emacs -Q --script build-site.el
For build or frontend changes, use a clean build, generate the search index, serve output/, and inspect the relevant pages in a browser:
SITE_CLEAN=1 emacs -Q --script build-site.el
.venv/bin/python search-index-json.py
python3 -m http.server 8000 --directory output
Verify at least:
- the home page and global navigation;
- a blog/post with a table of contents, tags, sidenotes, and comments as applicable;
- the relevant special page under
home/orplay/; - search results and keyboard navigation;
- browser console/network failures;
- the generated sitemap and tag/category pages.
Deployment and CI
.gitea/workflows/build.yml runs on pushes to main, nightly at midnight, and manual dispatch. Push events that only change the hidden-details JSON/generated JS pair are ignored to prevent a build loop.
sequenceDiagram
participant Git as Gitea main
participant Runner as org-web runner
participant Build as Site build
participant Monitor as PowerShell monitor
Git->>Runner: push, schedule, or manual dispatch
opt scheduled or manual
Runner->>Git: commit and push hidden-detail changes if present
end
Runner->>Build: SITE_CLEAN=1 make
Build-->>Runner: static output and logs
Runner->>Monitor: inspect result and authoring tests
Monitor-->>Runner: Discord notification
The runner is host-specific. It expects:
- the checkout at
/home/zaine/master-folder/org-platform/org_web; - output at that repository's
output/directory; - the permission helper referenced by the Makefile;
- PowerShell at
/snap/bin/pwsh; - the sibling authoring-service repository;
- Gitea/Discord credentials supplied as Actions secrets.
The workflow's monitor writes diagnostics and uses DISCORD_WEBHOOK_URL and API_KEY. Never commit those values or other service credentials.
Safe change checklist
- Edit source content, modular assets, or
lisp/; do not editoutput/as the source of a fix. - Check whether the file is generated or a compatibility wrapper before changing it.
- Add new globally loaded assets to
lisp/build-assets.el; keep page-only behavior scoped to its page when possible. - Preserve stable
#+SLUGvalues on comment-enabled pages. - Run
make test-elispand the smallest representative build/browser checks for the change. - Review
git statuscarefully: builds intentionally update generated Org files and manifests, while local logs and caches may also change. - Commit only the intended source and generated artifacts required by the workflow.
Troubleshooting
A build skips files that are missing from output/
Run with SITE_CLEAN=1. The normal integrity check catches a missing asset directory or entry page, but it does not validate every generated page.
htmlize cannot be installed
The first build needs access to MELPA/ELPA unless .packages/ already contains it. Restore network access or provide the cached package; the build cannot continue without syntax-highlighting support.
Lima Markdown fails to publish
Confirm pandoc is installed and available on PATH. Plain Org publishing does not require Pandoc.
Search is unavailable
Confirm output/search-index.json exists and the site is served over HTTP. Run make search after publishing HTML.
Interactive data does not load locally
Static file serving does not provide /api/.... Run/proxy the relevant external services, or expect those features to show their empty/error state.
The full make command fails outside the production host
make runs a hard-coded permission helper with sudo. Use make build followed by make search, or the portable commands above, on another machine.