Working Guide · One Catalog, Many Repositories

How to Work With Synapse and synapse-content

Working Guide · One Catalog, Many Repositories

How to work with Synapse

Everything from an empty terminal to a published lesson with runnable code, a judged problem and an architecture walkthrough you click through. Setup is written for Mac, Windows and Linux, and assumes you have never used a terminal before.

Content Repos1 + N
Setup Steps6
Reserved Fences8
Publish Bygit push

Synapse separates the application from everything the application says. One repository holds the code; a growing set of others hold the writing, and the platform merges them into a single catalog at runtime. Publishing a lesson is a git push to whichever repository owns it, and nothing is rebuilt.

This is the working guide. It assumes nothing beyond a terminal and covers the whole path — cloning, running the stack, writing a lesson with every feature the renderer has, verifying it, and getting it live. Where something fails quietly, this guide says so, because a wrong fence does not throw an error; it just renders as a plain code block and leaves you wondering.

Revised August 2026: content now spans several repositories rather than one, plain code fences open in a full editor, and there are two in-app diagram editors. Those sections are new; everything else stands.

The Split

One Catalog, Many Repositories

Repository Holds Changes reach production
ani2fun/synapse the application — Rust server, Astro web tier, visualisation engine image build → registry → deployment rollout, minutes
ani2fun/synapse-content the spine — the blog, the category declarations, and any book that has not been split out a sidecar pulls the commit, under a minute, no rebuild
a satellite — one repository per book that book, and nothing else: book.json plus chapters at the repository root fetched on a 60-second loop, under a minute, no rebuild

The application reads content off disk at a path it is given. In production that path is a checkout maintained by a git-sync sidecar; in development it is wherever you cloned the content repository. Neither the server nor the browser can write to it.

Spine and satellites

The spine is mounted first and always. Everything a satellite cannot own alone lives there: the blog and the category list that groups books on the library page.

A satellite is a repository whose root is a book — a book.json beside numbered chapter directories, with no wrapper folder. It is registered once from /admin, and from then on the server fetches it as a GitHub tarball on a sixty-second loop. The registration row owns where the book sits and in what order; book.json owns the slug, because the slug is the URL.

The rule that makes a split safe: on a duplicate book slug, the first source wins, and the spine is always first. So moving a book out of the spine is: register the satellite, verify it at its real URL while both copies still exist, and only then delete the copy in the spine. A satellite's grouping and its book.json slug must reproduce the path the book had before the split — otherwise deletion day moves every URL and orphans the reading progress recorded against the old ones.

Six books are satellites today — dsa, java, low-level-design, python, sql and system-design-from-first-principles. Nothing about writing a lesson changes when a book moves: the fences, the frontmatter and the URL rules below are identical in the spine and in a satellite.

The practical consequences are worth internalising before you start:

Fast loop

Save the file, refresh the page

In development the content index is re-checked per request, so an edit shows on the next refresh. No restart, no rebuild, no watcher to configure.

Cheap rollback ↩️

A bad lesson is a revert

Content is never in a database, so undoing a publish is git revert and a poll interval — not a migration and an incident.

Watch for 👻

Every .md is a page

Drop a README.md into a book directory and it renders as a lesson in the sidebar. Notes belong outside the book tree.

By design 🔗

Slugs are forever

The URL is built from directory and file names with the numeric prefix stripped. Renumbering is free; renaming breaks every link.

Setup

Running It Locally

You do not need to be a developer to do this. You need to copy six commands into a black window and wait. Everything below is written for someone who has never opened a terminal.

If you only want to write — fix a typo, add a lesson — you can skip this entire section twice over. Contributors edit lessons inside the app itself, with no installation at all, and writers who prefer files need only the content folder and a text editor. Running the whole platform is for people who want to change the software.

What you are about to install, in plain terms. Synapse is made of a few separate programs that talk to each other. Four of them (a database, a code sandbox, a login server, a diagram viewer) come pre-packaged, so you never install them yourself — a tool called Docker downloads and runs them for you. Two you do install: Rust, which the main server is written in, and Node, which builds the web pages. Plus Git, which downloads the source code. That is the whole list: Git, Rust, Node, Docker.

Step 0 · Open a terminal

The terminal is a window where you type commands instead of clicking. Everything below gets pasted into it, one block at a time, pressing Enter after each.

