16 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.
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 / Quasar SPA |
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.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/.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/
Quasar app on plain Vite (not Quasar CLI). src/main.js wires it up manually: router → pinia store
→ boot/* initializers → Quasar plugins → mount.
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,monaco.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/— SCSS._theme.scssholds the Quasar sass variables (wired invite.config.js).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, /_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 Quasar
theme colors via CSS custom properties (var(--q-primary)).
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.
Three 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')
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 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.
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. - Prefer es-toolkit over lodash on the backend.
- Dates use the native
TemporalAPI, not luxon (which is no longer a backend dependency —frontend/still uses it).Temporalis a global in Node 26 and is typed by the TS 7 lib, so it needs no import. Three 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
- Vue 3 with pug templates (
<template lang="pug">) in most components — check the file you're editing rather than assuming HTML. Quasar components are auto-imported in kebab-case (q-btn,q-dialog). - 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;
lodash-esis the utility library here.
GraphQL is being removed
An earlier iteration of 3.x used GraphQL/Apollo. 59 files under frontend/src/ still reference
APOLLO_CLIENT (mostly in commented-out queries), and blocks/block-index/ still imports a
tree.graphql. All of it is deprecated — there is no GraphQL server left in backend/, and
APOLLO_CLIENT is no longer defined as a global.
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.