Skip to content

graemeg/luhmann

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

80 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Luhmann (Blaise Pascal port)

Luhmann turns a directory of AsciiDoc files into a personal Zettelkasten β€” a linked web of notes β€” served over plain HTTP on localhost. You write notes in your own editor; Luhmann renders them, connects them, indexes them, and keeps the browser in sync as you work.

This is a reimplementation in the Blaise Pascal language, built with PasBuild and structured as a hexagonal (ports and adapters) application. Over the original Clojure version it adds automatic backlinks, tag-based organisation, an interactive note graph, a broken-links report, and a JSON API for editor integration.

Features

Notes and rendering
  • Renders each .adoc note to HTML via the asciidoctor binary and serves it wrapped in a clean, consistent UI.

  • Parallel build β€” renders notes concurrently at start-up, so even a large Zettelkasten is ready in a second or two (see Startup performance).

Connections
  • Automatic backlinks β€” you only ever write forward links (xref:); Luhmann computes the reverse direction and shows, on every note, which other notes link to it. No manual maintenance.

  • Interactive note graph (/graph) β€” an Obsidian-style force-directed map of your notes. Click a node to open that note; double-click to re-centre the graph on it; the browser Back button returns you to the graph.

  • Broken-links report (/broken) β€” lists every cross-reference whose target note does not exist, so dangling links are easy to find and fix.

Organisation and search
  • Tags β€” organise notes with an in-note :tags: attribute rather than by folder; untagged notes fall under a configurable catch-all label. Browse all tags at /tags.

  • Full-text search β€” a built-in index over every note’s title and body, with prefix matching (and * for substring) and highlighted result snippets.

Workflow
  • Live reload β€” edit a note in any editor and save; the browser refreshes itself automatically over a WebSocket. A page also re-syncs when you switch back to its tab, so listing pages never go stale.

  • Launch your editor from the browser β€” press e on any note (or call the edit API) and Luhmann opens that note in your configured editor, GUI or terminal. See editor integration.

  • Keyboard shortcuts β€” / to search, e to edit, g to open the graph for the current note (see Keyboard shortcuts (in the browser)).

Integration
  • Built-in JSON REST API β€” query backlinks, the link graph, and tags, and trigger an editor launch, from your own tools or an editor plugin. See REST API.

Local-first and self-contained
  • Runs as a single process on localhost, no database, no cloud, no account.

  • The graph library is vendored, so the graph works fully offline.

  • No external services beyond the asciidoctor binary used for rendering.

REST API

All endpoints are GET and take their input from query parameters. The graph and tag endpoints return JSON; the rest return HTML pages.

Endpoint Returns Description

/, /<path>.html

HTML

A rendered note page (with chrome, tags and backlinks). / serves index.adoc.

/search?q=<query>

HTML

Full-text search results page.

/graph

HTML

The interactive graph page (optionally ?note=<id> to centre it).

/tags, /tags/<tag>

HTML

All tags with counts, or the notes carrying one tag.

/broken

HTML

The broken cross-references report.

/api/backlinks?note=<id>

JSON

Notes that link to <id>: [ { "note": "…​", "title": "…​" }, …​ ].

/api/graph?note=<id>&depth=<n>

JSON

The neighbourhood subgraph around <id> (nodes + edges).

/api/graph/all

JSON

The whole-corpus graph (nodes + edges).

/api/tags

JSON

Every tag with its note count: [ { "tag": "…​", "count": N }, …​ ].

/api/edit?note=<id>

text

Launch the configured editor on <id> (see editor integration).

/ws

WebSocket

Live-reload channel; the server pushes a reload frame on any change.

A note id is a path relative to the notes directory, e.g. fruit/apple.adoc.

Architecture

Luhmann is a hexagonal (ports and adapters) application. A framework-free domain core β€” the link graph, tag index, change orchestration and AsciiDoc extractors β€” depends only on a set of port interfaces and knows nothing of HTTP, the filesystem or asciidoctor. Adapters implement those ports with concrete technology, and the composition root wires them together. This keeps the core fast to test (in-memory, no infrastructure) and every technology choice swappable.

For the full picture β€” the ports and adapters, the unit map, how backlinks are maintained, and why this shape β€” see the architecture guide. The original design rationale and Blaise-specific implementation notes are in the design document.