Your computerHow to open it
MacPress ⌘ + Space, type Terminal, press Enter.
WindowsClick Start, type PowerShell, click Windows PowerShell. (You will switch to a second terminal in Step 1 — read on.)
LinuxPress Ctrl + Alt + T, or search your applications for Terminal.

A line starting with # in the blocks below is a comment for you, not a command — you can paste it along with everything else and the computer will ignore it.

Windows users, read this before Step 1. Synapse's start-up script is written for Mac and Linux, and it uses tools Windows does not have. Rather than fight that, install WSL — Microsoft's official way to run Ubuntu Linux inside Windows. It takes one command, it is fully supported, and after it you follow the Linux instructions everywhere below.

In PowerShell, right-click and choose Run as administrator, then:

wsl --install

Restart when it asks. You will be prompted to pick a username and password for Ubuntu — write them down. From then on, open Ubuntu from the Start menu instead of PowerShell, and use the Linux column in every table below.

Step 1 · Install the four tools

Pick your operating system and paste the block. Each installer prints a lot of text — that is normal. Wait for your prompt to come back before pasting the next thing.

On a Mac

# 1. Homebrew — the standard Mac installer for developer tools.
#    Skip this line if `brew --version` already prints something.
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
 
# 2. Git and Node (22 or newer).
brew install git node
 
# 3. Rust, from the official installer. Choose option 1 (default) when it asks.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
 
# 4. Docker Desktop — this one has a normal installer window.
brew install --cask docker-desktop

Then open Docker Desktop from your Applications folder once and leave it running. Docker only works while that app is open; its whale icon sits in the menu bar at the top of the screen.

Why Rust comes from its own installer rather than Homebrew. Homebrew's rustup is "keg-only" — it installs without putting anything on your PATH, so the commands appear not to exist until you edit a shell config file. The official installer above handles that itself, and it is what rust-lang.org recommends. One less thing to get wrong.

On Windows (inside Ubuntu/WSL) or on Linux

# 1. Git, curl and build tools.
sudo apt update && sudo apt install -y git curl build-essential
 
# 2. Node 22.
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
 
# 3. Rust. Choose option 1 (default) when it asks.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
 
# 4. Docker.
sudo apt install -y docker.io docker-compose-v2
sudo usermod -aG docker $USER      # lets you use docker without typing sudo

On plain Linux, close the terminal and open a new one after that last line, or the permission change will not have taken effect. On Windows, install Docker Desktop for Windows instead of the apt step above and enable Settings → Resources → WSL integration for your Ubuntu — then Docker works from inside Ubuntu automatically.

Check it worked. Paste this; you want four version numbers and no "command not found":

git --version && node --version && cargo --version && docker --version

Step 2 · Download the two folders

# Make a working folder and move into it.
mkdir -p ~/synapse-workspace && cd ~/synapse-workspace
 
# The application, then the writing.
git clone https://github.com/ani2fun/synapse.git
git clone https://github.com/ani2fun/synapse-content.git

Keep them side by side like that. The application looks for the writing in a folder next to it, so this layout works with no configuration. (~ means your home folder; on a Mac that is /Users/yourname.)

Two folders is all you need to run the platform. The spine is the only content repository a local stack mounts by default — satellites are fetched over the network from their registration, which a development machine has no reason to do. If you want a satellite in your local catalog, clone it beside the others and point SYNAPSE_LOCAL_SOURCES at it; the registry itself cannot hold a local path.

Step 3 · Start the four helper services

Docker downloads and runs these for you. The first time takes a few minutes because it is fetching them; afterwards it is seconds.

cd ~/synapse-workspace/synapse
docker compose up -d db go-judge keycloak

-d means "in the background". To check they are alive:

docker compose ps

Now one small manual step. Docker creates a database named synapse, but the server wants one named synapse_rs. Create it once:

docker compose exec db createdb -U synapse synapse_rs

If it says already exists, you are fine — it is done. Everything else about the database sets itself up on first launch.

Step 4 · Start Synapse

cd ~/synapse-workspace/synapse
dev-tools/dev

The first run compiles the server and takes several minutes — anywhere from two to ten depending on your machine. It looks like it has frozen. It has not; Rust is simply slow to build the first time and fast forever after. Later runs start in seconds.

