All checks were successful
Build and Deploy Nuxt / build (push) Successful in 24s
354 lines
25 KiB
Markdown
354 lines
25 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.
|
||
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` `@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. 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.
|