Files
mathew/AGENTS.md
2026-09-30 11:42:21 +02:00

12 KiB

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. Frontend API URL comes from NUXT_PUBLIC_API_BASE (default http://localhost:5000, read via runtimeConfig.public.apiBase).

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.
  • src/config/db.js — connectDB / disconnectDB.
  • src/models/ — Mongoose schemas with timestamps: true. Article (slug, title, summary, content, tags + text index), 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.
  • 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/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).
  • 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 supports ?q= (regex search on title/summary/tags) and ?tag=.
  • Reads are public; the writes (POST /api/articles, PUT/DELETE by slug) run through requireAuth.

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 309 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/[slug] (article + TOC), /wiki/[slug]/edit, /new, /admin (the dashboard: every account and its role, plus the buttons that hand the role on or take it back — an admin alone sees anything but a refusal here). 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, sign out). ConfirmModal.vue is the yes/no popup any destructive step asks through (the editor's discard, the dashboard's delete). 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, AccountActionsMenu.vue on a row of the dashboard (the role and the account).
  • app/composables/ — useTopics (topics = distinct tags computed client-side from the full article list, shared via useState), 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 Admin link and the dashboard 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), 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. 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 settings 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.
  • 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).
  • 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.
  • 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).
  • 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.