mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
435 lines
28 KiB
435 lines
28 KiB
# Wiki.js 3.x
|
|
|
|
Next-generation open source wiki. This is the **3.x development branch** — incomplete, unstable, and
|
|
with no upgrade path from 2.x. AGPL-3.0.
|
|
|
|
**Nothing here has to stay compatible with an existing installation.** Nobody is expected to be
|
|
running an earlier state of this branch, so do not write migration shims, legacy-value fallbacks,
|
|
deprecated aliases or "old data may still contain X" handling. Change the shape, change the callers,
|
|
and delete the old path — a fallback for a case that cannot occur is dead code that still has to be
|
|
read, tested and reasoned about. This applies to db columns, API payloads, stored settings and
|
|
config keys alike; only real migrations under `backend/db/migrations/` are exempt, because Drizzle
|
|
needs the history to get a live dev database to the current schema.
|
|
|
|
Three independently-installed workspaces (each has its own `package.json` / `node_modules`, there is
|
|
no root package or monorepo tooling):
|
|
|
|
| Path | What it is |
|
|
| ----------- | ------------------------------------------------------------- |
|
|
| `backend/` | Fastify REST API server + job scheduler, Drizzle on PostgreSQL |
|
|
| `frontend/` | Vue 3 / Vite SPA, Tailwind CSS + an in-repo component library |
|
|
| `blocks/` | Lit web components users embed into wiki pages |
|
|
|
|
Requires Node.js **26+** and PostgreSQL **16+**. All three workspaces are ESM (`"type": "module"`).
|
|
|
|
The backend is **TypeScript 7**; `frontend/` and `blocks/` are JavaScript. See
|
|
[TypeScript (backend)](#typescript-backend).
|
|
|
|
## Layout
|
|
|
|
### Root
|
|
|
|
- `config.yml` — instance config (copy of `config.sample.yml`). Read by the backend at boot *and* by
|
|
`frontend/vite.config.js` in dev mode to learn the proxy target port.
|
|
- `assets/` — **build output** of the frontend (`vite build` writes here), plus static assets under
|
|
`assets/_assets/`. Served by the backend. Don't hand-edit.
|
|
- `dev/` — deployment/packaging artifacts: `dev/build/Dockerfile` (production image), `dev/helm/`,
|
|
`dev/packer/`, `dev/noto-emoji-build/`.
|
|
- `.devcontainer/` — VS Code dev container (app + postgres + pgAdmin via docker-compose).
|
|
- `localazy.json` — translation sync config; locale strings live in `backend/locales/`.
|
|
|
|
### `backend/`
|
|
|
|
Entry point is `backend/index.ts`, and it must be run **from the repo root** (`node backend`), not
|
|
from inside `backend/`. It boots in three phases: `preBoot()` (config → db → models → cache →
|
|
scheduler → event emitters), `initHTTPServer()` (Fastify plugins, auth, routes), `postBoot()`
|
|
(refresh locales/strategies/sites from disk & db, start scheduler).
|
|
|
|
- `api/` — REST route plugins, one file per resource (`sites.ts`, `users.ts`, `pages.ts`,
|
|
`system.ts`, `locales.ts`, `authentication.ts`), registered by `api/index.ts` under the `/_api`
|
|
prefix.
|
|
- `api/schemas/` — shared JSON Schemas registered via `app.addSchema()` and referenced from route
|
|
schemas as `{ $ref: 'Site#' }`. Register new shared schemas in `api/index.ts` *before* the routes.
|
|
- `controllers/` — non-API HTTP routes. `site.ts` serves per-site resources (logo, favicon, login
|
|
background) under `/_site`; `icons.ts` serves icons under `/_icons`, implementing the part of the
|
|
Iconify API protocol the frontend speaks (`/_icons/<prefix>.json?icons=a,b` and
|
|
`/_icons/<prefix>/<name>.svg`). Public and cached hard — see [Icons](#icons).
|
|
- `core/` — long-lived singletons: `config.ts` (yml + db-backed settings), `db.ts` (pg pool, Drizzle
|
|
instance, migrations, LISTEN/NOTIFY pubsub), `logger.ts`, `scheduler.ts` (poolifier thread pool +
|
|
postgres-backed job queue).
|
|
- `db/` — `schema.ts` (all Drizzle table definitions), `relations.ts`, `migrations/` (generated).
|
|
- `models/` — data-access classes over Drizzle, aggregated by `models/index.ts` and exposed as
|
|
`WIKI.models.*`. Business logic belongs here, not in route handlers. `types.ts` holds the shared
|
|
`SystemIds` passed to each model's `init()` during first-run seeding.
|
|
- `modules/` — pluggable extensions, discovered from disk. Each module is a directory with a
|
|
`definition.yml` (key, title, props/config schema) plus its implementation — e.g.
|
|
`modules/authentication/local/`. `modules/storage/*` is definition-only so far: the admin area
|
|
stores a configuration per site and module, but no `storage.ts` exists yet and nothing reads or
|
|
writes content through a target — pages and assets go straight to the database.
|
|
- `tasks/simple/` — jobs run in-process by the scheduler; each exports `task()`. File name is
|
|
kebab-case, the task key is its camelCase form.
|
|
- `tasks/workers/` — CPU-bound jobs run in a worker thread via `worker.ts`, which boots a minimal
|
|
`WIKI` global (config + logger + lazy `ensureDb()`) and dynamically imports the task.
|
|
- `base.yml` — system defaults for every config key. Do not edit as a user-facing config; it defines
|
|
the shape merged with `config.yml` and the db `settings` table.
|
|
- `helpers/` — small pure utilities (`common.ts`, `config.ts`).
|
|
- `types/` — ambient declarations: `global.d.ts` (the `WIKI` global) and `fastify.d.ts` (session +
|
|
route-permission augmentations).
|
|
- `locales/` — `en.json` source strings (Localazy-managed) + `metadata.js` language table (the one
|
|
remaining JavaScript file; typed by its sibling `metadata.d.ts`).
|
|
|
|
### `frontend/`
|
|
|
|
Vue 3 on plain Vite. `src/main.js` wires it up manually: router → pinia store → `boot/*`
|
|
initializers → mount. There is no UI framework: `src/components/shared/` is the component library
|
|
(every component is `W*`, used in templates as `<w-btn>`, `<w-input>`, …), registered globally by
|
|
`boot/components.js` and styled with Tailwind.
|
|
|
|
- `src/boot/` — one-time app initializers: `api.js` (creates the `ky` client, exposed
|
|
as the `API_CLIENT` global), `components.js` (global components), `eventbus.js` (`EVENT_BUS` global,
|
|
mitt), `externals.js`, `i18n.js`, `iconify.js` (points Iconify at this instance's `/_icons`),
|
|
`monaco.js`, `temporal.js` (conditionally polyfills `Temporal`, awaited before anything else in
|
|
`main.js`).
|
|
- `src/router/` — `index.js` (router factory) and `routes.js` (the full route table; page components
|
|
are lazily imported).
|
|
- `src/layouts/` — `MainLayout`, `AdminLayout`, `AuthLayout`, `ProfileLayout`.
|
|
- `src/pages/` — route-level views. `Admin*.vue` are the admin area, `Profile*.vue` the user profile.
|
|
- `src/components/` — everything else: dialogs (`*Dialog.vue`), full-screen overlays
|
|
(`*Overlay.vue`), editors (`Editor*.vue`), nav/tree components.
|
|
- `src/stores/` — Pinia stores (`site`, `user`, `page`, `editor`, `admin`, `common`, `flags`).
|
|
`stores/index.js` creates the pinia instance and injects `router` into every store.
|
|
- `src/renderers/` — page content rendering pipeline: `markdown.js` plus `modules/` (katex, kroki,
|
|
plantuml, markdown-it plugins).
|
|
- `src/css/` — `tailwind.css` (theme tokens, utilities and the shared component classes) plus SCSS:
|
|
`_theme.scss` (brand colours) and `_palette.scss` (the Material ramp the older stylesheets use).
|
|
Both are injected into every SFC by `css.preprocessorOptions.scss.additionalData` in
|
|
`vite.config.js`, which is why templates can write bare `$primary` / `$grey-4`.
|
|
- `src/helpers/`, `src/assets/`, `public/`, `index.html`.
|
|
|
|
Path alias `@` → `frontend/src` (defined in `vite.config.js`; `jsconfig.json` mirrors it for the IDE).
|
|
|
|
Dev server runs on **3001** and proxies `/_api`, `/_blocks`, `/_icons`, `/_site`, `/_thumb`, `/_user`
|
|
to the backend on **3000**, so the backend must be running too.
|
|
|
|
### `blocks/`
|
|
|
|
Self-contained Lit components. Each lives in `blocks/block-<name>/component.js` — the glob in
|
|
`rollup.config.mjs` picks up any directory matching `block-*` automatically, so a new block needs no
|
|
config change. Output goes to `blocks/compiled/`, which the backend serves statically under
|
|
`/_blocks/`. Blocks are loaded dynamically at runtime, which is why `_blocks/**` is excluded from
|
|
Vite's `dynamicImportVarsOptions`. A block pulling in a heavy library is fine — nothing is fetched
|
|
until its tag turns up in a page — and a library that still ships CommonJS works too, since the
|
|
rollup config runs `@rollup/plugin-commonjs` after `resolve()`.
|
|
|
|
Blocks style themselves off `:host` and read the theme colors via CSS custom properties
|
|
(`var(--q-primary)` — the `--q-` prefix is historical; the properties are declared in
|
|
`css/tailwind.css` and rewritten at runtime for per-site theming).
|
|
|
|
**Dark mode goes through `blocks/shared/theme.js`, never `:host-context()`.** The app's source of
|
|
truth is the `body--dark` class on `<body>`, which CSS in a shadow root cannot see; `:host-context()`
|
|
is the selector for exactly that and is what every block used to use, but only Chromium ever shipped
|
|
it — MDN has it deprecated, Firefox and Safari never implemented it, and there it silently never
|
|
matches, so the block stayed light on a dark page. Instead construct a `DarkMode` controller
|
|
(`this._darkMode = new DarkMode(this)`) in the block's constructor and write `:host([dark])`; the
|
|
controller keeps that attribute in step, sharing one MutationObserver across every block on the page.
|
|
A block that must *act* on the change rather than restyle for it passes `onChange`, or reads
|
|
`.isDark` — `block-diagram` redraws mermaid in its own dark theme, `block-map` resolves a per-block
|
|
`theme` prop that can pin a map light on a dark page.
|
|
|
|
## Commands
|
|
|
|
Run backend commands from `backend/`, frontend from `frontend/`, blocks from `blocks/`.
|
|
|
|
```sh
|
|
# backend
|
|
npm run dev # nodemon, restarts on any backend file change
|
|
npm run start # plain node
|
|
npm run typecheck # tsc — type check only, never emits
|
|
npm run typecheck:watch
|
|
npm run db-generate # drizzle-kit generate — after editing db/schema.ts
|
|
npm run db-up # drizzle-kit up
|
|
|
|
# frontend
|
|
npm run dev # vite dev server on :3001 (needs backend running on :3000)
|
|
npm run build # builds into ../assets — required before the backend can serve the UI
|
|
|
|
# blocks
|
|
npm run build # rollup → blocks/compiled/
|
|
```
|
|
|
|
`npx ncu -i` (`npm run ncu`) for interactive dependency updates.
|
|
|
|
The API is browsable via Swagger UI at `http://localhost:3000/_api` in a running instance. Default
|
|
admin login is `admin@example.com` / `12345678`.
|
|
|
|
## TypeScript (backend)
|
|
|
|
The backend is entirely **TypeScript 7** (the native Go compiler — `tsc` is a platform binary, not a
|
|
JS bundle). The only remaining `.js` is `locales/metadata.js`, which is Localazy-generated output and
|
|
is typed by a sibling `locales/metadata.d.ts`.
|
|
|
|
**There is no build step.** Node 26 runs `.ts` files directly by stripping types at load time, so
|
|
`node backend` and nodemon keep working unchanged as files are converted. `tsc` is used purely as a
|
|
type checker (`noEmit`) — never to produce output. Do not add a build/dist step.
|
|
|
|
Consequences of type stripping, all enforced by `backend/tsconfig.json`:
|
|
|
|
- **Relative imports must carry the real extension.** A `.ts` file importing a converted module writes
|
|
`./core/config.ts`, not `./core/config.js` and not extensionless — Node resolves the literal path.
|
|
This means converting a file requires updating the specifier in every file that imports it.
|
|
(`allowImportingTsExtensions`)
|
|
- **Only erasable syntax is allowed** — no `enum`, no `namespace`, no constructor parameter
|
|
properties, no `experimentalDecorators`. Use union types or `as const` objects instead of enums.
|
|
(`erasableSyntaxOnly`)
|
|
- **Type-only imports must say `import type`**, otherwise the import survives erasure and Node tries
|
|
to load a value that doesn't exist. (`verbatimModuleSyntax`)
|
|
|
|
`allowJs` is **off** — the backend is fully TypeScript, so a stray `.js` file would silently escape
|
|
type checking rather than be quietly tolerated. `locales/metadata.js` is the sole exception and is
|
|
resolved through its sibling `metadata.d.ts`.
|
|
|
|
`backend/types/global.d.ts` declares the ambient `WIKI` global as the `WikiGlobal` interface, wired
|
|
to the real module types (`WIKI.db` is the Drizzle instance, `WIKI.models` is `models/index.ts`, and
|
|
so on). Only `config` and `data` stay `any` — both are assembled at runtime from YAML plus a JSONB
|
|
settings table, so they have no static shape. `index.ts` and `worker.ts` build their own local `WIKI`
|
|
literal and assert it to `WikiGlobal`, since each populates the object progressively.
|
|
|
|
`backend/types/fastify.d.ts` augments Fastify: session fields (`authenticated`, `user`,
|
|
`permissions`) and the per-route `config.permissions` used by the `preHandler` permission hook.
|
|
|
|
**Four dynamic paths are extension-sensitive** and invisible to the type checker — they must be
|
|
updated by hand if the files they point at are ever renamed:
|
|
|
|
- `core/scheduler.ts` → `path.join(WIKI.SERVERPATH, 'worker.ts')` (the poolifier pool entry)
|
|
- `worker.ts` → `import('./tasks/workers/${kebabCase(job.task)}.ts')`
|
|
- `models/authentication.ts` → `import('../modules/authentication/${stg.module}/authentication.ts')`
|
|
- `models/storage.ts` → `import('../modules/storage/${key}/storage.ts')`, plus the `storage.ts`
|
|
presence check in `hasImplementation()` that gates it
|
|
|
|
`scheduler.ts` reads `tasks/simple/` filenames with `/\.[jt]s$/`, so task files are extension-agnostic.
|
|
|
|
`worker.ts` builds its own minimal `WIKI` (config + logger + lazy `ensureDb()`), but the shared
|
|
declaration types it as the full object — so worker-only code can reference members that do not
|
|
actually exist in a worker thread. Be deliberate about what you touch there.
|
|
|
|
Conventions established during the conversion, worth following in new code:
|
|
|
|
- **`catch (err: any)`** at each site rather than globally disabling `useUnknownInCatchVariables`.
|
|
Strict mode types a caught error as `unknown`, and this codebase reads `err.message` everywhere;
|
|
annotating per-site keeps the looseness visible instead of hiding it in tsconfig.
|
|
- **Per-route Fastify generics** for request shapes: `app.get<{ Params: { siteId: string } }>(...)`.
|
|
The JSON Schema stays as-is for validation and OpenAPI; the generic is what types `req.params`,
|
|
`req.body` and `req.query`.
|
|
- **Pre-existing bugs are preserved, not fixed.** Where the type checker exposed already-broken code,
|
|
it was left behaving identically behind a narrow cast plus a `FIXME:` comment explaining the real
|
|
fix. A migration should not silently change runtime behavior. Search `FIXME:` under `backend/` for
|
|
the list — they are genuine open bugs, not type-checker noise.
|
|
|
|
## Conventions
|
|
|
|
### Style, linting, formatting
|
|
|
|
**oxlint** for linting, **oxfmt** for formatting — not ESLint or Prettier (ESLint is explicitly
|
|
disabled in `.vscode/settings.json`). Both are devDependencies of `backend/` and `frontend/`.
|
|
|
|
```sh
|
|
npx oxlint # from backend/ or frontend/ — uses that dir's .oxlintrc.json
|
|
npx oxfmt <paths> # config is the repo-root .oxfmtrc.json
|
|
```
|
|
|
|
Format settings (root `.oxfmtrc.json`): no semicolons, single quotes, no trailing commas,
|
|
`bracketSameLine`, LF, final newline. 2-space indent, per `.editorconfig`.
|
|
|
|
Otherwise follow **standard JS** rules. Note that much of `frontend/` predates oxfmt and still uses
|
|
the standard-style space before parens (`function initializeRouter ()`); new and touched code should
|
|
be oxfmt-formatted, but don't reformat untouched files as drive-by changes.
|
|
|
|
Each workspace has its own `.oxlintrc.json` — the backend declares the `WIKI` global and node env;
|
|
the frontend adds the `vue` plugin and the `API_CLIENT` / `EVENT_BUS` / `Temporal` globals. Only the
|
|
`correctness` category is an error.
|
|
|
|
Both tools handle `.ts` with no extra configuration, and the backend's oxlint config already enables
|
|
the `typescript` plugin. oxlint does not type-check — run `npm run typecheck` for that.
|
|
|
|
**Never put two statements in a Vue template attribute.** `@click="doOne(); doTwo()"` builds today
|
|
and is a build error the moment the file is formatted, because `semi: false` and Vue disagree about
|
|
the same character. Vue's `transformOn` decides whether an inline handler is a statement block or an
|
|
expression from `exp.content.includes(';')` — with the semicolon it emits `$event => { … }`,
|
|
without it `$event => ( … )`. oxfmt breaks the handler across lines and drops the semicolon, so Vue
|
|
parenthesises two statements and the template fails to compile (`Error parsing JavaScript
|
|
expression: Unexpected token`). Write a named handler instead — `@click="closeAndRefresh"` — as
|
|
`EditorMarkdown.vue` and `PageRelationDialog.vue` do.
|
|
|
|
Neither side of that is worth reconfiguring, so don't try: the `includes(';')` check has no compiler
|
|
option behind it, and the parse error is raised by the built-in `transformExpression`, which
|
|
`baseCompile` runs *before* any `nodeTransforms` you could add — and Volar runs the same compiler,
|
|
so a build-time workaround would still leave the editor showing errors. On the formatter side,
|
|
`embeddedLanguageFormatting: "off"` does leave attribute expressions alone but also stops formatting
|
|
every `<script>` and `<style>` block in every SFC. This is not an oxfmt quirk either: Prettier with
|
|
`--no-semi` produces identical output. For a one-off where the inline form genuinely reads better,
|
|
`<!-- prettier-ignore -->` on the preceding line works (oxfmt honors Prettier's marker; there is no
|
|
`oxfmt-ignore`).
|
|
|
|
### Utilities and dates
|
|
|
|
These apply to **every workspace**, `frontend/` included — not just the backend.
|
|
|
|
- **Use `es-toolkit`, not `lodash-es`.** Installed in both `backend/` and `frontend/`.
|
|
- **Use the native `Temporal` API, not luxon.** See [Backend patterns](#backend-patterns) for the
|
|
Temporal gotchas worth knowing; they apply on the frontend too.
|
|
- **luxon and lodash-es are being removed entirely.** The migration is gradual: when you touch a file
|
|
that imports either one, convert that file's usages as part of the same change — but don't sweep
|
|
through untouched files as a drive-by. Once the last usage is gone, both dependencies get dropped.
|
|
- Prefer real es-toolkit subpath exports (`es-toolkit/object`, `es-toolkit/array`,
|
|
`es-toolkit/predicate`) over `es-toolkit/compat`. Two lodash helpers are compat-only and have direct
|
|
equivalents: `defaultsDeep(source, defaults)` → `toMerged(defaults, source)` (note the argument
|
|
order flips) and `toSafeInteger(x)` → `Number.parseInt(x, 10)`.
|
|
- On the frontend `Temporal` is a global, declared in `.oxlintrc.json`. `src/boot/temporal.js`
|
|
dynamically imports `temporal-polyfill` for browsers without native support (Safari, as of
|
|
mid-2026) and is awaited first in `main.js`. The polyfill is a lazy chunk (~21 kB gzipped) that
|
|
browsers with native `Temporal` never download.
|
|
|
|
### Permissions
|
|
|
|
There are **two kinds of permission**, granted separately and checked in different places. Which
|
|
kind a name belongs to decides how it may be enforced, so it is the first thing to establish about
|
|
any permission you touch.
|
|
|
|
**Global permissions** are held site-wide, bound to no path: `access:admin`, `manage:users`,
|
|
`manage:groups`, `manage:navigation`, `manage:theme`, `manage:sites`, `manage:system`. That list is
|
|
the whole of it — the one offered by the group editor (`GroupEditOverlay.vue`). They live on a
|
|
group's `permissions` column, are flattened onto `req.session.permissions` at login
|
|
(`models/users.ts` → `updateSession`), and are what the per-route `config.permissions` hook
|
|
checks. `manage:system` bypasses every check everywhere.
|
|
|
|
**Page rule permissions** are bound to paths, and to locales and sites: `read:pages`, `write:pages`,
|
|
`review:pages`, `manage:pages`, `delete:pages`, `write:styles`, `write:scripts`, `read:source`,
|
|
`read:history`, `read:assets`, `write:assets`, `manage:assets`, `read:comments`, `write:comments`,
|
|
`manage:comments` (`PAGE_PERMISSIONS` in `api/pages.ts`). A group grants them through **rules**:
|
|
each rule names some of them (`roles`) plus how it addresses pages (`match` + `path`, or tags) and
|
|
what it does with them (`mode`: ALLOW / DENY / FORCEALLOW). Nothing is granted by default, and when
|
|
several rules match, the most specific one wins — `helpers/pageRules.ts` documents the ordering.
|
|
Ask `WIKI.models.groups.checkAccess(actor, permission, page)`, or `mayOnPage(req, permission, page)`
|
|
in `api/pages.ts`.
|
|
|
|
Consequences worth knowing:
|
|
|
|
- **A page permission cannot be enforced by `config.permissions`.** That hook reads the group-wide
|
|
list only, so `permissions: ['write:pages']` refuses everybody. A route that turns on a page
|
|
permission declares no route permission and checks in the handler instead — say so with a
|
|
`No route-level permissions:` comment, as `api/pages.ts`, `api/assets.ts` and `api/blocks.ts` do.
|
|
- **The two names are not interchangeable.** `manage:pages` does not imply `write:pages`: a rule
|
|
grants the exact strings in its `roles`.
|
|
- **On the frontend**, `userStore.permissions` is the global list (from `users/whoami`) and
|
|
`userStore.pagePermissions` is what the session holds AT THE CURRENT PATH (from
|
|
`pages/userPermissions`, refreshed per route in `App.vue`). `userStore.can()` ORs the two and
|
|
treats `manage:system` as a wildcard, so it answers "may do this somewhere". Gate a control over
|
|
the page in front of the reader on `pagePermissions` — that is what the endpoint behind the
|
|
button will check.
|
|
- **An anonymous request is the guests group**, not an absence of groups: that is how a wiki opens
|
|
reading, and suggesting edits, to the public. Deny guests explicitly where an account is genuinely
|
|
required (`reviewerFor` in `api/approvals.ts` is the worked example).
|
|
- **Never invent a permission name.** Both lists above are closed; `can('browse:fileman')` and
|
|
friends matched nothing and silently hid the controls they guarded.
|
|
|
|
### Backend patterns
|
|
|
|
- **The `WIKI` global.** Set up in `index.ts`, typed in `types/global.d.ts`, available everywhere
|
|
without importing:
|
|
`WIKI.db` (Drizzle), `WIKI.models.*`, `WIKI.config`, `WIKI.logger`, `WIKI.cache`, `WIKI.scheduler`,
|
|
`WIKI.events.{inbound,outbound}` (Emittery), `WIKI.sites` / `WIKI.sitesMappings` (cached site
|
|
configs), `WIKI.ROOTPATH`, `WIKI.SERVERPATH`, `WIKI.INSTANCE_ID`.
|
|
- **Routes** are Fastify plugins: `async function routes(app) { ... }` with a default export.
|
|
- **Permissions** are declared per-route in `config.permissions`, and enforced by a single
|
|
`preHandler` hook in `index.ts`. The array is OR-ed; a nested array is AND-ed
|
|
(`permissions: ['read:sites', ['manage:users', 'manage:groups']]`). `manage:system` bypasses every
|
|
check. `@fastify/swagger`'s `transform` folds these into the OpenAPI description automatically —
|
|
so declaring them is also how they get documented. Only **global** permissions belong here; see
|
|
[Permissions](#permissions) for the other kind and how they are checked.
|
|
- **Every route needs a `schema`** with `summary`, `tags`, and response schemas. `hideUntagged` is on,
|
|
so an untagged route is invisible in the API docs. Reuse `$ref` schemas from `api/schemas/`.
|
|
- **Errors** via `@fastify/sensible` helpers (`reply.notFound()`, `reply.badRequest()`,
|
|
`reply.unauthorized()`, `reply.forbidden()`). The `setErrorHandler` in `index.ts` shapes `/_api/`
|
|
failures into `{ ok, error, statusCode, message }` JSON.
|
|
- **Schema changes**: edit `db/schema.ts`, then `npm run db-generate` and commit the generated
|
|
migration. Never hand-edit an existing migration.
|
|
- **Dates use the native `Temporal` API**, not luxon (no longer a backend dependency). `Temporal` is a
|
|
global in Node 26 and is typed by the TS 7 lib, so it needs no import. Four things to know:
|
|
- `Temporal.Instant` accepts **exact time units only** — `add({ days: 1 })` throws. Since these are
|
|
all UTC instants, use `{ hours: 24 }`.
|
|
- Temporal types have no `valueOf`, so `a < b` **throws**. Compare with
|
|
`Temporal.Instant.compare(a, b)`.
|
|
- `Instant.toString()` defaults to nanosecond precision; pass
|
|
`{ smallestUnit: 'millisecond' }` for values written to postgres or compared as strings, which is
|
|
what the rest of the codebase emits.
|
|
- Converting: `date.toTemporalInstant()` from a `Date` (what drizzle returns for `timestamp`
|
|
columns), `Temporal.Instant.from(str)` for postgres-format strings (what raw `db.execute()`
|
|
returns), and `new Date(instant.epochMilliseconds)` going back the other way.
|
|
|
|
### Frontend patterns
|
|
|
|
- **Templates are plain HTML.** A handful of pre-3.x leftovers are still `<template lang="pug">` —
|
|
check the file you're editing rather than assuming.
|
|
- **UI components come from `components/shared/`**, registered globally, so `<w-btn>` / `<w-input>` /
|
|
`<w-icon>` need no import. Each one is scoped to how this app actually uses it rather than to the
|
|
full API of the framework component it replaced; the header comment in each file says where they
|
|
differ. Add a prop there rather than reaching around it.
|
|
- HTTP calls go through the `ky` client, reachable as the `API_CLIENT` global (declared in the oxlint
|
|
config, so no import needed) — e.g. `await API_CLIENT.get('sites').json()`. It handles the `/_api`
|
|
prefix; authentication is the session cookie, sent with every request.
|
|
- Cross-component messaging uses the `EVENT_BUS` global (mitt).
|
|
- State lives in Pinia option stores. For utilities and dates use `es-toolkit` and `Temporal` — see
|
|
[Utilities and dates](#utilities-and-dates); the `lodash-es` and `luxon` still present in older
|
|
files are on their way out.
|
|
|
|
### Icons
|
|
|
|
Icons come from **Iconify** and are referenced the way Iconify references them — `<prefix>:<name>`,
|
|
e.g. `mdi:account-edit`. That string is all that content, navigation items and page relations ever
|
|
store; no SVG is ever written into content.
|
|
|
|
- **Admin** (`AdminIcons.vue` → `/_api/icons`) manages which sets exist: adding a set stores its
|
|
metadata only, and enabling/disabling one controls whether its icons can be searched and filled in.
|
|
- **`models/icons.ts`** resolves a reference through four tiers — memory, disk
|
|
(`<dataPath>/cache/icons/<prefix>/<name>.json`), the `icons` db table, then the Iconify API. **Only
|
|
the db is permanent**; the disk cache is derived and starts empty on a fresh instance, so never treat
|
|
it as storage. The upstream API is consulted only for an icon nobody has used yet, is capped per
|
|
minute (public routes can trigger a fill), and is skipped entirely when `offline` is set.
|
|
- **Serving** is `controllers/icons.ts` under `/_icons`, cached for a year and immutable. Rendering a
|
|
page never resolves an icon server-side.
|
|
- **Frontend**: render every icon with `<w-icon :name>` (`components/shared/WIcon.vue`).
|
|
Components that take an `icon` prop go through it too, so every form works there.
|
|
- Every Iconify reference written **literally in this repo's source** is inlined at build time by
|
|
`scripts/generate-icons.mjs` into `src/assets/icons.generated.js` (committed) and drawn as an
|
|
inline `<svg>`. Run `npm run icons` after adding or removing one; `check-icons.mjs` fails if the
|
|
bundle drifts. This is why the interface needs no icon webfont — and why nothing an
|
|
administrator does to icon sets can blank it, which fetching at runtime could not promise:
|
|
resolution is gated on the set being enabled, and deleting a set drops every icon stored for it.
|
|
- A reference built at runtime — an icon a **user** picked, stored on a page or nav item — is
|
|
invisible to that scan and falls through to `iconify-icon`, resolving against `/_icons` as
|
|
before. A name assembled by concatenation is therefore a bug: make it a literal.
|
|
- `img:…` renders as an `<img>`. Legacy `las la-cog` / `mdi-check` webfont names are mapped onto
|
|
their Iconify equivalents for data written before the fonts were dropped; do not write new ones.
|
|
- Picking an icon calls `POST /_api/icons/materialize`, which is what guarantees the wiki can serve it
|
|
afterwards without the Iconify API.
|
|
|
|
### GraphQL is being removed
|
|
|
|
An earlier iteration of 3.x used GraphQL/Apollo. **All of it is deprecated** — there is no GraphQL
|
|
server left in `backend/`, and `APOLLO_CLIENT` is not defined as a global, so any call still going
|
|
through it throws. `blocks/block-index/` also still imports a `tree.graphql`.
|
|
|
|
Three files under `frontend/src/` make live `APOLLO_CLIENT` calls, and each needs a REST endpoint
|
|
that does not exist yet, so the feature behind it is currently broken:
|
|
|
|
| File | Feature |
|
|
| ---- | ------- |
|
|
| `components/AuthLoginPanel.vue` | self-registration (the `register()` call only — passkey login and 2FA are REST now) |
|
|
| `pages/AdminNavigation.vue`, `pages/AdminUtilities.vue` | assorted admin actions |
|
|
|
|
When touching such a file, port it to the REST API (`API_CLIENT` + the matching `backend/api/` route)
|
|
rather than extending the GraphQL code. If the REST endpoint doesn't exist yet, add it under
|
|
`backend/api/` following the schema + permissions conventions above — `sites/:siteId/images/:kind`,
|
|
which replaced the logo and favicon upload mutations in `AdminGeneral.vue`, is a recent example of
|
|
doing exactly that.
|