Files
mathew/AGENTS.md
Aran Roig e36fef7290
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 29s
Calc2
2026-10-02 02:14:29 +02:00

346 lines
25 KiB
Markdown

# Mathew — a wiki for mathematics
A collaborative wiki of math concepts. Articles are written in Markdown with LaTeX
(KaTeX) and stored in MongoDB behind an Express API; a Nuxt 4 app renders and edits them.
The interface itself speaks five languages (en, es, ca, fr, de), chosen per account.
```
mathew/
├── dev.sh # starts/stops both dev servers together
├── backend/ Express 5 + Mongoose API (port 5000)
└── frontend/ Nuxt 4 / Vue 3 wiki UI (port 3000)
```
## Running the project
Requires MongoDB on `localhost:27017` (database `mathew`).
```bash
./dev.sh # both dev servers in foreground (Ctrl+C stops both)
./dev.sh start|stop|restart|status
./dev.sh logs [all|backend|frontend]
```
Or per-service: `npm run dev` inside `backend/` or `frontend/`
(backend uses `node --watch`; backend has no build step).
Seed sample articles: `npm run seed` in `backend/`.
Environment: `backend/.env` (`PORT=5000`, `MONGO_URI=mongodb://127.0.0.1:27017/mathew`,
`JWT_SECRET` — signs session tokens, `AUTH_TOKEN_TTL` — how long one lasts, default `7d`;
see `.env.example`). Without `JWT_SECRET` the API warns and uses an insecure dev key.
Production (`docker-compose.yml`) points the backend at the LAN MongoDB server
(`MONGO_URI=mongodb://192.168.1.7:27017/mathew`; development keeps `localhost:27017` via
`backend/.env`); `JWT_SECRET` interpolates from
`/var/www/app/.env` on the deploy host (create it once — the public site must not sign sessions
with the dev key). The stack's nginx resolves `backend`/`frontend` through Docker's DNS per
request, so container recreates cannot leave it on a stale IP.
Frontend API URL comes from `NUXT_PUBLIC_API_BASE`
(default `http://localhost:5000`, read via `runtimeConfig.public.apiBase`). The frontend image
builds it empty: the browser then calls `/api` on its own origin, which nginx routes to
`backend:5000`, while the Nuxt server's own (SSR) `/api` calls go through a `routeRules` proxy
aimed at `NUXT_API_PROXY_TARGET` (`https://mathew.aranroig.com` baked in at image build — the
public address nginx routes on to the backend, since the frontend may not reach containers
by service name).
There is **no test suite, linter, or typecheck script** configured — don't invent commands.
## Backend (`backend/`)
Plain JavaScript, ESM (`"type": "module"`, `.js` files with relative `.js` import specifiers).
- `src/index.js` — app setup: CORS, JSON body, `/health`, routers, 404 handler, central
error handler (Mongoose `ValidationError`/`CastError` → 400), graceful shutdown. After
the connection it calls `User.ensureAnAdmin()` so somebody can always open the dashboard,
and `Article.replaceReservedLanguageIndex()` so an older database can write an article in
any language (see the language gotcha below).
- `src/config/db.js` — `connectDB` / `disconnectDB`.
- `src/models/` — Mongoose schemas with `timestamps: true`. `Article` (slug, title,
summary, content, tags + text index that points its reserved `language_override`
at a field no article carries — see the language gotcha below), `Item` (simple demo resource),
`User` (username + `passwordHash`, `select: false`; `setPassword`/`checkPassword`
via bcrypt, `toPublic()` for responses, and `credentialProblem()` as the single
place account rules are checked). The profile picture lives on `User.avatar` as a
small data URL — no file storage. `User.locale` is the interface language the
person picked (`SUPPORTED_LOCALES`: `en es ca fr de`, default `'en'`).
`User.role` is `'admin'` or `'member'`
(`ROLES`); `User.hasNone()` and `User.ensureAnAdmin()` cover who gets the
former — see the roles gotcha below.
`EditProposal` — a member's request for a change. `kind` is `'edit'` — a page that
exists, named by its `article` `_id` — or `'create'`, a page the wiki does not have
yet: `article` stays null until an approval writes one, and `slug`/`baseSlug` keep
the address the request asks for and the version group a proposed translation joins.
Either way it holds the whole proposed state (title/content/tags/language), a `base`
snapshot of the article as it read when the request was sent (the diff's other half,
empty for a creation), a `status` (`pending`/`approved`/`rejected`), who sent it
(`createdBy` + the name recorded alongside, so it reads after an account closes) and
who decided it.
- `src/routes/` — one Express `Router` per resource, mounted in `index.js`.
- `src/routes/auth.js` — `POST /api/auth/register` (creates the account and returns
its session; the wiki's **first** account is created as an admin),
`POST /api/auth/login`, `GET /api/auth/me` (who a token belongs to).
Login answers the same way to a wrong name and a wrong password. Account updates,
all behind `requireAuth`: `PUT /api/auth/me` (rename), `PUT /api/auth/password`
(needs the current one), `PUT /api/auth/avatar` (data URL, `""` clears),
`PUT /api/auth/locale` (interface language, one of `SUPPORTED_LOCALES`).
- `src/routes/articles.js` — reads open, writes by role. An **admin**'s save applies
straight: `POST /api/articles` creates through `createArticle()` and
`PUT /api/articles/slug/:slug` writes through `applyArticleEdit()` (which re-slugs on
a title change and refuses a language the version group already has); both answer the
wiki's rules through `resolveCreation()` and `requestError()`, so a create and an
approved request cannot drift apart. A **member's** same save answers `202` with
`{ proposal }` instead — an `EditProposal` upserted per (article, member, pending),
or, for a page that does not exist yet, per (member, pending, asked-for slug): saving
again *revises* the waiting request, and a draft that retitles itself sends the
`proposalId` the first answer carried so it stays one request.
`DELETE` by slug runs `requireAuth, requireAdmin` alone, and settles (deletes) that
article's proposals with it.
- `src/routes/admin.js` — mounted at `/api/admin`, everything behind
`requireAuth, requireAdmin`: `GET /api/admin/users` (every account, newest
first, with `createdAt`), `PUT /api/admin/users/:id/role` (`role` is `'admin'`
or `'member'`; an admin's own role is the one they cannot change), and
`DELETE /api/admin/users/:id` (closes an account for good — never their own,
by the same rule; the articles it wrote stay, since articles name no author).
The edit-request shelf: `GET /api/admin/proposals` (every request, decided ones
included, each with its proposer, its `base`/proposed text and the article as it
stands now), `GET /api/admin/proposals/count` (just `{ pending }` — how many
still wait, which is what the sidebar's badge on the Requests link wears),
`PUT /api/admin/proposals/:id/approve` (an edit is written through
`applyArticleEdit`, a creation through `createArticle` — so a page that appeared
meanwhile, or a version group that grew the language asked for, is refused the same
honest way and the request stays waiting; a gone article or an already-decided
request is an error) and `PUT /api/admin/proposals/:id/reject` (refuses; the page
stays put).
- `src/middleware/auth.js` — `signToken(user)`, `requireAuth`, which reads
`Authorization: Bearer <token>` and sets `req.user = { id, username }`; anything
else is answered with 401 `{ message }`. `requireAdmin` runs after it and reads
the role off the **account** (403 `{ message }` for a member), so removing the
role works the next time the dashboard is asked for.
- `src/seed.js` — sample articles.
Conventions to follow:
- Every handler is `async` with `try { ... } catch (err) { next(err); }`; the central
error handler in `index.js` formats responses (`{ message }` everywhere).
- Articles are addressed by **slug**, not by Mongo id (`/api/articles/slug/:slug`).
`slugify()` / `uniqueSlug()` (in `routes/articles.js`) generate uniquified slugs
(`-2`, `-3`, ... suffixes); POST requires `title`, PUT re-slugs when the title changes.
- The list endpoint (`GET /api/articles`) selects `-content` (no body) and answers
`{ articles, total, hasMore }`. It filters by article, not by document — `?q=`
(regex on title/tags), `?tag=` a tag any version wears, `?lang=` a language any
version is written in — and `?limit=` / `?offset=` page it **by article** (the
identity, not the document), so `total` counts each article once however many
languages it exists in. `?prefer=` (a reader's interface language) picks which
version stands for an article — that language's, else English, else the canonical
one — but never decides which articles answer. `?preferFirst` additionally **ranks**
the answers, leading with the articles that speak that language themselves (the
Ctrl+K search popup asks for it; the shelf and home pages do not). Asked for no
`limit`, the whole shelf answers at once.
- `GET /api/articles/facets` answers `{ total, topics, languages }` counted over the
whole shelf (topics lowercased, both tallied by popularity). The `/articles` page
draws its topic folders and language chips from this, not from the cards it
happens to have on screen.
- Reads are public; the writes (`POST /api/articles`, `PUT`/`DELETE` by slug) run
through `requireAuth`. Of those, a `PUT` applies straight only for an admin and a
`DELETE` is admin-only — a member's `PUT` is answered `202` with a proposal instead
(see the roles gotcha below).
## Frontend (`frontend/`)
Nuxt 4 (srcDir `app/`, TypeScript, Vue 3 `<script setup>` SFCs). No UI framework —
hand-rolled SCSS (devDep `sass`) in `app/assets/css/`: `main.scss` `@use`s one
partial per section (`_tokens`, `_layout`, `_editor`, `_book`, …); shared shapes
(popups, dropdowns, segmented controls, avatars) are mixins in `_mixins.scss`, and
the accent themes generate from a map in `_tokens.scss`. Colours stay CSS custom
properties so themes swap at runtime via `data-theme`/`data-accent` attributes
(set pre-paint by an inline script in `nuxt.config.ts`).
UI wording is localized with `@nuxtjs/i18n` (`frontend/i18n/`): `i18n.config.ts` plus
one JSON catalog per language in `i18n/locales/` (all 350 keys, kept in parity with
`en.json`). `strategy: 'no_prefix'` — the interface language never appears in a URL
(`/wiki/<lang>/<slug>` is the *article's* language) — with cookie detection
(`mathew-locale`, `fallbackLocale: 'en'`) so the server renders in the right language
from the first draw. Every UI string goes through `t()` in the catalogs; messages that
carry `<code>`/`<span>` markup are drawn with `v-html` through `th()`
(`useAppLocale`), which escapes interpolated values. Adding a language = add a catalog
copy + an `APP_LANGUAGES` entry + the code in the backend's `SUPPORTED_LOCALES`.
- `app/pages/` — `/` (search), `/wiki/[lang]/[slug]` (one language version of an
article — the address every version has, English included — drawn as a page of
the open book), `/wiki/[slug]` (an address naming no language: the middleware
turns it onto the version the slug speaks, so what is left of this route is
saying the wiki has nothing there), `/wiki/[slug]/edit`, `/new`,
`/admin` (the account dashboard: every account and its role, plus the buttons that
hand the role on or take it back) and `/admin/proposals` (the edit-request desk:
the requests members have sent — approve, reject, and a
details view of each change as two pages, sent-state beside proposed) — the two
admin surfaces, each reached by its own admin-only sidebar link; an
admin alone sees anything but a refusal on either.
The two editor routes wrap their form in `AuthGate`, so it shows only with a session.
- `app/components/` — PascalCase SFCs (header, editor, MarkdownView, TocNav, etc.).
`AuthGate.vue` is the editor's stand-in for a signed-out visitor; `AuthModal.vue`
is the popup that signs them in; `ProfileModal.vue` is the account popup the
sidebar's account row opens (picture, username, password, interface language,
sign out). `ConfirmModal.vue`
is the yes/no popup any destructive step asks through (the editor's discard, the
dashboard's delete); `ProposalSentModal.vue` is the success popup that confirms a
member's filed request — dismissing it is what leaves the editor. All popups (teleported overlays, closed by esc/backdrop/
navigation like the other popups — opening one calls `hide()` on the others,
they never stack). A row of actions folds into a ⋮ menu built on the shared
`.kebab*` styles: `ArticleActionsMenu.vue` on an article's title row (its Delete
item is drawn for admins alone), `AccountActionsMenu.vue` on a row of the
dashboard (the role and the account). `ProposalRow.vue` is the edit-request desk's row:
who wants what (a change to a page, or a page the wiki does not have yet — a creation
is named by its proposed title with a dashed "new article" mark, since there is no
address to link to until it is granted), the status pill, the approve/reject buttons,
and the details diff — the article as sent against the text proposed, both through
`MarkdownView`, where a creation reads its asked-for address instead of a page that
is not there.
- `app/composables/` — `useRecentlyViewed` (localStorage),
`useAuth` (the session: token in the `mathew-session` cookie so the server sees it
too, `signIn`/`signUp`/`signOut`, `authHeaders()` for write calls, `restoreSession()`
asking `/auth/me` who a stored token belongs to, plus `updateUsername`/
`changePassword`/`setAvatar` for the profile popup, and `isAdmin` — whether the
account holds the admin role, which is what the sidebar's two admin links (Admin
and Requests) and the two admin pages are drawn from), `useSignInPopup` (open state of
that popup, shaped like `useSettings`/`useSearch`), `useProfileMenu` (same, for the
profile popup), `useArticleActionsMenu` (same, for an article's ⋮ menu), and
`useAccountActionsMenu` (same, for a dashboard row's ⋮ — it holds the **id** of
the row that is open, so one row's menu is open at a time rather than a
boolean per row), `usePendingRequests` (how many edit requests wait — shared
state the sidebar draws as a badge on its Requests link and the requests desk
keeps in step: `refresh()` asks the API's count, `report(n)` lays the desk's
own loaded tally on it), and `useAppLocale` (the interface language: `APP_LANGUAGES` —
the five supported UI languages with their own names —, `choose(code)` to turn
the UI (`setLocale` also keeps the `mathew-locale` cookie in step), `applyAccount(code)`
to let an account overrule the device, and `th()` for messages drawn as HTML).
- `app/plugins/auth.ts` — runs `restoreSession()` once at startup on server and client,
so the sidebar's account row and the editor's gate are right in the first render,
and applies the account's `locale` when it restores one.
- `app/utils/markdown.ts` — the single render pipeline: `markdown-it` (**html: false**,
raw HTML is escaped) + `markdown-it-texmath` + KaTeX (`$...$` inline, `$$...$$` display,
macros `\R \N \Z \Q \C`) + highlight.js, sanitized with DOMPurify. It also builds the
TOC. Fenced blocks tagged ` ```example / theorem / corollary / definition / proof /
proposition / lemma ` (optionally followed by a title) render as callout boxes —
blockquote-styled, collapsible through a native `<details>` whose summary is the
label row (always opens open; MarkdownView animates the fold with the Web
Animations API — the intent rides on `data-open`, motion skipped under
`prefers-reduced-motion`), each kind with its own hue (`--box-*` in
`_tokens.scss`, shape `.md-box` in `_prose.scss`) and a label written in the
*article's* language (`MarkdownView`'s `articleLang` → `renderMarkdown`'s env,
never the UI locale). Render all article Markdown through this, not ad-hoc renderers.
- API calls use `$fetch` against `${useRuntimeConfig().public.apiBase}/api`.
## Gotchas
- The **interface language** is not the article language: `/wiki/fr/…` addresses the
French *article*, never the UI. UI precedence: the signed-in account's `locale` (applied
at startup and on signing in) > the `mathew-locale` cookie (written by `setLocale`, read
on the server pre-render) > browser > `en`. The profile popup always turns this
device; with a session it also saves to the account — an account that refuses
(dead session) doesn't undo the switch. Error messages that come back from the API
stay English; only the frontend's own fallbacks are translated.
- **Every version has an address, English included**: `/wiki/<lang>/<identity>`, where the
identity is the canonical version's slug — so `/wiki/en/bayes-theorem` and
`/wiki/fr/bayes-theorem` are one article two ways, and a version's own stored slug
(`bayes-theorem-fr`) appears in no address. `versionHref(identity, lang)`
(`app/utils/routes.ts`) is the one place that writes such an address; the
`open-article` middleware is the one place that reads them and knows where a version
lives, so the short `/wiki/<slug>` form — a wiki link, an old bookmark, a graph node —
is answered by resolving the document the slug speaks and turning the address onto its
version (302 on the server; on the client the navigation to the short address is
abandoned rather than committed, so it never reaches the history and Back still leads to
the page the link was pressed on). The short form opens an article in the reader's own
language when the article speaks it — English failing that (`preferredLanguage` in
`app/utils/languages.ts`, and the home/shelf/search lists pass the same choice to the
API as `?prefer=` so a card shows the version it will open) — while an address that
does name a language is honoured as asked, which is how the version switcher keeps its
promise. A slug the wiki has nothing at keeps its address and says so.
- **Changing an article's language turns its page, it does not open another.** The book's
stack (`useArticleTabs`) is keyed by article *identity*, not by document slug:
`openVersion(article)` finds that article's page and moves its `lang`/`slug`/`title`
over, so the row keeps its shape and the same page shows the other language (the pane
starts at the top of the new text). The tab also carries the document's `_id`, so a
canonical retitle — same document, new identity — follows the page to its new address
instead of opening a second one beside it. `tabHref`/`tabAt`/`closeTab` all speak
identity (+ language); `useArticleLibrary.put` files a document both under its stored
slug and under its `lang:identity` version key, so the short and versioned addresses are
the same page of the shelf; and `useRecentlyViewed` remembers identity + language, so an
article read in two languages is still one article remembered once, at the version it
was last opened at.
- Never call `t()` in `withDefaults` static prop defaults — they don't react to locale
changes. Use `props.x ?? t(...)` in the template/computed instead (see `AuthGate`,
`ConfirmModal`).
- `useAppLocale` takes the composer from `useNuxtApp().$i18n`, **not** `useI18n()`: a Nuxt
plugin has no component instance, and the auth plugin applies the account's language from
one. `useI18n()` there throws *"Must be called at the top of a `setup` function"* — a 500 on
every request from an account with a saved language. Composables a plugin needs are taken
before its first `await`.
- Message catalogs are compiled: literal `{`/`@` break the compiler, so the editor's LaTeX
seed lives as a JS constant in `ArticleForm.vue` and only its words are catalog keys.
- Reading is open; **writing takes an account**. `POST /api/articles` and `PUT`/`DELETE`
by slug need `Authorization: Bearer <token>` from `/api/auth/login` or
`/api/auth/register`. The frontend keeps that token in the `mathew-session` cookie
(readable by the app, sent as a header — it is not an httpOnly session cookie), so a
set `JWT_SECRET` matters in production; anyone with the dev key can mint a session.
- A 401 from a write means the held session is dead; the editor keeps the draft and
opens the sign-in popup over it rather than signing out and unmounting the form.
- Anyone may register, and an account cannot edit another's article (articles store
no author). Roles are the one difference between accounts: `role` is `'admin'` or
`'member'`. **The first account ever registered is the admin** — and on a wiki
whose accounts all predate the role, `User.ensureAnAdmin()` gives it to the oldest
one at startup. Admins promote and demote from `/admin`; nobody may change their
own role, so there is always at least one admin left to open the page. The same
menu deletes an account — through `ConfirmModal`, and never the admin's own, for
the same reason. Articles outlive the account that wrote them.
- **A member's save is not a write — neither a page nor a new one.** `POST /api/articles`
and `PUT /api/articles/slug/:slug` check the account's role: an admin's save applies at
once; a member's is filed as an `EditProposal` and answered `202 { proposal }` — the live
article does not move, and a requested page does not appear. Only one pending request per
(article, member): saving again while one waits *revises* it, and the request records a
`base` snapshot of the article as it read when sent, so the dashboard can diff it and warn
when the page has since moved. A *creation* is held the same way with no article to hang it
on: it keeps the address it asks for (`slug`, plus `baseSlug` when it translates an existing
article), is found by that address while it waits, and takes the `proposalId` its first answer
carried so a retitled draft revises the same request instead of filing a second one for a
page the wiki has twice over. Approving runs the very code a direct admin write runs by —
`applyArticleEdit()` for an edit (so a retitle re-slugs, and a taken language is refused),
`createArticle()` for a creation, which records the article it brought about so the decided
row links to it; the edit-request desk's diff lays a creation against the empty address it asks for.
Rejecting leaves everything alone, and both decided and waiting requests stay on the
edit-request desk's list as the record. **Delete is the one thing no member does at all** — `DELETE`
runs `requireAdmin` and, when it succeeds, deletes that article's proposals with it.
`ArticleForm.vue` reads the `202`: instead of navigating to the (unchanged) article, or one
that does not exist yet, it marks the draft sent and opens `ProposalSentModal` over the
form — so the button cannot be pressed twice on an already-sent draft — and dismissing
that popup (button, esc, backdrop) leaves the editor: to the version that was sent, or
for a creation to the article it translates, else the search page.
- Profile pictures are stored as data URLs on the account (the browser shrinks them
to a 256px square before upload); `express.json` allows 1.5 MB bodies so one fits
a request. A 401 from a profile update means the session died — the popup signs
out and opens the sign-in popup over it.
- Changing an article's title can change its slug; old URLs 404 (no redirects).
- **Any language writes, but MongoDB reserves the name.** A text index reads a document's
`language` field as *which language to analyse it in*, and refuses a value it has no
analyzer for — `ca`, `ja`, `ko`, `ar`, `zh`, `pl`, `uk`, `fa`, `hi` among them, which is
why creating an article in one of those used to answer `500 language override unsupported`.
The article schema's text index points that reserved `language_override` at
`articleLanguage`, a field no article carries, so the wiki's own `language` stays the
wiki's business. Index options cannot change in place (MongoDB answers the second request
with "an equivalent index already exists"), so `index.js` drops a stale text index at
startup through `Article.replaceReservedLanguageIndex()` — one boot repairs an older
database, and any text index added later has to set `language_override` the same way.
- **`/articles` is filed by topic, not a flat shelf.** With no `?tag=` in the
address the page shows one folder per topic (drawn from `/articles/facets`);
a folder is a real link to `?tag=<topic>` and opens onto the articles wearing
it. **An open folder's shelf arrives a batch at a time.** 24 cards come down
with the route, and the row under the grid (`IntersectionObserver`, 600px of
reach) asks for the rest — so a topic or language filter is the API's to
apply (`?tag=&lang=`): the page holds no whole shelf to filter itself, and
changing the filter drops the batches read for the old one. That is also why
the folder tallies come from `/articles/facets` rather than from the cards on
screen — counting the loaded batch would let "40 topics" and the tallies
grow as the reader scrolls.
- Frontend dev server may fall back to port 3001 if 3000 is taken.
- `frontend/types/markdown-it-texmath.d.ts` provides types for the untyped texmath package.