The rule only forbade comments narrating a change. CSS comments are now avoided outright: a rule reads through its selector and properties, and why a margin is negative or a selector excludes a class goes in the commit message. The exceptions stay: a browser workaround, a value that must match one in another file, a `z-index` in a stacking contract. The building-client-ui skill carries the rule in its styling summary, and the PR review's comment-style check flags any other added CSS comment rather than only `/* was Npx */`. Co-Authored-By: Claude Opus 5.5 <[email protected]>
38 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Overview
Trilium Notes is a hierarchical note-taking application with synchronization, scripting, and rich text editing. TypeScript monorepo using pnpm with multiple apps and shared packages.
Development Commands
npm install -g pnpm && pnpm install # setup (pnpm 12 is a native binary; corepack < 0.34.5 cannot start it)
pnpm server:start # dev server at http://localhost:8080
pnpm desktop:start # Electron dev app
pnpm standalone:start # standalone (in-browser) client
pnpm client:build | server:build | desktop:build
pnpm --filter <pkg> test <pattern> # e.g. pnpm --filter server test spec/etapi/search.spec.ts
pnpm --filter <pkg> coverage # per-package coverage (client, server, standalone)
pnpm typecheck # all projects, native compiler, a few seconds
pnpm dev:format-check | dev:format-fix # stylistic formatting
- Never run ESLint (
dev:linter-check,dev:linter-fix,npx eslint) — it dies with an out-of-memory error, so a run tells you nothing. CI does not lint either — there is noeslint/linter-checkstep anywhere in.github/workflows/, so lint findings never block a merge; the gates arepnpm typecheckand the per-packagetest --coverageruns. (packages/*is ignored by the ESLint config anyway.) - Never run
pnpm test:all,test:parallel,test:sequential, or a whole-packagecoverageduring development — CI runs them on every push. Run the narrowest suite that covers what you touched:pnpm --filter <pkg> test <pattern>(Vitest treats the trailing argument as a substring filter over spec paths). Core specs need two runs, server and standalone (see Testing). Only reach for a full suite if the user asks or wants a final check. - Typecheck with
pnpm typecheck, not a rawtsc— it resolves the project references a hand-writtentsc -p …gets wrong, and is far cheaper than a test suite. Still, run it once, after a piece of work is finished — not after every edit: it builds the project references, and repeating it is a common way to lose minutes across a session. Two ways its output misleads: the incremental build does not recheck the files that use an ambient.d.ts(types-lib.d.ts) after it changes — deleteapps/client/dist/*.tsbuildinfo(gitignored) before trusting the result; and a dependent project (tsconfig.spec.json,apps/build-docs) is checked only once the app project compiles, so its errors appear only after the app's are fixed, and some vanish with them. - Two TypeScript versions, on purpose: root
package.jsonhastypescript6.x (the JS compiler API that TypeDoc, typescript-eslint and the browser-bundled language service inpackages/codemirrorload) and@typescript/native(7, drivespnpm typecheckand owns thetscbin). Do not bumptypescriptto 7, dedupe them, or switch to the@typescript/typescript6shim (nolib.*.d.ts→ client build breaks). Reasoning:docs/Developer Guide/Developer Guide/Environment Setup.md, "TypeScript".
Git Workflow
- Committing directly on
mainis allowed and expected for small fixes and self-contained features — do not create a branch first for those. The default "branch before committing on the default branch" rule does not apply to this repository. - Large or risky work goes on a branch: multi-commit features, migrations, refactors spanning many packages, anything that needs review or a PR before landing.
- Only commit when explicitly asked to in that message, and the ask covers only the step it accompanies — a later step is left uncommitted until asked again. A remark like "commits go on main" is about branch choice, not standing permission to commit. Otherwise leave changes staged/unstaged for review.
- The issue-closing keyword goes in the commit/PR subject, not the body —
fix(markdown): wrap imported tables (closes #10270). A bugfix commit must carry it. - That keyword is the only way an issue is ever closed or annotated. Never post a comment on a GitHub issue, never
gh issue close, never change labels — the maintainer speaks to reporters, and the merge closes the issue. Reading issues (gh issue view) is fine; writing to them is not. git rmis not a neutral delete. It leaves the deletion staged, so a commit made before the matching reference updates are staged lands aHEADpointing at a module that no longer exists. When removing a file whose references are edited in the same pass, delete it from the filesystem instead, or land the deletion and the reference updates together — never leave the index in a state that is broken on its own.- Commit by explicit path —
git commit -F <message file> -- <paths>, not whatever the index holds. The user stages and unstages in this worktree too, so between two commands the index can gain their files or lose yours. Give each commit its own message file: aWriteonto an existing path that has not been read fails, and the commit then takes the old message. - Never run a tree-wide git operation — no
git stash,git checkout --,git restore. The user edits this worktree at the same time, so a stash takes their uncommitted work with it, and their IDE can flush a partial buffer onto the stashed state and block the pop. To decide whether a failure predates your change, argue statically instead — check whether the failing spec even imports the module you touched. If a real A/B is unavoidable, copy the file, edit it in place, and restore it, touching only files you wrote; prefergit worktree addon a temp dir over anything that reaches the whole tree.
Opening a PR
git push -u origin <branch> then gh pr create --base main --title … --body-file <file>. There is no PR template in .github/, so the body is yours to shape.
originisTriliumNext/Trilium— the working directory is namedNotesand ~19 contributor forks are configured as remotes, so neither the path nor the remote list names the target.ghresolves it fromremote.origin.gh-resolved, already set, so--repois redundant;upstreamis the archivedzadam/triliumand is never a base.- Write the body from the commit bodies, grouped by what the change does —
git log --format='=== %s%n%b' main..HEAD. A feature branch here carries a long, self-contained rationale per commit (the measurements, the rejected alternative, the reason a value is what it is), and a PR description that restates the subject lines throws all of it away. Group the commits under headings by area, not chronologically: a reviewer reads the reworked subsystem, not the order it was built in. - Pass the body via
--body-file, not--body. Markdown of this length in an argument hits shell quoting and heredoc-in-fishproblems; write it to the scratchpad first. - Cosmetic
edit-docschurn is ignored, not mentioned.pnpm edit-docs:edit-docs(andedit-docs:sync-docs) reformats wholeapps/server/src/assets/doc_notes/**/*.htmlfiles it merely opened (<li><p>x</p></li>↔<li>x</li>, shifted across a tag, attributes rewrapped), so a docs commit routinely touches pages the branch has nothing to do with. That is expected and needs neither a rebase nor a note in the description. Read the diff first, though: reflow is safe, but a changedhref, a dropped<img src>or a paragraph that lost its text is a real defect and must be fixed before the PR goes up.
Monorepo Structure
apps/
client/ # Preact frontend (+ legacy jQuery widgets); shared by server, desktop, standalone
server/ # Node.js backend (Express, better-sqlite3); serves the client, REST/WebSocket
desktop/ # Electron: server + client in one process (see developing-electron-desktop skill)
standalone/ # whole stack in the browser: core in a Web Worker on SQLite WASM + OPFS, no Node (see developing-standalone skill)
mobile/ # Capacitor shell around standalone (see developing-capacitor-mobile skill)
web-clipper/, website/, db-compare/, dump-db/, edit-docs/, build-docs/, icon-pack-builder/, script-deployer/
packages/
trilium-core/ # entities, services, SQL, sync, most API routes — shared by server, desktop, standalone
commons/ # types + utilities shared with the client
trilium-e2e/ # shared Playwright tests
ckeditor5/, codemirror/, highlightjs/, share-theme/, pdfjs-viewer/, splitjs/, turndown-plugin-gfm/
pnpm --filter <package> <command> runs a command in one package.
packages/trilium-core is shared by server, desktop and standalone — not by the client. apps/client has zero @triliumnext/core imports; it reaches the backend over REST/WebSocket and shares only types via @triliumnext/commons. So: a dependency added to core lands in server, desktop and standalone bundles (standalone's worker imports core at startup — that is the cost to weigh, never "the client would pay for it"), and frontend code can never call a core function — it needs an API route or a type in commons. The split is backend-vs-frontend, not Node-vs-browser: standalone runs core in a browser worker, which is why core carries the no-Node-built-ins rules below.
Core Architecture
Three-Layer Cache System
All data access goes through cache layers — never bypass with direct DB queries:
- Becca (
packages/trilium-core/src/becca/): Server-side entity cache. Access viabecca.notes[noteId]. - Froca (
apps/client/src/services/froca.ts): Client-side mirror synced via WebSocket. Access viafroca.getNote(). - Shaca (
apps/server/src/share/): Optimized cache for shared/published notes.
Critical: Always use cache methods, not direct DB writes. Cache methods create EntityChange records needed for synchronization.
Entity System
Core entities live in packages/trilium-core/src/becca/entities/ (not apps/server/):
BNote— Notes with content and metadataBBranch— Multi-parent tree relationships (cloning supported)BAttribute— Key-value metadata (labels and relations)BRevision— Version historyBOption— Application configurationBBlob— Binary content storage
Entities extend AbstractBeccaEntity<T> with built-in change tracking, hash generation, and date management.
Entity Change & Sync Protocol
Every entity modification creates an EntityChange record driving sync:
- Login with HMAC authentication (document secret + timestamp)
- Push changes → Pull changes → Push again (conflict resolution)
- Content hash verification with retry loop
Sync services: packages/trilium-core/src/services/sync.ts, syncMutexService, syncUpdateService.
Widget-Based UI
Frontend widgets in apps/client/src/widgets/:
BasicWidget/TypedBasicWidget— Base classes (jQuerythis.$widgetfor DOM)NoteContextAwareWidget— Responds to note changesRightPanelWidget— Sidebar widgets with position ordering- Type-specific widgets in
type_widgets/directory
Widget lifecycle: doRenderBody() for initial render, refreshWithNote() for note changes, entitiesReloadedEvent({loadResults}) for entity updates. Fluent builder pattern: .child(), .class(), .css() chaining with position-based ordering. These legacy widgets are jQuery; new UI is Preact under widgets/react/ — don't mix the two inside one component.
The client runs Preact 11: components, createPortal and types come from preact, hooks from preact/hooks, and preact/compat only for memo, lazy, Suspense, flushSync and useSyncExternalStore. Compat is nonetheless always loaded, so onChange on a text input fires on input and onFocus/onBlur bubble. A removed component's useEffect cleanup runs after paint, so one that must happen during the unmount is a useLayoutEffect cleanup. Ref typing, per-element ARIA types and the rest: the building-client-ui skill, "Preact 11 conventions"; for specs (unmount inside act(), which events to dispatch): writing-unit-tests.
Reusable Preact Components
Shared components live in apps/client/src/widgets/react/ — always reuse them (FormTextBox, FormSelect, Button, Badge, NoItems, Dropdown, Table, Calendar, …) instead of writing raw HTML elements or a custom implementation, and never put Bootstrap utility classes (form-control-sm, input-group, …) on them. Any control floating over a note's content (map, mind map, image, diagram) goes on OverlayControlGroup / OverlayToolbar — never a hand-rolled <button>. The full catalogue, when to use a Dropdown menu and when a DropdownPanel and the overlay-control contract are in the building-client-ui skill — load it before building client UI.
Component Styling
- Avoid inline styles — do not use the
styleattribute/prop on JSX elements unless absolutely necessary (e.g. a truly dynamic, computed value that cannot be expressed in CSS). Static layout, sizing, spacing, and visual properties must go in CSS. - Per-component CSS files: each component should have a matching
.cssfile (e.g.my_dialog.tsx→my_dialog.css), imported at the top of the component file. - CSS nesting for scoping: since CSS modules are not available, scope styles using a root class and native CSS nesting. For example, a dialog with
className="my-dialog"should have its styles nested under.modal.my-dialog { … }. - Reuse existing components instead of building custom markup — prefer
FormTextBox,FormTextBoxWithUnit,FormSelect,Slider,Button, etc. over hand-rolled<input>,<select>, or<button>elements. - Safe-area insets always read
var(--safe-area-inset-top, env(safe-area-inset-top))(and the-bottom/-left/-righttwins), never a bareenv(). Android's WebView leavesenv(safe-area-inset-*)at0, soapps/mobile'sMainActivityinjects the real values as those custom properties; theenv()fallback covers iOS and desktop browsers, where nothing is injected. Details in thedeveloping-capacitor-mobileskill.
API Architecture
- Internal API — REST, trusts the frontend. Most routes are core-shared and also run under WASM (
packages/trilium-core/src/routes/); Node-only ones live inapps/server/src/routes/api/. Load theadding-internal-api-routeskill for the wrapper/return conventions before adding one. - ETAPI (
apps/server/src/etapi/) — external API with token auth; keep it backwards compatible. - WebSocket (
packages/trilium-core/src/services/ws.ts) — real-time sync to the client (IPC-backed on desktop).
Platform Abstraction and core rules
packages/trilium-core/src/services/platform.ts defines PlatformProvider (crash(), getEnv(), isElectron/isMac/isWindows), implemented per app in apps/desktop, apps/server, apps/standalone; singleton via initPlatform()/getPlatform(). Because core also runs in standalone's browser worker:
- No
process.env—getPlatform().getEnv(key)(standalone maps URL params like?safeMode→TRILIUM_SAFE_MODE). - No Node built-ins in core, including
path— usepackages/trilium-core/src/services/utils/path.ts(extname()/basename()) and the platform providers. - Platform checks are functions —
isElectron(),isMac(),isWindows()fromutils/index.tscallgetPlatform()and only work afterinitializeCore(); in static definitions wrap them in a closure (value: () => isWindows() ? "0.9" : "1.0"). - Avoid the barrel in early-loading modules —
import { x } from "@triliumnext/core"loads every export;config.ts-like modules import subpaths (@triliumnext/core/src/services/utils/index) to dodge init-order cycles. - Binary conversions go through
packages/trilium-core/src/services/utils/binary.ts(wrapStringOrBuffer/unwrapStringOrBufferforstring | Uint8Array,encodeBase64/decodeBase64,encodeUtf8/decodeUtf8; also exported asbinary_utils), not hand-rolledTextEncoder/Buffer.from().
Electron Desktop App
apps/desktop runs server + client in one Electron process; the renderer loads over the trilium-app:// custom protocol and talks to main only through the preload bridge (window.electronApi, typed by packages/commons/src/lib/electron_api_interface.ts). The main process bundles to ESM with code splitting (dist/main.mjs + lazy chunks/, via buildBackend(..., { format: "esm" })); the preload and image_worker.cjs stay CJS. nodeIntegration is off, contextIsolation is on, @electron/remote is gone — never require("electron") in client code. Adding an API means interface + preload.ts + an ipcMain handler in the owning service + a spec. Load the developing-electron-desktop skill for the recipe, the security model, running/launch errors and testing.
Flathub packaging
apps/desktop/flatpak/ holds the whole recipe — manifest, trilium.sh, flathub.json, .desktop, metainfo — and scripts/flatpak/ writes it into the Flathub repo (update-repo.mts pins the ref and the pnpm sources, generate-sources.mts rebuilds the offline generated-sources.json); .github/workflows/release-flathub.yml runs both and opens the PR. Never hand-edit the packaging repo — the next run overwrites it. The manifest runs the checkout's own apps/desktop/electron-forge/{flip-fuses,trim-locales}.ts and chore:update-build-info --from-commit, each shared with the forge build, so a change there lands in both the .deb/.rpm/Forge flatpak and Flathub — keep the run-as-script guard and the argument the manifest passes. The sandboxed data dir is a review condition: trilium.sh reads $XDG_DATA_HOME/trilium-data and the Flathub reviewer struck every grant for the legacy ~/.local/share/trilium-data, so existing users' notes are unreachable by design until the migration is built — never "fix" that by adding a --filesystem= back. Load the packaging-for-flathub skill before touching any of it.
Standalone (in-browser) app
apps/standalone runs the client on the page and @triliumnext/core — plain JS — in a dedicated Web Worker over @sqlite.org/sqlite-wasm persisted in OPFS; a Web Lock elects the one tab that owns the database, which answers its own API calls straight from the worker (standaloneApi.localFetch); the service worker forwards other tabs' calls to it and covers what the page still requests. Every core provider has a browser twin in apps/standalone/src/lightweight/ — a new provider or Node import in core breaks this build first. Load the developing-standalone skill before touching sw.ts, main.ts, local-server-worker.ts, lightweight/* or vite.config.mts.
Mobile (Capacitor) app
apps/mobile wraps the standalone WASM build in a Capacitor WebView — no network backend. Its one tab always owns the worker, so the client answers most API calls in-page; what still leaves it (images, fonts, uploads) goes through the service worker on Android, at https://localhost, and through apps/standalone/src/ios-interceptors.ts on iOS, at capacitor://localhost, where no service worker can register. iosScheme: "https" is a no-op and must not be re-added, and the iOS interceptor path is not dead code. Load the developing-capacitor-mobile skill before touching apps/mobile, ios-interceptors.ts, capacitor_http_handler.ts or the capacitor: branches of sw.ts/main.ts.
PDF viewer
PDF notes render in Mozilla's PDF.js viewer plus Trilium's code from packages/pdfjs-viewer, which every app serves from the built dist — rebuild it (pnpm --filter @triliumnext/pdfjs-viewer build) after every viewer change, since no *:start script does; load the developing-pdf-viewer skill before touching the viewer, Pdf.tsx or the PDF sidebar panels.
Database
SQLite (better-sqlite3 on Node, @sqlite.org/sqlite-wasm on OPFS in standalone) behind packages/trilium-core/src/services/sql/ (DatabaseProvider, prepared-statement cache, transactions). Schema: packages/trilium-core/src/assets/schema.sql; migrations: integer-versioned entries in the descending MIGRATIONS array in packages/trilium-core/src/migrations/migrations.ts (inline SQL or a NNNN__description.ts module) — load the evolving-the-data-model skill before adding a column or migration.
Internationalization
- English is the only catalogue you edit; 40+ other locales come from Weblate. Three English catalogues, chosen by who loads the string:
apps/client/src/translations/en/translation.json(the app),en/entry.json(setup.*,login.*,set_password.*— the setup/login/password pages load only this ~11 KB file), andapps/server/src/assets/translations/en/server.json(server,trilium-core, the Electron main process and standalone's worker — it is the catalogue for every non-browser-UI runtime, so at()in core needs no fallback). Load theworking-with-translationsskill to find, add or audit keys without reading the 226 KB file. - Client:
import { t } from "../services/i18n"; everywhere else:import { t } from "i18next". Never hardcode user-facing text, including in Electron dialogs/tray/IPC. - The
encatalogues are US English —color,center,meter,recognize,labeled,canceled.en-GBis a locale of its own and carries only the strings whose British spelling differs, so when a new or edited string contains one of those words, add the British form there too (node .claude/skills/translating-locales/locale.mjs merge en-GB <catalog> <file>). Leaveen-GBalone when the spelling is the same in both. - Interpolate with
{{var}}. The client turns i18next's HTML-escaping off ininitLocale()— translated strings are rendered as text, where escaping reached the reader as/— so a string headed for a sink that takes HTML escapes its own values:Menutitles andhighlightedNotePathTitletake HTML, so their callers wrap interpolated values inescapeHtml()(t("table_view.hide-column", { title: escapeHtml(title) })), and the confirm/info dialogs sanitize a string message with DOMPurify (SanitizedHtml). The server catalogue still escapes, hence{{- var}}on its quote-carrying values. Interpolated components whose order can vary by language (links, note references) use<Trans>fromreact-i18next, nott(). - Multi-paragraph text is one message with
\n\nbetween paragraphs, never a key per paragraph, so the translation decides where paragraphs fall. Render it with.pre-wrap-textor split it on blank lines into<p>— see theworking-with-translationsskill. - Third-party components (mind-map context menu, …) still go through
t()with their strings under a dedicated namespace (e.g."mind-map"). - Text editor (
packages/ckeditor5): plugins calleditor.t("English text")— the English text is the message id, the entry lives undertext-editor.ckkeyed by its slug, andapps/client/src/services/i18n.spec.tsfails on a missing or stale one. Rules (name itt, literal argument, don't shadow upstream strings,MESSAGE_OVERRIDES,renderShortcut):ckeditor5-plugin-developmentskill. - New locale:
docs/Developer Guide/Developer Guide/Concepts/Internationalisation Translations/Adding a new locale.md.
Attribute Inheritance
Three inheritance mechanisms:
- Standard:
note.getInheritableAttributes()walks parent tree - Child prefix:
child:labelon parent copies to children - Template relation:
#template=noteNoteIdincludes template's inheritable attributes
Use note.getOwnedAttribute() for direct, note.getAttribute() for inherited.
Client-Side API Restrictions
- Do not use
crypto.randomUUID()or other Web Crypto APIs that require secure contexts - Trilium can run over HTTP, not just HTTPS - Use
randomString()fromapps/client/src/services/utils.tsfor generating IDs instead
Shared Types Policy
- Types shared between client and server belong in
@triliumnext/commons(packages/commons/src/lib/) - Import shared types directly from
@triliumnext/commons- do not re-export them from app-specific modules - Keep app-specific types (e.g.,
LlmProviderfor server,StreamCallbacksfor client) in their respective apps
Important Patterns
- Protected notes: Check
note.isContentAvailable()before accessing content; usenote.getTitleOrProtected()for safe title access - Long operations: Use
TaskContextfor progress reporting via WebSocket - Event system (
packages/trilium-core/src/services/events.ts): Events emitted in order (notes → branches → attributes) during load for referential integrity - Backend lazy loading: a heavy or rarely-used dependency in server/desktop code loads via dynamic
import()inside its (async) call site, so the ESM split build puts it in a lazy chunk instead of the startup path. When the target is a CommonJS package, read it through the interop —const mod = await import("pkg"); const { x } = mod.default ?? mod;— a plain destructure of the namespace silently yieldsundefinedin split ESM output, and unit tests mock past it. Measure and verify with theanalyzing-backend-bundleskill (check-dynamic-imports.mjscatches the interop break) - Search: Expression-based, scoring happens in-memory — cannot add SQL-level LIMIT/OFFSET without losing scoring
- Widget cleanup: Unsubscribe from events in
cleanup()/doDestroy()to prevent memory leaks
Code Style
- 4-space indent, semicolons, double quotes, max line 100 (the format config enforces these). Unix line endings come from Git —
.gitattributespluscore.autocrlfnormalize to LF on commit, so the format config does not check them. Imports sorted pereslint-plugin-simple-import-sort(packages before relative, alphabetical within a group) — only ESLint checks that and it isn't run locally, so sort by hand. - Never use the non-null assertion
!, tests included. Narrow instead:?.,?? fallback, an explicit check, or an*OrThrowaccessor (becca.getNoteOrThrow(id)). - Never use
Array.prototype.forEach— write afor...ofloop instead, and iteratearray.entries()when the index is needed (for (const [index, item] of arr.entries())). It reads better and allowsbreak/continue/await. - Helpers go below the primary export they support (or in another module), never between the imports and the main definition — the entry point reads first.
- No ~10-SLOC modules. A component, hook or helper of about ten lines of substance joins an existing module that owns the same concept (e.g.
OverlayFullscreenButtonlives inOverlayControlGroup.tsx, its tests in that spec); a file of its own costs a module boundary and an import per call site and buys nothing. Split out once it has grown. - Comments — Google developer-documentation style: plain English, present tense, active voice, real identifiers (
froca.getNote(), not "the cache lookup"); can/might/must used precisely, never may. Say what the code does or why it is shaped so, not what changed. Keep it to a line or two — the reproduction, the measurements and the before/after belong in the commit body, which is the place to be thorough. A comment narrating a defect ("at 375px those ran 28px past the card") ages the moment the layout moves, and the next reader needs only the constraint that still binds, not the investigation.- This rule overrides the file you are editing. Most comments in the repository predate it and use a literary register — personified subjects, em-dash asides, inverted clauses. Do not match them; they are history, not house style. Code has no voice: a card does not state, a row does not say, a request does not stand, a tool does not hand over.
- Name the identifier instead of alluding to it. Write
// Erasing changes the note and attachment counts, so `DatabaseInfo` reads them again.— not// Erasing frees pages inside the file rather than shrinking it, so what the card above has to re-read is what the database holds. - Never comment an absence. A removal — a prop, a flag, a CSS rule, a branch, a whole widget — is explained by the commit that makes it, not by a note left where the code was: at the call site it defends something the reader cannot see. The same goes for a removal's mirror image: don't annotate a default that was left alone, or an option deliberately not passed. If an absence needs defending in the source, name the code better instead.
- Avoid CSS comments. A rule is read through its selector and properties; a comment explaining why a margin is negative or a selector excludes a class belongs in the commit message. Comment only what no reader can recover from the stylesheet: a browser workaround, a value that must match one in another file, a
z-indexin a stacking contract. Never narrate a change (/* was 8px */,/* moved from the toolbar */).
Testing
- Server tests (
apps/server/spec/): Vitest, must run sequentially (shared DB), forks pool, max 6 workers - Client tests (
apps/client/src/): Vitest with happy-dom environment, can run in parallel - Core tests (
packages/trilium-core/src/**/*.spec.ts):trilium-corehas no runner of its own — the server and standalone suites both include its specs (apps/server/vite.config.mts,apps/standalone/vite.config.mts) and run them against different platform providers (node + better-sqlite3 vs. happy-dom + sqlite-wasm). Green underpnpm --filter server testis not proof; runpnpm --filter standalone testas well. See thewriting-unit-testsskill for the cross-runtime traps - E2E tests (
packages/trilium-e2e/): Shared Playwright tests, run viapnpm --filter server e2eorpnpm --filter standalone e2e - ETAPI tests (
apps/server/spec/etapi/): External API contract tests - Browser-mode tests (
packages/ckeditor5) drive a real headless Chromium via@vitest/browser-playwright(pnpm exec playwright install chromiumonce); where that browser cannot run (NixOS), pointCHROME_BINat a system one — never add a local override config. See theckeditor5-testingskill - Build validation tests check artifact integrity
- Write concise tests: Group related assertions together in a single test case rather than creating many one-shot tests
- Extract and test business logic: When adding pure business logic (e.g., data transformations, migrations, validations), extract it as a separate function and always write unit tests for it
- See the spec fail before it passes: a spec written against already-correct code can pass for the wrong reason. Prefer writing the spec before the fix — run it red, then fix, then run it green; this needs no edit-revert-restore dance and leaves no window where the tree holds a hand-broken file. When the fix had to come first (the cause was only clear after reading the code, or the fix created the seam the spec attaches to), revert it with a targeted in-file edit — never
git stashorgit checkout --— run the spec, then restore. Either way, report both runs and check every assertion in the red run: one that still passes is covering something else or is vacuous, and needs tightening (look the element up, assert it exists, then act on it) - Don't drive the running app to verify a UI change — the user exercises the UI and reports back, so a headless-browser walkthrough duplicates their work and adds no information. Ship once the relevant unit tests and
pnpm typecheckare green, and state plainly what is and isn't verified. Booting an instance is for what the user cannot answer from the UI — which CSS rule actually won, a stacking or placement bug, a fixture-only repro: seereferences/inspecting-the-running-app.mdin thebuilding-client-uiskill
Documentation
- Script API reference — Generated by
apps/build-docs(TypeDoc) into the gitignoredsite/script-api/{backend,frontend,electron}and published to docs.triliumnotes.org. Not committed; never hand-edit — it's regenerated from the script API type definitions docs/User Guide/— Markdown pages plus!!!meta.json; the in-app help underapps/server/src/assets/doc_notes/andapps/standalone/src/assets/help_meta.jsonare generated from it and never hand-edited. Edit the Markdown by hand or in the editor (pnpm edit-docs:edit-docs), then runpnpm edit-docs:sync-docsso the HTML and both help metas follow. A page's shape (a new page, an image, a rename, a move) is meta work — thewriting-documentationskill'sdocs.mjsdoes it (new,image,rename,move,delete) and audits the trees (check); load the skill before touchingdocs/docs/Developer Guide/anddocs/Release Notes/— Safe for direct Markdown editing (also normalized bysync-docs)
A user-facing change is documented in the same change
Any change to what the user sees or does — a feature, a button moved or removed, a keyboard shortcut, a
label, an option, a default, where a feature is configured — is not finished until the User Guide
describes the new state, in the same commit or PR as the code. The docs are a deliverable, not a
follow-up. Before reporting the work done: node .claude/skills/writing-documentation/docs.mjs impact --diff (plus impact "<feature name>" for names the diff cannot know) lists the pages that mention the
changed UI strings and the help pages wired from the touched components; read every hit and edit what
describes an affordance that changed; a feature no page covers gets a page (docs.mjs new). Then
docs.mjs sync and docs.mjs check. Release notes are the maintainer's at release time — not on a branch.
Two traps this catches:
- The doc named the wrong place to begin with. Verify where a control actually mounts by grepping for
the component, not by inferring it from an i18n namespace or from another doc page —
NoteBadgesis keyedbreadcrumb_badges.*and renders in the title row. - A capability gate has a blast radius. Widening one (a note type added to a list a widget switches on) can surface panels, bars and menu entries the change never mentioned. Grep the gate's other readers and say what else now appears.
Recipes
Storing User Preferences
No localStorage — preferences are synced options. To add one: (1) type in OptionDefinitions, packages/commons/src/lib/options_interface.ts; (2) default in defaultOptions, packages/trilium-core/src/services/options_init.ts; (3) whitelist it in ALLOWED_OPTIONS, packages/trilium-core/src/routes/api/options.ts — otherwise the API rejects writes with "Option 'X' is not allowed to be changed"; (4) a control in the matching settings pane (apps/client/src/widgets/type_widgets/options/*.tsx) plus its English key; (5) read/write via useTriliumOption / useTriliumOptionBool / useTriliumOptionInt / useTriliumOptionJson. Details: docs/Developer Guide/Developer Guide/Concepts/Options/Creating a new option.md.
Adding Hidden System Notes
The _hidden subtree holds system notes with deterministic _-prefixed IDs so every sync instance builds the same tree; checkHiddenSubtree() creates them at startup. Add the HiddenSubtreeItem (from @triliumnext/commons) to buildHiddenSubtreeDefinition() in packages/trilium-core/src/services/hidden_subtree.ts — id (starts with _), title (key under "hidden-subtree" in server.json), type, icon (bx-name, no bx prefix), attributes, children, content; enforceAttributes / enforceBranches / enforceDeleted: true keep attributes, placement and removals in sync. Launcher-bar entries: hidden_subtree_launcherbar.ts; templates: hidden_subtree_templates.ts.
Writing to Notes from Server Services
note.setContent()requires a CLS (Continuation Local Storage) context — wrap calls incls.init(() => { ... })(frompackages/trilium-core/src/services/context.ts)- Operations called from Express routes already have CLS context; standalone services (schedulers, Electron IPC handlers) do not
Adding New LLM Tools
Tools are defineTools({...}) registries in packages/trilium-core/src/services/llm/tools/ (note_tools, attribute_tools, attachment_tools, hierarchy_tools, …), served to both the LLM chat and the MCP server. Each tool: description, Zod inputSchema, execute, mutates: true for writes. A new module goes into allToolRegistries in tools/index.ts; Node-only tools (in-app docs) live in apps/server/src/services/llm/tools/ and register via registerToolRegistryLoader() (a lazy dynamic-import loader — see registerServerLlmExtensions in apps/server/src/services/llm/index.ts) so the tool stack stays out of the startup path. Add the client label under llm.tools.<tool_name> in imperative tense ("Search notes"). Mirror ETAPI's field choices but don't import ETAPI mappers — inline them so the LLM layer stays decoupled.
Server-Side Static Assets
Node-side assets (templates, translations, prompts) live in apps/server/src/assets/ and are read via RESOURCE_DIR from apps/server/src/services/resource_dir.ts (path.join(RESOURCE_DIR, "llm", "prompts", …)); assets core itself reads (schema.sql, LLM skills) live in packages/trilium-core/src/assets/. Never resolve paths with import.meta.url/fileURLToPath or __dirname + relative path — the server bundles to dist/main.mjs plus lazy chunks/, so both point at the bundle (or at whichever chunk the module landed in), not the source tree.
MCP Server
Trilium exposes an MCP server at http://localhost:8080/mcp (.mcp.json) — only while pnpm server:start is running, and only with an ETAPI token exported as TRILIUM_ETAPI_TOKEN before starting Claude Code (otherwise 401; create one in Options → ETAPI). Use it to read/search/modify real note data when developing note-related features.