23 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 with JWT refresh, 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.
Blocks style themselves with :host / :host-context(body.body--dark) for dark mode 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).
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.
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.
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:pages', 'write:pages']]).manage:systembypasses every check.@fastify/swagger'stransformfolds these into the OpenAPI description automatically — so declaring them is also how they get documented. - 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 and JWT refresh. - 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.
Seven 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 |
passkey login, self-registration, TFA verify + setup |
components/ChangePwdDialog.vue, pages/ProfileAuth.vue, components/SetupTfaDialog.vue |
password / TFA self-service |
pages/AdminGeneral.vue, 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 — users/profile/editor-settings
is a recent example of doing exactly that.