More things
This commit is contained in:
188
AGENTS.md
188
AGENTS.md
@@ -40,10 +40,13 @@ Plain JavaScript, ESM (`"type": "module"`, `.js` files with relative `.js` impor
|
||||
|
||||
- `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.
|
||||
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), `Item` (simple demo resource),
|
||||
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
|
||||
@@ -52,6 +55,15 @@ Plain JavaScript, ESM (`"type": "module"`, `.js` files with relative `.js` impor
|
||||
`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),
|
||||
@@ -60,12 +72,34 @@ Plain JavaScript, ESM (`"type": "module"`, `.js` files with relative `.js` impor
|
||||
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
|
||||
@@ -80,10 +114,25 @@ Conventions to follow:
|
||||
- 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=`.
|
||||
- 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`.
|
||||
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/`)
|
||||
|
||||
@@ -96,7 +145,7 @@ 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
|
||||
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
|
||||
@@ -105,33 +154,52 @@ 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).
|
||||
- `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, sign out). `ConfirmModal.vue`
|
||||
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). All popups (teleported overlays, closed by esc/backdrop/
|
||||
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,
|
||||
`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),
|
||||
`.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 Admin link and the
|
||||
dashboard are drawn from), `useSignInPopup` (open state of
|
||||
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), and `useAppLocale` (the interface language: `APP_LANGUAGES` —
|
||||
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).
|
||||
@@ -149,13 +217,46 @@ copy + an `APP_LANGUAGES` entry + the code in the backend's `SUPPORTED_LOCALES`.
|
||||
- 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.
|
||||
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`
|
||||
@@ -173,10 +274,53 @@ copy + an `APP_LANGUAGES` entry + the code in the backend's `SUPPORTED_LOCALES`.
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user