**The API.** Every route now lives under `/api/v1`, with `/api/...` rewritten
onto it — one route, two spellings, so they cannot drift and the OpenAPI
document describes each endpoint once. Errors carry an `error` object with a
stable code, one human sentence and, for a validation failure, the fields that
were wrong; `detail` is untouched so nothing that reads it breaks. The whole
surface — 320 routes, their parameters and their status codes — is checked in
as `backend/tests/api-contract.json`, and a test fails on any difference,
naming the routes that moved. `docs/api.md` is the contract in prose.
**Refresh tokens**, so an app can stay signed in without keeping a password.
Rows rather than signatures: listable, withdrawable, stored as hashes, rotated
on every use. A spent token coming back ends the whole session, because a theft
and a replay look identical from the server and the safe reading is the unsafe
one. A browser is not given one — it has nowhere to put it and a person to ask.
**An end-to-end stack**: `docker-compose.test.yml` with its own Postgres and
Redis, `e2e/seed.py` for the smallest world the tests name, and Playwright with
five projects — desktop, iPhone, Pixel, iPad and a browserless API project.
Devices because every bug reported this week was a phone bug found by a person
looking at a screenshot; a desktop-only suite would have passed through all of
them. Forty tests, five clean runs.
It found four things in its first hour:
- **A fresh deploy could not start.** `create_all()` ran before
`CREATE EXTENSION vector`, so any database that had never had pgvector
installed died on the first table with a vector column. Invisible here
because this one has had the extension for a year.
- **A figure in a published article was a 404 for everyone but an admin.**
Media in the library is nobody's to read by default, and nothing made an
exception for a drawing an article actually shows — so every illustration
added this week was an empty box for every real user.
- **Every rate limit was one bucket for the whole site.** The backend saw
nginx's address for every request, so ten bad passwords from anybody locked
out everybody, and no log line could say who. nginx now takes the real
address from the proxy and overwrites the header on the way in; uvicorn runs
with --proxy-headers.
- **The reading page's breakpoints disagreed** — 1150px in the component,
820px in the stylesheet. Between them the menu button claimed the contents
drawer and then toggled a class on a rail that was still in the layout: the
contents did not open and the site menu did not either. The button was dead
on every tablet.
And two smaller ones: the login limiter counted successful sign-ins, so eleven
people behind one hospital NAT locked each other out — it is cleared by a
correct password now; and `/uploads/{path}` served GET and HEAD from one route
with one operation id, which makes every OpenAPI client generator refuse the
document.
The first admin's password is generated and printed once at first start when
`DEFAULT_ADMIN_PASSWORD` is blank, rather than the account not existing:
`docker compose logs backend | grep -A3 "FIRST ADMIN"`.
CI (`.forgejo/workflows/tests.yml`) runs the backend suite, the contract, the
frontend suite and the build on every push to dev, main or master, and the
end-to-end stack on those branches and on pull requests into them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
152 lines
7.5 KiB
Markdown
152 lines
7.5 KiB
Markdown
# Writing an article
|
|
|
|
Everything in an article body is Markdown, plus four pieces of syntax of our own.
|
|
None of them need the editor: they are plain text, so they survive copy-paste,
|
|
an AI draft, and a database import equally well.
|
|
|
|
## Cross-references — `[[…]]`
|
|
|
|
A link from one article to another. Two forms:
|
|
|
|
| You write | It renders as | Use when |
|
|
|---|---|---|
|
|
| `[[264\|respiratory failure]]` | a link labelled *respiratory failure* | **always, by preference** |
|
|
| `[[respiratory-failure]]` | a link labelled *respiratory-failure* | quick drafting |
|
|
|
|
The first number is the article's **id**, and the id is the part that cannot
|
|
change. A slug can be renamed — the old one keeps resolving, because every
|
|
former slug is kept in `article_slugs` — but an id is permanent, so the id form
|
|
is the one that cannot rot. The label after the bar is whatever reads naturally
|
|
in the sentence: *"…severe viral `[[3|bronchiolitis]]`…"* reads as
|
|
"…severe viral bronchiolitis…", with the two words linked.
|
|
|
|
Where the id comes from: the article's URL. `/articles/264` is id 264. The
|
|
editor's Linked-questions panel and the library listing both show it too.
|
|
|
|
What happens at the far end: the link opens the article **in a side pane** next
|
|
to what you are reading, rather than navigating away — following a reference
|
|
does not lose your place. A marker pointing at an article that does not exist is
|
|
reported on the editor as a broken link rather than rendering as dead text.
|
|
|
|
Inside a **card or a list row** the same marker is flattened to its label
|
|
instead of being rendered — a link inside something that is already a link would
|
|
swallow the click.
|
|
|
|
## Key points — `==…==`
|
|
|
|
`Give ==IVIG within 10 days== of fever onset.` — the words between the double
|
|
equals are highlighted in yellow, the way a highlighter is run over a textbook.
|
|
|
|
Use it in the **high-yield view**, which is what it is for: a summary where
|
|
nothing is emphasised is a shorter article, not a revision aid. A few phrases a
|
|
section, never a whole paragraph.
|
|
|
|
Rules the parser follows, so you can predict it:
|
|
|
|
- A highlight may not run across a line break, so one stray `==` cannot swallow
|
|
the rest of a section.
|
|
- `Sodium == 140` is a lab value, not an unclosed highlight: a lone marker stays
|
|
the characters you typed.
|
|
- `====` is four equals signs, not an empty highlight.
|
|
|
|
The Key button in the editor toolbar wraps the selection for you.
|
|
|
|
## Teaching tips — `{{phrase|the tip}}`
|
|
|
|
`{{stridor|Inspiratory stridor is extrathoracic until proven otherwise}}` — the
|
|
phrase is underlined in the prose, and the sentence appears where the phrase is,
|
|
on a click. It is a cross-reference minus the article: sometimes what a learner
|
|
needs is one sentence, and writing a whole article to hang it on is how a
|
|
sentence goes unwritten.
|
|
|
|
The phrase may not contain a bar or a brace; the tip may not contain braces.
|
|
|
|
## Maths
|
|
|
|
`$…$` inline and `$$…$$` for a display block, rendered by KaTeX. Serum
|
|
osmolality set as plain text is a different sentence from the one you wrote.
|
|
|
|
## The three views
|
|
|
|
Every section belongs to exactly one of them, chosen in the editor's view tabs.
|
|
A view with no sections in it is not offered to the reader at all.
|
|
|
|
| Variant | What it is for |
|
|
|---|---|
|
|
| **long** | The full article: pathophysiology, presentation, workup, management. The body of the work, and the default for anything written before views existed. |
|
|
| **short** | The high-yield revision view. Tight lists of what a candidate must carry into an exam — not a summary of the long article's structure. This is where `==key points==` belong. |
|
|
| **clinical** | What to do at the bedside, when the topic has one. Assessment, immediate management, escalation, disposition. Omit it entirely for a topic that is not acted on clinically. |
|
|
|
|
An AI draft is asked for all three (see `ARTICLE_DRAFT_PROMPT`); it is told
|
|
explicitly *not* to categorise the article or link it to questions, because both
|
|
of those are judgements an educator makes.
|
|
|
|
## Linking questions to an article
|
|
|
|
From the article editor, under **Linked questions**:
|
|
|
|
- **One at a time** — search by the stem, choose whether the link lands on the
|
|
whole article or on one section.
|
|
- **A whole topic at once** — choose a category, and every question filed under
|
|
it (including its subtopics, unless you say otherwise) is linked in one action.
|
|
The button carries the number, so you can see that "Cardiology" means 43
|
|
questions before you press it. Up to 300 in one go.
|
|
|
|
Either way these are ordinary link rows: removable one at a time, and a
|
|
*snapshot* rather than a rule. Questions filed under that category tomorrow are
|
|
**not** linked automatically — the alternative would silently attach new
|
|
questions to an article nobody has looked at since.
|
|
|
|
The same list is editable from each question's own editor, which is the same
|
|
relationship seen from the other end.
|
|
|
|
## Linking cards to a question, and to an article
|
|
|
|
Cards go the other way round: the link is made **from the card**, in the deck
|
|
browser, and read from the question's end only.
|
|
|
|
- Open **Cards**, then the deck, then the ⛓ control on a card.
|
|
- Search a question by its stem, or an article by its title, and link it.
|
|
|
|
What a learner then sees: under the correct answer, beside the ▤ chips for
|
|
topic reading, a **▦ chip naming the deck** — one chip per deck however many of
|
|
its cards are tied to that question, because three doors to the same room is
|
|
one door too many. The same chips appear in the answer review of a finished
|
|
attempt.
|
|
|
|
Why only from the card's side: a card that listed the questions it belongs to
|
|
would hand a learner revising the deck the shape of the exam, and the back of a
|
|
card is an answer.
|
|
|
|
**A deck nobody shared is invisible.** Cards generated from an article land in
|
|
a private deck called *Cards: <title>*, owned by whoever pressed the button;
|
|
it appears in nobody else's list, and the chip is not drawn for anyone who
|
|
cannot open the deck. Sharing is a deliberate second action, after reading what
|
|
the model wrote.
|
|
|
|
## What can be linked to what
|
|
|
|
| From | To | Where you do it | What the reader gets |
|
|
|---|---|---|---|
|
|
| Article prose | Article | `[[264\|label]]` in the text | A link with a hover card: excerpt, new tab, or a pane beside what they are reading |
|
|
| Article | Question | Editor → Linked questions | Practice under the article, and the article under the question's answer |
|
|
| Question | Article | Question editor → the same list | ▤ chips under the correct answer, landing on the article or one of its sections |
|
|
| Card | Question | Cards → deck → ⛓ | ▦ chip under the correct answer, opening the deck to study |
|
|
| Card | Article | Cards → deck → ⛓ | The card listed under *Related cards* on the article |
|
|
| Explanation prose | Article | `[[264\|label]]`, same as anywhere | The same hover card |
|
|
|
|
A question→article link can land on **one section** rather than the whole
|
|
article — choose the section when you make the link, and the reader opens at
|
|
that heading. A cross-reference written in prose cannot: `[[264|label]]` always
|
|
means the whole article.
|
|
|
|
## Deleting
|
|
|
|
- A draft that was **never published** is deleted outright. There is nothing to
|
|
restore, and a trash full of abandoned stubs is a second list to maintain.
|
|
- Anything that **has been published**, even once, goes to the **trash** in
|
|
Editorial and can be restored exactly as it was. Somewhere there is a
|
|
learner's note against one of its sections, a question linked to it, and a
|
|
link somebody sent a colleague.
|
|
|
|
The confirmation on the editor says which of the two you are about to do.
|