Leave this window open — the site runs for as long as it does. Press Ctrl + C in it to stop everything.

Step 5 · Open it

Go to http://localhost:5373 in your browser. You should see the library, with every book in your content folder.

To sign in — needed only for running code and saving solutions — use username tester, password tester.

AddressWhat it isDo you need it?
localhost:5373the siteThis is the one
localhost:8280the API the site talks toonly when debugging
localhost:8181the login server (admin / admin)rarely
localhost:5532the databaserarely
localhost:5150the sandbox that runs reader codeno
localhost:8190the diagram vieweronly with the c4 option on

Do not change 5373 to something else. The login server is configured to trust that exact address. Move the site to another port and sign-in fails silently — no error message, just a login that never completes. This one has cost real hours.

Step 6 · Change something and watch it appear

This is the moment the two-repository split pays off. With the site still running, open any lesson file in the other folder — say ~/synapse-workspace/synapse-content/blog/working-with-synapse.md — in any text editor, change a word, and save.

Now refresh the page in your browser. Your change is there. No rebuild, no restart, no publish button. The server re-reads the content folder on every request, so writing feels like editing a document rather than deploying software.

When something goes wrong

What you seeWhat it meansWhat to do
command not foundthe tool did not install, or the terminal predates itclose the terminal, open a new one, try again
Cannot connect to the Docker daemonDocker is not runningMac: open Docker Desktop. Linux: sudo systemctl start docker
permission denied on a docker commandyour user is not in the docker group yetLinux: log out and back in after the usermod line
the server exits immediately at startupthe database is missing or not upre-run Step 3, including the createdb line
address already in usean older copy is still runningfind its terminal and press Ctrl + C, then start again
the site loads but lists no booksthe two folders are not side by sidesee the note below
signing in does nothingyou are not on port 5373use http://localhost:5373 exactly
it just sits there on first runRust is compilingwait — two to ten minutes, once

If your two folders are not siblings, tell the server where the writing is:

SYNAPSE_ROOT=/full/path/to/synapse-content dev-tools/dev

And to check the server is answering at all, this should print {"status":"ok"}:

curl -s localhost:8280/api/health

The settings worth knowing

Everything is configured by environment variables with working defaults, so you can ignore all of them until you want one. Set them in front of the start command, as in the SYNAPSE_ROOT example above.

SettingDefaultWhat it does
SYNAPSE_ROOT../synapse-contentwhere the writing lives
SYNAPSE_AUTO_RELOADtruere-read content on every request — what makes Step 6 work. Production pins it to the published commit instead
SYNAPSE_PORT8280the API port
TUTOR_ENABLEDfalsethe built-in Socratic coach, if you have a local language model running
CONTENT_FORGEdry-runin-app editing: off, dry-run (everything except the final publish), or github
Writing

The Content Model

A directory becomes a book the moment it contains a book.json. Everything else follows from the filesystem.

synapse-content/
  my-book/
    book.json                       ← this file is what makes it a book
    index.md                        ← the syllabus page, listed first
    01-foundations/                 ← a chapter (nestable, up to 6 deep)
      01-first-lesson.md
      02-second-lesson.md
      03-a-problem.md
      03-a-problem.editorial.md     ← worked solution, revealed on demand
      03-a-problem.tests.json       ← the judge's suite
  _media/my-book/first-lesson/…     ← images and video, at the REPO ROOT
  local-only/                       ← never published
{
  "title": "My Book",
  "description": "One paragraph, shown on the library card.",
  "tags": ["architecture"],
  "estimatedReadingMinutes": 180,
  "order": 3,
  "slug": "my-book"
}

Three rules that decide your URLs

1 Numeric prefixes order; slugs identify. 01-foundations/02-hashing.md becomes /synapse/my-book/foundations/hashing. The prefix is stripped. Renumber freely to reorder a book — no URL changes. Rename the slug and every link to it breaks.

2 index.md sorts first, then numeric prefixes, then plain alphabetical. A chapter's displayed title is its folder name, humanised — there is no title override file, so rename the folder to change what the sidebar says.

3 Every other .md under a book is a visible lesson. The walker skips names starting with _ or ., files ending .editorial.md, and reserved companion directories. Nothing else. Author notes, TODO files and READMEs must live outside the book tree.

Lesson frontmatter

---
title: "Consistent Hashing"
summary: "One line, shown wherever the lesson is listed."
essential: true
---

title and summary are required. essential: false marks an optional deep-cut. One more field changes the lesson's kind:

kind: problem

That single line switches on the two-pane workbench, unlocks the .editorial.md sidecar and loads .tests.json. Without it, both sidecars are silently ignored — which is the most common reason a problem page renders as ordinary prose.

The Interesting Part

The Fence Vocabulary

This is what separates Synapse from a static site generator. Seven language names are reserved — the renderer claims them for widgets instead of syntax-highlighting them:

mermaid · d2 · viz · quiz · problem · testcases · editorial

Everything else is a display language, and what it does is decided by the fence's meta — the text after the language name.

FenceProduces
```pythona highlighted code card — plus Try in Editor, when the sandbox speaks that language
```python runan editor with a Run button, executed in the sandbox
```python run viz=array:numsrunnable and visualised, the picture rooted at nums
```python solution time=O(n) space=O(1)a spoiler-safe revealed answer with complexity labels
```mermaid / ```d2a rendered diagram
```d2 boardsa walkthrough — a tree of boards the reader clicks through
```viz widget=arraya declarative visualisation from an authored payload
```simulatoran embedded interactive simulator from _simulators/
```quizan interactive question
```problem + ```testcases + ```editorialthe problem workbench and its attachments

Runnable code, live

Eleven languages run in the sandbox: Python, Java, Scala, C, C++, Go, Rust, Kotlin, TypeScript, JavaScript and SQL. Add the bare word run to a fence and the reader gets an editor and a ▶ button.

Here is one, in this post — blog posts cross the identical pipeline a lesson does, so every feature described here works here too. Press ▶:

The rule that catches people out: every runnable fence must be a complete, self-contained program. Fences do not concatenate — each one is loaded into the editor alone, run with empty standard input, and must print something deterministic. If your example needs a driver, the driver goes in the same fence.

Plain fences are runnable too — Try in Editor

run is not the only way a reader reaches the sandbox. Any plain fence in a language the sandbox speaks grows a Try in Editor button in its toolbar, which opens the snippet in a near-fullscreen editor with its own Run button and a standard-input box. You write nothing extra: no run, no meta, no change to the Markdown at all. A prose fence in a language the sandbox does not speak — bash, json, yaml — still gets the toolbar and the copy button, just not this one.

The distinction worth keeping straight when you write:

```python```python run
In the pagea code cardan editor, inline
Reaches the sandboxthrough Try in Editordirectly, in place
Use it forillustrating a point in prosethe example the lesson is about

Editing in the popup requires signing in; running does not. A signed-in reader's edits are kept in their own browser, so closing the editor and opening the same snippet again — or coming back to the lesson tomorrow — returns the version they were working on rather than your original. Reset to the original puts your fence back, and rewriting that fence retires their saved copy automatically, so a reader is never handed edits that no longer fit the code around them.

What this means for you as an author: nothing to maintain, but one habit worth keeping — the same rule as a run fence. A plain fence a reader can open in the editor should still be a complete, self-contained program where that is reasonable. A three-line excerpt is a perfectly good illustration; it just will not do anything useful when somebody presses Run on it.

Adjacent fences group

Two run fences in different languages, written back to back, become one card with a language switcher — not two cards:

```python run
def solve(): ...
```
 
```java run
class Main { }
```

The same grouping applies to solution fences and to plain display fences, which become tab groups. If you want two separate cards, put a sentence between them.

Quizzes

A quiz fence carries one JSON object:

{"prompt": "Which of these never reaches the origin?", "options": ["A cache hit at the edge", "A submission", "A code run"], "answer": "A cache hit at the edge"}

Written as a ```quiz fence, that renders as an interactive question. Here is a real one:

answer must match one of the options exactly, by string equality. A mismatch produces a question nobody can answer correctly, and nothing warns you.

Visualisation

The feature the platform exists for: watch your own code execute, step by step, with the data structure drawn.

Add a viz= hint to a run fence and the traced execution becomes a picture:

```python run viz=array:nums
```

The part after the colon is the root — the variable the picture is about. A heap contains many objects; naming the root is what makes the drawing about nums rather than about whichever object the tracer happened to see first.

Seventeen structures are available, and the spelling is exact:

array · grid · stack · queue · deque · tree · heap · list · hashmap
graph · trie · union-find · fenwick · bitset · skiplist · segment-tree · callstack

Two of them are kebab-case, and they are the two everyone gets wrong: union-find and segment-tree. Write unionFind and the fence parses to nothing, renders as a plain code block, and tells you nothing. Tracing currently follows Python and Java.

Practice

Judged Problems

A problem is a lesson with kind: problem and up to two sidecars sharing its stem:

FileRoleWho sees it
03-two-sum.mdthe description, plus the starter fenceseveryone
03-two-sum.editorial.mdworked approaches and solutionsrevealed on demand, behind the Editorial tab
03-two-sum.tests.jsonthe test suitesamples only reach the browser

That last row is the important one. The suite is read twice by two different consumers: the reader's page is given only the cases marked as samples, while the judge — server-side — gets the whole thing. A suite can hold thirty cases and show three. The hidden ones are not hidden by the interface; they are never serialised into the response at all.

A malformed suite is a loud error on both paths rather than a silently empty one, because a problem that quietly grades against nothing is worse than a problem that fails to load.

What happens when a reader submits

The request is accepted, not answered. The server validates, writes a pending row, spawns a detached judging task and returns 202 with an id in milliseconds. The browser polls for the verdict.

Judging runs someone else's code, including code that loops forever until a timeout kills it — so holding an HTTP connection open for it would mean a client timeout losing a result the work is still producing. And because a process can die mid-judge, a grace-windowed sweep at startup completes anything a dead process abandoned. Every asynchronous accept needs a sweeper; a 202 without one is a promise with no mechanism behind it.

Submitting requires signing in, and — in deployments that enforce it — being on a submit allowlist, because a saved submission spends shared compute and storage. Running code needs neither; anonymous readers are rate-limited per IP, signed-in ones per account with a larger budget.

Pictures

Diagrams and Walkthroughs

Two engines, and they are not interchangeable.

Default 🔀

Mermaid

Flowcharts, sequence diagrams, state machines, class diagrams, ER diagrams. Rendered in the browser, lazily, near the viewport.

Structure 🧱

D2

Architecture boxes, topologies, deployments — and, with the boards marker, a walkthrough: one source, a tree of boards, and a reader who clicks down through them a level at a time. Consecutive plain D2 fences merge into one slideshow — the sanctioned way to build a mechanism up step by step.

Here is a Mermaid diagram of the flow this whole post describes:

Drawing one without leaving the site

You do not have to write diagram source blind and refresh to see it. Synapse ships two editors — /d2 and /mermaid — each a split view with the source on the left and the live figure on the right, autosaving as you type.

They are also the fastest route from a sketch to a lesson. Open one on a blank page and it is a scratchpad; open it on an existing figure and it loads the published source, so you can fix a diagram in place. Either way the Add to lesson button routes the result through the same review-and-pull-request pipeline as any other content edit — there is no separate publishing path and no endpoint of their own.

Two details worth knowing before you reach for one:

/d2/mermaid
Names the figure bythe fence's info stringa --- frontmatter block inside the source
Also exportswalkthroughs — a ```d2 boards tree of clickable boards
Opening an existing one?lesson=&at= — and at counts per language, so /mermaid?at=1 is the second Mermaid figure, not the second figure

Walkthroughs, and the one rule you must not break

An architecture diagram is a d2 fence like every other figure — no model file, no shared workspace, no service. What makes it a walkthrough is the boards marker, which turns one source into a tree of boards the reader clicks down through: a system, its containers, the code inside one of them.

```d2 boards name="c4-payments" root="System Context"
sys: "Payments\n[Software System]" { link: layers.container }
 
layers: {
  container: {
    api: "Payment API\n[Go service]" { link: _.layers.code }
  }
  code: {
    guard: "IdempotencyGuard"
  }
}
```

link: resolves against the board it is written in. At the root, layers.container is right; one level down, the same board is _.layers.container. Get it wrong and there is no error anywhere — d2 validate reports success, the board draws, and the reader clicks a box that does nothing.

Everything else about it is ordinary. A walkthrough is addressed by the hash of its source, so the same one in two lessons is one set of boards; name= is a label for the editor and the export, not a path. Nothing is gathered from other repositories, so a satellite's diagrams work the day the satellite is registered — there is no build to deploy first, and no shared namespace to collide in.

Write the boxes up as prose underneath the figure. Text a reader has to click for is text that search, the sitemap, and most readers never see.

Assets & Prose

Media, Blog Posts and Links

Media lives at the repository root

Not beside the lesson. One _media/ tree, addressed by book and lesson slug:

_media/my-book/consistent-hashing/ring.svg
![The hash ring](/media/my-book/consistent-hashing/ring.svg)

SVG, PNG, WebP, JPEG, GIF, MP4 and WebM are served, range-aware, with one shared hour of cache — media is path-addressed rather than content-hashed, because authors replace files in place.

The renderer does not touch your links, so intra-site links must be app-absolute and built from slugs:

Form
Works/synapse/my-book/foundations/hashing
Dead./02-hashing.md — relative Markdown links do not resolve
Dead/synapse/my-book/01-foundations/02-hashing — prefixes are stripped from URLs

Only link to lessons that already exist. Nothing validates this for you, so a link-check before publishing is worth the thirty seconds.

Blog posts

A blog post is a Markdown file in blog/. The slug is the filename; a leading _ marks it a draft and it is skipped entirely.

---
title: A Post
summary: One or two sentences for the listing.
publishedAt: 2026-07-23
tags: [engineering]
readMinutes: 8
eyebrow: Optional · Kicker · Line
meta: Read Time=8 min; Anything=You Like
---

Posts cross the identical markdown pipeline lessons do — the runnable fence, the quiz and the diagram above are all proof of it — plus a rich block vocabulary of heroes, callouts, comparison tables and pull-quotes that this post is built from. Read one of the existing posts' source as the reference; the classes are all blog-post__*.

No Terminal Required

Editing Without git

There is a second way to change a lesson, added for people who should not have to learn git to fix a typo.

1

A signed-in, content-editor allow-listed reader sees a Suggest an edit link on a lesson.

2

It opens a dedicated editor page with the file's full source — frontmatter fence included — and a fingerprint of the bytes it loaded.

3

A rendered preview is step one of submitting and cannot be skipped. It uses the reader's exact pipeline, so what you see is what the page becomes. A blocking lint error disables Submit.

4

The server commits to edit/<username>/<lesson-path> and opens a pull request against whichever repository owns that lesson — the spine or a satellite. The maintainer reviews and merges; the change then ships by the ordinary content pipeline.

Five things worth knowing before relying on it:

  • Existing .md lessons only. No sidecars, no book.json, no new files, no media uploads.
  • A book can move mid-review. A new suggestion is routed by wherever the lesson resolves now; a revision to one already under review goes back to the repository recorded on it when it was opened, so an in-flight pull request cannot be stranded by a split.
  • A second edit while your pull request is open adds a commit to the same branch, rather than opening a second one — so a reviewer gets one conversation per page.
  • The frontmatter fence is part of what you are editing. Deleting it is refused, because it silently changes the page's title, summary and social tags. So is submitting a file with no title left.
  • If the file changed on disk while you had it open, submitting is a 409 and you are asked to reload and reapply. There is no lock; a fingerprint comparison is the guard.

Locally this runs in dry-run by default: the entire flow executes — the allowlist gate, the drift guard, the validation, the branch derivation, the stored history — and only the final call to GitHub is skipped. You can exercise the whole feature without a credential anywhere near your machine.

Discipline

Verify Before You Push

Content has no compiler, so the checks are yours to run. In rough order of how often they catch something:

1 Open the page. The running app is the final authority. Diagrams draw, widgets mount, quizzes hydrate, no console errors. Nothing else proves a page renders.

2 Render-gate every diagram. Extract each fence to a file and run mmdc -i x.mmd -o /tmp/x.svg or d2 x.d2 /tmp/x.svg. A diagram that fails to parse renders as an error card, or as nothing.

3 Run every runnable fence. With standard input closed, twice, checking the output is identical. A fence that needs stdin or prints a timestamp is broken in a way that only shows up in front of a reader.

4 Validate quiz JSON, and confirm each answer is character-for-character one of its options.

5 Check your links resolve to lessons that exist, in the slug-stripped form.

Checking a page by curl tells you half the truth. Prose and code are server-rendered, so the text really is in the HTML — but every widget is a placeholder that an island claims on mount. D2 figures are the exception: the renderer beside the app draws them during SSR, so a real <svg> really is in the raw response. Everything else — Mermaid, and D2 when the renderer is unreachable — is a <div> carrying an encoded source that an island draws on mount, viewport-lazily. So a diagram far down the page has not rendered because nothing scrolled near it, which is not the same as having failed.

If you are changing the application rather than the content, the gates are automated and worth running before you push:

cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
dev-tools/check-conventions.sh      # layer purity + file-size caps
cd web && npx vitest run
dev-tools/e2e                       # browser suite + the per-page JS budget

That last one is the interesting gate. It fetches each page kind from a production-shaped serve and sums the gzipped weight of everything the HTML loads eagerly, failing the build over 250 KiB. The lazy parts — the editor, the diagram engines, the visualiser — are dynamic imports, so they cannot appear in that sum by construction rather than by an exclusion list somebody has to maintain.

Shipping

How It Actually Ships

Two paths, two very different speeds, and knowing which one you are on tells you whether to wait or to investigate.

Prose, media and sidecars take the fast path. For the spine, a sidecar polls the content repository, fetches the new commit into its own directory, and atomically repoints a symlink. The application derives its content version from that checkout's git hash, re-read per request — so a new commit is a new version, a new cache key, and no redeploy. Well under a minute.

The symlink is doing real work there. Updating files in place would let a request landing mid-write see one lesson from the new commit and another from the old. A symlink swap is a single atomic operation: every request sees exactly one commit.

A satellite takes the same fast path by a different road: no sidecar and no checkout, just the server pulling the repository as a GitHub tarball into its content cache on a sixty-second loop. Same outcome — git push, wait under a minute, it is live — and the same absence of a rebuild.

Then there is the cache. Pages and content are served with max-age=60, stale-while-revalidate=600, so a reader may see the previous version for up to a minute — and, if their edge node has not revalidated, a stale-but-instant copy for longer while the fresh one is fetched behind them. That is a deliberate trade: for a learning platform, a one-minute delay on a typo fix is invisible, and never showing anyone a spinner is worth far more.

Field Notes

Traps That Fail Quietly

Every item here has actually happened. None of them produces an error message.

SymptomCauseFix
A widget renders as a plain code blockthe fence meta did not match — runs, run=true and Run all fail; it must be the bare wordcheck the meta character by character
Two code blocks became one tabbed cardadjacent fences group by designput a sentence between them
A quiz can never be answered correctlyanswer does not exactly equal one optionstring equality, not index — check whitespace
An unexpected lesson in the sidebarevery .md under a book is a pagemove notes out of the book tree, or prefix with _
The editorial tab is missingthe lesson has no kind: problemadd it — the sidecar is ignored without it
Every architecture diagram on the site brokea second specification {} blockthere is exactly one; extend it additively
A relationship vanished from a C4 viewthe build dropped it and still exited 0grep the build log for error
An image 404smedia put beside the lesson instead of in the root _media/move it; the path is _media/<book>/<lesson>/
Sign-in fails with an invisible 403the web dev server is not on 5373free the port; the realm allow-lists that exact origin
The server exits at bootPostgres is unreachable, or synapse_rs does not existthe system of record does not degrade — create the database
An intra-book link 404sthe numeric prefix was left in the URLstrip it: 01-foundationsfoundations
In Short

The Whole Thing in Ten Lines

Once the four tools from Step 1 are installed, this is the entire thing:

mkdir -p ~/synapse-workspace && cd ~/synapse-workspace
git clone https://github.com/ani2fun/synapse.git
git clone https://github.com/ani2fun/synapse-content.git
cd synapse
docker compose up -d db go-judge keycloak
docker compose exec db createdb -U synapse synapse_rs
dev-tools/dev                                  # → http://localhost:5373
 
# then, in the other folder:
#   mkdir my-book && write book.json
#   mkdir my-book/01-foundations
#   write my-book/01-foundations/01-first.md with title + summary frontmatter
#   refresh the page — it is already there

That last line is the part worth keeping. There is no build step between writing a lesson and reading it, and no build step between merging one and it being live. The whole authoring pipeline is a file, a commit, and a symlink — and every feature in this guide is something you get by typing a word after a triple backtick.