24 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.
Production (docker-compose.yml) runs MongoDB as its own mongo service (volume mongo-data)
and passes the backend a MONGO_URI by service name; 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 (MongooseValidationError/CastError→ 400), graceful shutdown. After the connection it callsUser.ensureAnAdmin()so somebody can always open the dashboard, andArticle.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 withtimestamps: true.Article(slug, title, summary, content, tags + text index that points its reservedlanguage_overrideat a field no article carries — see the language gotcha below),Item(simple demo resource),User(username +passwordHash,select: false;setPassword/checkPasswordvia bcrypt,toPublic()for responses, andcredentialProblem()as the single place account rules are checked). The profile picture lives onUser.avataras a small data URL — no file storage.User.localeis the interface language the person picked (SUPPORTED_LOCALES:en es ca fr de, default'en').User.roleis'admin'or'member'(ROLES);User.hasNone()andUser.ensureAnAdmin()cover who gets the former — see the roles gotcha below.EditProposal— a member's request for a change.kindis'edit'— a page that exists, named by itsarticle_id— or'create', a page the wiki does not have yet:articlestays null until an approval writes one, andslug/baseSlugkeep 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), abasesnapshot of the article as it read when the request was sent (the diff's other half, empty for a creation), astatus(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 ExpressRouterper resource, mounted inindex.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 behindrequireAuth: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 ofSUPPORTED_LOCALES).src/routes/articles.js— reads open, writes by role. An admin's save applies straight:POST /api/articlescreates throughcreateArticle()andPUT /api/articles/slug/:slugwrites throughapplyArticleEdit()(which re-slugs on a title change and refuses a language the version group already has); both answer the wiki's rules throughresolveCreation()andrequestError(), so a create and an approved request cannot drift apart. A member's same save answers202with{ proposal }instead — anEditProposalupserted 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 theproposalIdthe first answer carried so it stays one request.DELETEby slug runsrequireAuth, requireAdminalone, and settles (deletes) that article's proposals with it.src/routes/admin.js— mounted at/api/admin, everything behindrequireAuth, requireAdmin:GET /api/admin/users(every account, newest first, withcreatedAt),PUT /api/admin/users/:id/role(roleis'admin'or'member'; an admin's own role is the one they cannot change), andDELETE /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, itsbase/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 throughapplyArticleEdit, a creation throughcreateArticle— 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) andPUT /api/admin/proposals/:id/reject(refuses; the page stays put).src/middleware/auth.js—signToken(user),requireAuth, which readsAuthorization: Bearer <token>and setsreq.user = { id, username }; anything else is answered with 401{ message }.requireAdminruns 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
asyncwithtry { ... } catch (err) { next(err); }; the central error handler inindex.jsformats responses ({ message }everywhere). - Articles are addressed by slug, not by Mongo id (
/api/articles/slug/:slug).slugify()/uniqueSlug()(inroutes/articles.js) generate uniquified slugs (-2,-3, ... suffixes); POST requirestitle, 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), sototalcounts 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.?preferFirstadditionally 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 nolimit, the whole shelf answers at once. GET /api/articles/facetsanswers{ total, topics, languages }counted over the whole shelf (topics lowercased, both tallied by popularity). The/articlespage 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/DELETEby slug) run throughrequireAuth. Of those, aPUTapplies straight only for an admin and aDELETEis admin-only — a member'sPUTis answered202with 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 inAuthGate, so it shows only with a session.app/components/— PascalCase SFCs (header, editor, MarkdownView, TocNav, etc.).AuthGate.vueis the editor's stand-in for a signed-out visitor;AuthModal.vueis the popup that signs them in;ProfileModal.vueis the account popup the sidebar's account row opens (picture, username, password, interface language, sign out).ConfirmModal.vueis the yes/no popup any destructive step asks through (the editor's discard, the dashboard's delete);ProposalSentModal.vueis 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 callshide()on the others, they never stack). A row of actions folds into a ⋮ menu built on the shared.kebab*styles:ArticleActionsMenu.vueon an article's title row (its Delete item is drawn for admins alone),AccountActionsMenu.vueon a row of the dashboard (the role and the account).ProposalRow.vueis 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 throughMarkdownView, 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 themathew-sessioncookie so the server sees it too,signIn/signUp/signOut,authHeaders()for write calls,restoreSession()asking/auth/mewho a stored token belongs to, plusupdateUsername/changePassword/setAvatarfor the profile popup, andisAdmin— 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 likeuseSettings/useSearch),useProfileMenu(same, for the profile popup),useArticleActionsMenu(same, for an article's ⋮ menu), anduseAccountActionsMenu(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), anduseAppLocale(the interface language:APP_LANGUAGES— the five supported UI languages with their own names —,choose(code)to turn the UI (setLocalealso keeps themathew-localecookie in step),applyAccount(code)to let an account overrule the device, andth()for messages drawn as HTML).app/plugins/auth.ts— runsrestoreSession()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'slocalewhen 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
$fetchagainst${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'slocale(applied at startup and on signing in) > themathew-localecookie (written bysetLocale, 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-theoremand/wiki/fr/bayes-theoremare 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; theopen-articlemiddleware 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 (preferredLanguageinapp/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 itslang/slug/titleover, 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/closeTaball speak identity (+ language);useArticleLibrary.putfiles a document both under its stored slug and under itslang:identityversion key, so the short and versioned addresses are the same page of the shelf; anduseRecentlyViewedremembers 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()inwithDefaultsstatic prop defaults — they don't react to locale changes. Useprops.x ?? t(...)in the template/computed instead (seeAuthGate,ConfirmModal). useAppLocaletakes the composer fromuseNuxtApp().$i18n, notuseI18n(): 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 asetupfunction" — a 500 on every request from an account with a saved language. Composables a plugin needs are taken before its firstawait.- Message catalogs are compiled: literal
{/@break the compiler, so the editor's LaTeX seed lives as a JS constant inArticleForm.vueand only its words are catalog keys. - Reading is open; writing takes an account.
POST /api/articlesandPUT/DELETEby slug needAuthorization: Bearer <token>from/api/auth/loginor/api/auth/register. The frontend keeps that token in themathew-sessioncookie (readable by the app, sent as a header — it is not an httpOnly session cookie), so a setJWT_SECRETmatters 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:
roleis'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 — throughConfirmModal, 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/articlesandPUT /api/articles/slug/:slugcheck the account's role: an admin's save applies at once; a member's is filed as anEditProposaland answered202 { 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 abasesnapshot 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, plusbaseSlugwhen it translates an existing article), is found by that address while it waits, and takes theproposalIdits 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 —DELETErunsrequireAdminand, when it succeeds, deletes that article's proposals with it.ArticleForm.vuereads the202: instead of navigating to the (unchanged) article, or one that does not exist yet, it marks the draft sent and opensProposalSentModalover 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.jsonallows 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
languagefield as which language to analyse it in, and refuses a value it has no analyzer for —ca,ja,ko,ar,zh,pl,uk,fa,hiamong them, which is why creating an article in one of those used to answer500 language override unsupported. The article schema's text index points that reservedlanguage_overrideatarticleLanguage, a field no article carries, so the wiki's ownlanguagestays the wiki's business. Index options cannot change in place (MongoDB answers the second request with "an equivalent index already exists"), soindex.jsdrops a stale text index at startup throughArticle.replaceReservedLanguageIndex()— one boot repairs an older database, and any text index added later has to setlanguage_overridethe same way. /articlesis 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/facetsrather 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.tsprovides types for the untyped texmath package.