Building

Prerequisites:

  • A Blaise compiler binary (v0.12.0 or newer).

  • PasBuild on the PATH.

  • The asciidoctor binary (/usr/bin/asciidoctor) for rendering, and find for note enumeration.

pasbuild compile --compiler /path/to/blaise

The executable is produced at target/luhmann, with static assets copied to target/public.

Running

target/luhmann /path/to/notes

/path/to/notes is a directory of .adoc files with an index.adoc entry point. Open http://localhost:2022 to view the Zettelkasten.

The static-asset directory is resolved as: $LUHMANN_PUBLIC, else public beside the binary (the PasBuild layout), else src/main/resources/public.

Try the example 🍎🍊

The repository ships with a small example Zettelkasten under example/ β€” a handful of interlinked notes about fruit. Point Luhmann at it to see the features in action:

target/luhmann example

Then open http://localhost:2022 and explore:

  • the Backlinks panel on every note β€” e.g. open xref-rich fruit and see the five notes that reference it;

  • the graph (top-right Graph link) showing the notes clustering around the shared concepts (fruit, pome, citrus);

  • tags β€” every note is tagged, and nothing is organised by folders;

  • search β€” try app, *range, or citrus.

It is deliberately tiny, but it demonstrates the whole idea: atomic notes, organised by tags and links rather than folders, with structure emerging from the connections between them.

Keyboard shortcuts (in the browser)

Key Action

/

focus the search box

e

launch the configured editor on the current note

g

open the graph centred on the current note

Esc

unfocus the search box (so the single-key shortcuts work again)

Configuration

Luhmann runs with sensible defaults; everything is optional. To override a setting, create a .luhmann.ini file inside your notes directory (the same directory you pass on the command line):

# ~/Documents/second-brain/.luhmann.ini
port        = 2022
render-jobs = 12
editor      = gnome-terminal -- mcedit

Available keys cover the port, the number of parallel render jobs, the editor command (GUI or terminal), the catch-all tag label, and the asciidoctor path. At startup Luhmann prints which config file it loaded so you can confirm it was picked up.

See the configuration guide for the full list of settings with defaults, and for editor-integration details (GUI vs terminal editors, and how to launch console editors such as vim or mcedit).

Tags

Declare tags inside a note with the AsciiDoc :tags: attribute:

= Apple
:tags: fruit, pome, edible

An apple is a pome fruit ...

Notes with no tags are filed under the catch-all label (default unfiled), so every note is reachable from /tags.

The search box (or /search?q=…​) runs a full-text query over every note’s title and body. Matching is case-insensitive and works on word prefixes by default, so a partial word finds the whole word:

Query Matches

zet

any note containing a word starting with zet β€” e.g. Zettelkasten.

zettel, zettelkasten

the same note (a full word is a prefix of itself).

note tag

notes containing a word starting with note and/or one starting with tag (more matching terms rank higher).

To match a fragment in the middle or end of a word, prefix the term with * (a substring search):

Query Matches

kas

nothing β€” kas is not the start of any word.

*kas, *kasten

Zettelkasten β€” the fragment appears inside the word.

Prefix is the default because it keeps short queries focused; reach for * when you specifically want an infix match.

Startup performance

At start-up Luhmann renders every note once by shelling out to asciidoctor. That cost is dominated by process-startup latency (~150 ms per note), so the build renders notes in parallel: up to render-jobs asciidoctor processes run at a time, and the result is identical to a serial render.

Measured on an 86-note corpus (16-core machine):

render-jobs Build time Speedup

1 (serial)

~13.0 s

1.0x

8 (default)

~3.0 s

4.3x

12

~2.3 s

5.6x

16

~2.1 s

6.3x

Set render-jobs to roughly your physical core count. Beyond that the gains flatten (the remaining time is per-process startup plus the serial indexing phase). The build prints its own timing on the Build complete line.

Tests

scripts/test.sh

This compiles and runs every unit-test program under src/test/pascal. The network/corpus integration tests (test_adapters, test_http) are excluded from the scripted run and are exercised manually.

About

A Zettelkasten note web server, written in Blaise Pascal.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors