Files
mathew/AGENTS.md
Aran Roig e2c2c66d7f
Some checks failed
Build and Deploy Lean / build (push) Failing after 16m44s
Build and Deploy Nuxt / build (push) Failing after 48s
Vibecoded CI/CD
2026-10-06 00:23:17 +02:00

456 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 all three services together (lean via docker compose)
├── backend/ Express 5 + Mongoose API (port 5000)
├── frontend/ Nuxt 4 / Vue 3 wiki UI (port 3000)
└── lean/ lean4web editor server — its own Docker image (port 8080)
```
## Running the project
Requires MongoDB on `localhost:27017` (database `mathew`).
```bash
./dev.sh # all three services in foreground (Ctrl+C stops all)
./dev.sh start|stop|restart|status
./dev.sh logs [all|backend|frontend|lean]
```
Or per-service: `npm run dev` inside `backend/` or `frontend/`
(backend uses `node --watch`; backend has no build step). The Lean editor
(`/lean`) is the third service: `dev.sh` brings it up through docker compose
alongside the npm pair — the first build fetches Mathlib and takes a while,
and when docker is unavailable dev.sh says so and still starts the other two.
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 Lean
editor's address comes from `NUXT_PUBLIC_LEAN_URL` (default `http://localhost:8080`,
read via `runtimeConfig.public.leanUrl`) — on an HTTPS site it must be an https://
address too, or the browser blocks it (and the `wss://` derived from it) as mixed content (see `lean/README.md`). 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.
## Lean editor (`lean/`)
`/lean` is now a **native editor**, not an iframe. The frontend speaks to the lean4web
server (`lean/`) directly over its websocket (`ws(s)://<leanUrl>/websocket/<project>`),
carrying the Lean 4 **language server protocol** (LSP): the client sends `initialize`,
`didOpen`/`didChange`, `$/lean/plainGoal`; the server streams back `publishDiagnostics`
and goal answers. That replaces the old frame entirely — the wiki owns the editor (Monaco),
its theme (live, same page), and the Goals/Messages pane.
`lean/Dockerfile` still builds lean4web (upstream's real Lean + Mathlib per session;
`--build-arg LEAN4WEB_REF=<tag|branch>` pins it) and runs its websocket server on port 8080;
the compose `lean` service runs it (image `git.aranroig.com/syndria98/mathew-lean:latest` —
CI builds and pushes it, and deploys lean, only when `lean/**` changes: `deploy-lean.yml`;
the regular deploy just pulls the tag). Upstream asks for a dedicated machine, so the build
is **multi-stage**: dev tooling stays in the builder, and the pushed image is a slim runtime
of node + elan toolchain + built client + Mathlib's compiled cache (~11GB — the cache is
the bulk; upstream's second demo project `Stable` is dropped, its extra ~3GB toolchain was
unused). `--build-arg BAKE_MATHLIB=false` ships Mathlib *out* of the image (~4GB) into a
`.lake` volume, filled once at first start by `lean/entrypoint.sh` — see lean/README.md.
It runs `NO_BWRAP=true` — bubblewrap cannot nest in a container, so Docker itself is the
sandbox; whoever reaches the port can run Lean. Build/run, TLS, security and projects:
`lean/README.md`.
- `frontend/app/utils/lean/lsp.ts` — a minimal LSP JSON-RPC client over a websocket
(request/response pairing, notification dispatch, and auto-ack of the server→client
requests a single-document client has no use for). It negotiates `utf-16` positions,
matching Monaco's column model.
- `frontend/app/utils/lean/lean-language.ts` — registers the `lean4` language with a
Monarch tokenizer and Monaco themes rebuilt from the wiki's live tokens: light/dark surfaces
plus the chosen `--accent`, so the caret, selection, active line number and keywords follow
the accent (`applyLeanTheme()` re-reads the tokens and switches on a live change; themes are
named by their palette, so a redefinition never races one Monaco has already cached).
- `frontend/app/composables/useLeanSession.ts` — the session: connect to a project, open
`Main.lean`, send debounced whole-document `didChange`s, track the cursor for `$/lean/plainGoal`,
split diagnostics into editor marks vs. Messages (severity 3 = Lean's `#check`-style info),
read elaboration progress. Lives at module scope so the running elaboration outlives the
book moving the page between hosts.
- `frontend/app/components/LeanWorkbench.vue` — Monaco + the Goals/Messages sidebar; loads
Monaco (and the worker) client-side only. Provides completions, hover, `\`→unicode input,
and the "open the classic lean4web client" escape link.
## 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 405 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`.
- **The same bundle also ships as a desktop app** (Tauri 2, `src-tauri/`):
`npm run desktop:dev` / `desktop:build` / `desktop:generate` / `desktop:icons`
in `frontend/` (needs Rust and the WebKitGTK stack; the shell, the scripts and
the build prerequisites are `src-tauri/README.md`). `nuxt.config.ts` changes
shape on one flag — `MATHEW_DESKTOP=1`, implied too by the `TAURI_ENV_*` the
Tauri CLI passes to its hooks: `ssr: false`, no `/api` proxy route, the
pre-paint script from `public/preboot.js` instead of inline — and writes the
files to `dist-desktop/public` (never over the web `.output/`), which is what
`frontendDist` reads. Nothing in `app/` is desktop code: `utils/desktop.ts`
asks at run time whether it is in a window (`__TAURI_INTERNALS__`) and only
then does the window's thing — outward links leave through the `opener`
plugin (`plugins/desktop.ts`), and the session token and interface language
mirror into localStorage (`keepTokenRemembered`, since a webview is not owed
a cookie jar for its own `tauri://localhost`). A desktop build bakes the API
base to `https://mathew.aranroig.com` (`NUXT_PUBLIC_API_BASE` overrides, e.g.
`http://localhost:5000` to test against this machine) and leaves `leanUrl`
empty — a window on a random laptop has no `localhost:8080` to find.
- `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. The book can hold the wiki's own pages too: `SearchModal` lists
`/graph` and `/calculator` among its results (matched by name or by words like
*calc*, *plot*, *map*) and opening one lays the live surface on a page of the book
beside the articles — `ToolPane.vue` is that page (spine, ×, `inert` when not in
view), holding the surface extracted as `GraphStage.vue` / `CalculatorStage.vue`.
The 3D plotter is a third page of its own, `/calculator3d` — sidebar link
and all — drawn by `Calculator3DStage.vue` over `Scene3D.vue`: surfaces
z = f(x, y), a sampled mesh projected and shaded by hand onto one canvas,
quads laid farthest-first, no 3D library (`compileSurfaceExpression` in
`utils/plot-expression.ts` is its algebra). The two calculators share the
dock's shapes but never rows — each keeps its own, in localStorage.
A name in a field that is none of x, y, a constant or a called function
is a slider: a lone `k` or a `k = 2` claims one (plots nothing), and every
expression speaking the name answers to its value — the compile pass hands
each result its `vars`, and `useVarSliders` (values, each slider anchored
under the row that claims it, persistence) and `VarSliders.vue` are shared
by both calculators.
The Lean editor is a fourth page, `/lean`: `LeanWorkbench.vue` *is* the editor — Monaco
(loaded client-side; the `lean4` language and two wiki-matched themes live in
`utils/lean/lean-language.ts`) beside a native infoview of Goals, Messages and a status
line. `useLeanSession` speaks the Lean language protocol to the lean4web server (`lean/`,
address `runtimeConfig.public.leanUrl`) over one websocket, and lives at module scope so a
running elaboration survives the book remounting the page. A dead server answers with the
unreachable banner and a retry; a status-bar link opens the classic lean4web client in its
own tab.
`useArticleTabs` files such a page under the identity `page:<name>` and finds it
at its own address (`tabAt`/`tabHref` know `toolFromPath`); while its page is
open, the plain route hosts defer to the book and draw nothing, and asked plain
(no open page — a direct link or bookmark) they draw the stage full-page as ever.
- `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.
- **`/lean` speaks to a server outside the stack.** The wiki does not use an iframe: the
frontend speaks Lean itself (see the **Lean editor** section) — the browser opens
`<leanUrl>/websocket/<project>` (`NUXT_PUBLIC_LEAN_URL`, default `http://localhost:8080`
— an address for the browser, not a server-side proxy target to fix up). The Lean server
is strict about the handshake: `rootUri` must be a *string* (the server's URI rewriter
throws on a null and the message vanishes), and capabilities like `showDocument.support`
and `insertTextFormat.valueSet` must be shaped objects — the Lean watchdog quits the
session otherwise. The Messages pane reads severity-3 diagnostics (that is where Lean
puts `#check` output; there is no `$/lean/plainMessages` anymore). Dev runs the server
with `./dev.sh` (via docker compose; or lean4web natively: `NO_BWRAP=true PORT=8080 npm run prod`).
On an HTTPS site the address must be https (its `wss://` is derived from it — an `http://`
Lean server refuses an https wiki outright).
A tool page like this is wired in step: `ToolPage`/`TOOL_PAGE_PATHS`
(`utils/routes.ts`), the page file, `TOOL_LABEL_KEYS`, the `ToolPane` branch and spine
glyph, the sidebar link, and the `PAGE_TOOLS` entry.
- Frontend dev server may fall back to port 3001 if 3000 is taken — the desktop
shell's `devUrl` is fixed to 3000, so free the port before `npm run desktop:dev`.
- `frontend/types/markdown-it-texmath.d.ts` provides types for the untyped texmath package.