Files
mathew/AGENTS.md
Aran Roig 68b74eb60b
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 24s
Graph calculator improvmentes
2026-10-02 14:53:00 +02:00

25 KiB
Raw Blame History

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).

./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 @uses 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. 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. 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.
  • 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.