# 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/.json?icons=a,b` and `/_icons//.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/*` ships `db` and `disk` — see [Storage targets](#storage-targets). - `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 ``, ``, …), 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-/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 ``, 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`. ### How far to go verifying a change Match the check to the size of the change. `npm run build`, `npx oxlint` and `npm run typecheck` are seconds each and are the right check for nearly everything. **Do not stand up a throwaway instance and drive a headless browser to look at a small change.** That means booting a backend against a scratch database, seeding it, and screenshotting through `/usr/bin/chromium` — a good ten minutes of setup that a moved border, a colour, a spacing tweak or a renamed label does not earn. Read the rule you wrote, trust the build, and say what you changed. It is worth the setup for a **new** piece of UI whose markup has to meet a stylesheet written elsewhere, where being wrong means shipping something visibly broken — a component reusing existing content classes is the case that has actually gone wrong. Also for a flow with real state to exercise (a login, an upload, a save), where a screenshot answers a question reading cannot. See the `wikijs-isolated-test-instance` memory for how to boot one when it IS warranted. ## 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 # 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 `