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.
wiki/CLAUDE.md

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.