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).
Layout
Root
config.yml— instance config (copy ofconfig.sample.yml). Read by the backend at boot and byfrontend/vite.config.jsin dev mode to learn the proxy target port.assets/— build output of the frontend (vite buildwrites here), plus static assets underassets/_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 inbackend/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 byapi/index.tsunder the/_apiprefix.api/schemas/— shared JSON Schemas registered viaapp.addSchema()and referenced from route schemas as{ $ref: 'Site#' }. Register new shared schemas inapi/index.tsbefore the routes.
controllers/— non-API HTTP routes.site.tsserves per-site resources (logo, favicon, login background) under/_site;icons.tsserves icons under/_icons, implementing the part of the Iconify API protocol the frontend speaks (/_icons/<prefix>.json?icons=a,band/_icons/<prefix>/<name>.svg). Public and cached hard — see 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 bymodels/index.tsand exposed asWIKI.models.*. Business logic belongs here, not in route handlers.types.tsholds the sharedSystemIdspassed to each model'sinit()during first-run seeding.modules/— pluggable extensions, discovered from disk. Each module is a directory with adefinition.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 nostorage.tsexists 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 exportstask(). File name is kebab-case, the task key is its camelCase form.tasks/workers/— CPU-bound jobs run in a worker thread viaworker.ts, which boots a minimalWIKIglobal (config + logger + lazyensureDb()) 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 withconfig.ymland the dbsettingstable.helpers/— small pure utilities (common.ts,config.ts).types/— ambient declarations:global.d.ts(theWIKIglobal) andfastify.d.ts(session + route-permission augmentations).locales/—en.jsonsource strings (Localazy-managed) +metadata.jslanguage table (the one remaining JavaScript file; typed by its siblingmetadata.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 thekyclient, exposed as theAPI_CLIENTglobal),components.js(global components),eventbus.js(EVENT_BUSglobal, mitt),externals.js,i18n.js,iconify.js(points Iconify at this instance's/_icons),monaco.js,temporal.js(conditionally polyfillsTemporal, awaited before anything else inmain.js).src/router/—index.js(router factory) androutes.js(the full route table; page components are lazily imported).src/layouts/—MainLayout,AdminLayout,AuthLayout,ProfileLayout.src/pages/— route-level views.Admin*.vueare the admin area,Profile*.vuethe 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.jscreates the pinia instance and injectsrouterinto every store.src/renderers/— page content rendering pipeline:markdown.jsplusmodules/(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 bycss.preprocessorOptions.scss.additionalDatainvite.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/.
# 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
.tsfile importing a converted module writes./core/config.ts, not./core/config.jsand 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, nonamespace, no constructor parameter properties, noexperimentalDecorators. Use union types oras constobjects 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 thestorage.tspresence check inhasImplementation()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 disablinguseUnknownInCatchVariables. Strict mode types a caught error asunknown, and this codebase readserr.messageeverywhere; 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 typesreq.params,req.bodyandreq.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. SearchFIXME:underbackend/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/.
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, notlodash-es. Installed in bothbackend/andfrontend/. - Use the native
TemporalAPI, not luxon. See 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) overes-toolkit/compat. Two lodash helpers are compat-only and have direct equivalents:defaultsDeep(source, defaults)→toMerged(defaults, source)(note the argument order flips) andtoSafeInteger(x)→Number.parseInt(x, 10). - On the frontend
Temporalis a global, declared in.oxlintrc.json.src/boot/temporal.jsdynamically importstemporal-polyfillfor browsers without native support (Safari, as of mid-2026) and is awaited first inmain.js. The polyfill is a lazy chunk (~21 kB gzipped) that browsers with nativeTemporalnever 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, sopermissions: ['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 aNo route-level permissions:comment, asapi/pages.ts,api/assets.tsandapi/blocks.tsdo. - The two names are not interchangeable.
manage:pagesdoes not implywrite:pages: a rule grants the exact strings in itsroles. - On the frontend,
userStore.permissionsis the global list (fromusers/whoami) anduserStore.pagePermissionsis what the session holds AT THE CURRENT PATH (frompages/userPermissions, refreshed per route inApp.vue).userStore.can()ORs the two and treatsmanage:systemas a wildcard, so it answers "may do this somewhere". Gate a control over the page in front of the reader onpagePermissions— 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 (
reviewerForinapi/approvals.tsis 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
WIKIglobal. Set up inindex.ts, typed intypes/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 singlepreHandlerhook inindex.ts. The array is OR-ed; a nested array is AND-ed (permissions: ['read:sites', ['manage:users', 'manage:groups']]).manage:systembypasses every check.@fastify/swagger'stransformfolds these into the OpenAPI description automatically — so declaring them is also how they get documented. Only global permissions belong here; see Permissions for the other kind and how they are checked. - Every route needs a
schemawithsummary,tags, and response schemas.hideUntaggedis on, so an untagged route is invisible in the API docs. Reuse$refschemas fromapi/schemas/. - Errors via
@fastify/sensiblehelpers (reply.notFound(),reply.badRequest(),reply.unauthorized(),reply.forbidden()). ThesetErrorHandlerinindex.tsshapes/_api/failures into{ ok, error, statusCode, message }JSON. - Schema changes: edit
db/schema.ts, thennpm run db-generateand commit the generated migration. Never hand-edit an existing migration. - Dates use the native
TemporalAPI, not luxon (no longer a backend dependency).Temporalis a global in Node 26 and is typed by the TS 7 lib, so it needs no import. Four things to know:Temporal.Instantaccepts exact time units only —add({ days: 1 })throws. Since these are all UTC instants, use{ hours: 24 }.- Temporal types have no
valueOf, soa < bthrows. Compare withTemporal.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 aDate(what drizzle returns fortimestampcolumns),Temporal.Instant.from(str)for postgres-format strings (what rawdb.execute()returns), andnew 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
kyclient, reachable as theAPI_CLIENTglobal (declared in the oxlint config, so no import needed) — e.g.await API_CLIENT.get('sites').json(). It handles the/_apiprefix; authentication is the session cookie, sent with every request. - Cross-component messaging uses the
EVENT_BUSglobal (mitt). - State lives in Pinia option stores. For utilities and dates use
es-toolkitandTemporal— see Utilities and dates; thelodash-esandluxonstill 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.tsresolves a reference through four tiers — memory, disk (<dataPath>/cache/icons/<prefix>/<name>.json), theiconsdb 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 whenofflineis set.- Serving is
controllers/icons.tsunder/_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 aniconprop 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.mjsintosrc/assets/icons.generated.js(committed) and drawn as an inline<svg>. Runnpm run iconsafter adding or removing one;check-icons.mjsfails 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/_iconsas before. A name assembled by concatenation is therefore a bug: make it a literal. img:…renders as an<img>. Legacylas la-cog/mdi-checkwebfont names are mapped onto their Iconify equivalents for data written before the fonts were dropped; do not write new ones.
- Every Iconify reference written literally in this repo's source is inlined at build time by
- 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.