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

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 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.
  • 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 with JWT refresh, 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.

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 .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.tspath.join(WIKI.SERVERPATH, 'worker.ts') (the poolifier pool entry)
  • worker.tsimport('./tasks/workers/${kebabCase(job.task)}.ts')
  • models/authentication.tsimport('../modules/authentication/${stg.module}/authentication.ts')
  • models/storage.tsimport('../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/.

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, not lodash-es. Installed in both backend/ and frontend/.
  • Use the native Temporal API, 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) 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.

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:pages', 'write:pages']]). 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.
  • 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 onlyadd({ 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 and JWT refresh.
  • 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; 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.

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.