# 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 + mailpit via docker-compose). Mailpit is the mail server for development: it accepts everything and delivers nothing, so a confirmation link or a password reset lands in a web inbox at `http://localhost:8025` rather than a real mailbox. Point the wiki at it under **Admin → Mail** — host `localhost`, port 1025, TLS off, no credentials. - 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). Started with **`--no-experimental-webstorage`** — by the npm scripts and by the production image's `CMD`, which is the only reason a bare `node backend` still opens with an experimental warning about `localStorage`. Nothing here uses Web Storage; `lib0`, under yjs, probes for it as it loads the way a library that runs in a browser too has to, and Node 26 answers that probe with a warning instead of a value unless `--localstorage-file` is given. Off, the global is absent and the probe takes its node path in silence. - `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). `blocks.ts` serves compiled blocks under `/_blocks`, from the built tree or from an imported package depending on the block and the site — see [Distributing a block](#distributing-a-block). `rootFiles.ts` is the exception that registers at the root rather than under a prefix: `robots.txt` and `sitemap.xml`, the two names in `RESERVED_ROOT_FILES` a crawler asks for by convention, both driven by the site's **General → SEO** settings. The sitemap lists what the GUESTS group may read and nothing else. - `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). `modules/analytics/*` is the odd one out: a pair of YAML files and no implementation at all — see [Analytics](#analytics). - `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`), plus two that are not: `storageFiles.ts`, the file-tree half of the storage modules that address content by path (see [Storage targets](#storage-targets)), and `appShell.ts`, which describes a page in the HTML document served for it (see [The app shell and SEO](#the-app-shell-and-seo)). - `types/` — ambient declarations: `global.d.ts` (the `WIKI` global) and `fastify.d.ts` (session + route-permission augmentations). - `locales/` — `en.json` source strings (CrowdIn-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 under `/_blocks/` — alongside the blocks a site has imported as packages, see [Distributing a block](#distributing-a-block). 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). **Except where the block's content IS the thing being styled**, which is `block-tab` and `block-steps`: both leave what they hold in the light DOM, because it is page content and has to be drawn by the article's own stylesheet. A shadow root cannot reach into it either — `::slotted()` matches the slotted element and nothing below it, so a slotted `
    `'s `
  1. ` items are already out of range — so their appearance lives in `frontend/src/css/_page-contents.scss` with the rest of the content typography, and the component itself is a plain `HTMLElement` carrying the definition. Don't copy that shape for a block that draws its own furniture; a shadow root is still the default. **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. ### Distributing a block A block need not ship with the wiki. `npm run package -- block-xyz` compiles one block on its own and writes `blocks/packages/block-xyz.wkblock`, a single file an administrator uploads under **Admin → Content Blocks → Install Block...**. So the whole of writing one is: clone this repository, add a directory under `blocks/`, package it, upload it — nothing of the instance is rebuilt or restarted, and the author never touches the wiki they are writing for. **A packaged block is the same thing as a built-in one, arriving by a different road.** Same `component.js`, same `static definition`, same rollup build; what differs is where its files end up and where its definition is read from. So a block being written is developed in a full checkout — `npm run build` and the compiled tree — and packaged once it works. There is no second authoring API. `blocks/package.mjs` is the packager and `backend/helpers/wkblock.ts` reads what it writes. **The format is stated in full in both files and has to be kept in step by hand**: `blocks/` and `backend/` are separately installed workspaces and the backend does not type-check JavaScript, so there is no module the two halves could share. - **One block per package, and the directory name is the identity.** `blocks/block-xyz/` declares `block: 'xyz'`, is packaged as `block-xyz.wkblock`, serves as `block-xyz.js` and renders as ``. The packager refuses a mismatch, because every one of those names is derived from the same key. A **child block** (`isChild`) is refused outright: it is part of whatever holds it, has no row and nothing to switch on, so a package of one would install nothing. - **Everything is namespaced under the block's own name**, which is what lets an imported block and a built-in one be served from the same `/_blocks/` without either standing on the other. A package holds `block-xyz.js`, optionally `block-xyz.worker.js`, and `block-xyz/**` — the assets from its `assets.json` and, unlike the full build, its shared chunks, which `buildConfig({ only })` names into that directory rather than leaving at the root. Both the packager and the importer check it. - **The package is the only copy.** It is stored verbatim on the block's row (`packageData`) with its definition and a `checksum`. Nothing is written to `blocks/` on the server, which is a build output and in a container is part of the image. - **Re-importing the same key is an upgrade, not a second block.** The row is updated, so what the site had switched on and configured on it survives; the reply says `isNew: false`. A key a built-in block already uses is refused with 409 rather than shadowing it. - **`manage:sites` is what it takes**, the same permission the screen already needs — and deliberately not something stricter. A block is code that runs in every reader's browser on that site, which is exactly what the raw head and body fields under **Admin → Theme** already are. It is not a new kind of power, it is a tidier way to exercise one the admin area already grants. - **The container is parsed as something a stranger uploaded**, because the trust boundary above is about the CODE, not about the file: every length is bounded before it is acted on, every file's SHA-256 is checked, and every path has to fall inside the block's namespace. **The files are served from `/_blocks/` like a built-in's, and `controllers/blocks.ts` is what decides which.** That route replaced the `@fastify/static` registration for `/_blocks/`, because a static plugin claims the whole prefix and leaves nothing to ask the question in front of it; the plugin is still registered with `serve: false`, for `reply.sendFile`. - **The first path segment names the block, and that is the whole decision** — which is the reason the namespace above is enforced. - **It depends on which SITE was asked**, since a custom block belongs to one, and two sites on an instance may each have imported a different block under the same key. The frontend has no site in hand when it loads a block (it reads a tag out of the page and asks for it), so the hostname resolves it, the same `WIKI.sitesMappings` lookup the request hooks do. - **`/cache/blocks//block-/` is a cache, not storage.** `servingPathFor` unpacks the stored package into it on the first request and `block-.checksum` beside it says which version is there — written last, so an unpack that died halfway is done again rather than half served. Which also means an upgrade reaches every instance of an HA set on its own, including one that was not running when the upload happened, and a fresh container needs nothing restored. - **Custom files are revalidated (`no-cache` + ETag), built-ins are held for an hour.** The names are the same across versions of a custom block, and the point of uploading a fixed one is that the fix is live. **A custom block's definition is read from its row wherever a built-in's is read from the manifest** — its props in `getSiteBlocks`, and its tag and attributes in the sanitiser's allow list (`getEnabledForRender`, which fetches both in the one query `postProcess` was already making, for the same reason that query is not cached: a definition this misses is a block stripped out of somebody's page). Everything else about it is identical, the enable toggle included: a custom block that is switched off is stripped from a page being saved exactly as a built-in one is. ## 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/ npm run package -- block-x # compile one block → blocks/packages/block-x.wkblock ``` `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. ### Booting a throwaway instance For the cases above, and never against a running dev instance: that database is somebody's own work, and its admin account may well have 2FA on, which cannot be scripted. **A database of its own, not a schema of its own.** Copy `config.yml` to `config.test.yml` with `port: 3010`, `db.db: wikitest` and `dataPath: ./data-test` — and leave `schema: wiki` alone. A second *schema* in the same database fails on the first migration: `CREATE TYPE "treeType"` in `db/migrations/20260809235619_init` is not schema-qualified, and neither is the column that references it, so the type is created in one search path and looked for in another (`42704 typenameType`). There is no `psql` in the dev container, so create the database with `pg` out of `backend/node_modules`, connecting with the credentials already in `config.yml`. Then `CONFIG_FILE=config.test.yml node --no-experimental-webstorage backend` **from the repo root**. `CONFIG_FILE` is resolved against `WIKI.ROOTPATH` (`core/config.ts`), so it is a path relative to the root and not to `backend/`. It seeds itself and takes ~25s to reach listening. **Puppeteer is not installed in any workspace, and must not be added to one for a screenshot.** Install `puppeteer-core` into a scratch directory instead and drive the browser already on the box: `executablePath: '/usr/bin/chromium'`, `args: ['--no-sandbox']`. It pulls ~25 packages and downloads no browser of its own. **Scripting the API rather than the browser**, which is the quicker way to get a page and a history in place. Three things about it are not guessable: - **The site ID comes from `GET /_api/bootstrap`**, which is `publicAccess: true` and answers with the site, the flags and the session — it is what the SPA itself calls on boot. Not from `GET /_api/sites`: that needs `read:sites` or `access:admin`, so logged out it answers 401, and the site ID is what logging in requires. - **Login is `PUT /_api/sites/:siteId/auth/login`** (not POST) with `{strategyId, username, password}`. The strategy is the built-in local one, whose ID is fixed as `systemIds.localAuthId` in `base.yml`. A fresh instance answers `nextAction: changePassword` with a `continuationToken` for the seeded `admin@example.com` / `12345678`; feed that to `PUT .../auth/changePassword`, which needs **`strategyId` as well as** `continuationToken` and `newPassword`. The session cookie is good after that. - **The auth endpoints are rate limited, and successes are counted too.** `limitAuthAttempts` (`helpers/rateLimit.ts`) guards login, 2FA, this password change, passkeys and page unlock with one counter per client address — ten attempts per five minutes, then a fifteen minute ban. A re-runnable script that tries the seeded password before the one it changed it to therefore burns a guaranteed failure per run and eventually locks itself out. Try the changed password FIRST, and clear a ban with `DELETE FROM wiki."rateLimits"` rather than waiting it out. **Two things block a fresh install's first screenshot.** The seeded admin is forced through a change-password form on first login — fill both `input[autocomplete="new-password"]`, the current password field being `v-if`'d away whenever a continuation token is in hand. And the site root raises the **Welcome overlay** over the header while there is no home page, so navigate to any other path to get at the real one. **Tearing down** is killing your own PID — a dev instance shows up as `node backend` too, so match on start time or the `CONFIG_FILE` in `/proc//environ` rather than on the name — then `DROP DATABASE wikitest` and deleting `config.test.yml` and `data-test/`. Neither is gitignored: `.gitignore` names `/config.yml` and `/data` as exact paths, so a copy under any other name is tracked and will turn up in the next commit. ## TypeScript (backend) The backend is entirely **TypeScript 7** (the native Go compiler — `tsc` is a platform binary, not a JS bundle). **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 `