9.4 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.
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 (MongooseValidationError/CastError→ 400), graceful shutdown. After the connection it callsUser.ensureAnAdmin()so somebody can always open the dashboard.src/config/db.js—connectDB/disconnectDB.src/models/— Mongoose schemas withtimestamps: true.Article(slug, title, summary, content, tags + text index),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.roleis'admin'or'member'(ROLES);User.hasNone()andUser.ensureAnAdmin()cover who gets the former — see the roles gotcha below.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).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).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 supports?q=(regex search on title/summary/tags) and?tag=. - Reads are public; the writes (
POST /api/articles,PUT/DELETEby slug) run throughrequireAuth.
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).
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 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, sign out).ConfirmModal.vueis 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 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,AccountActionsMenu.vueon 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 viauseState),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 Admin link and the dashboard 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).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.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
- 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. - 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).
- 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.