All checks were successful
Build and Deploy Nuxt / build (push) Successful in 23s
330 lines
23 KiB
Markdown
330 lines
23 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.
|
|
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` (`http://backend:5000` baked in at image build).
|
|
|
|
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. 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.
|