svelte-check is clean, so it gates like the rest. eslint still reports without failing, now for two findings rather than 87: both are the unsanitized book description, written up in TODO.md.
11 KiB
Chitai frontend
SvelteKit web app for the eBook library. See the repo-root AGENTS.md for the overall picture and
dev-environment setup.
Stack: SvelteKit 2 with adapter-node · Svelte 5 (runes) · Tailwind v4 · Zod v4 ·
vendored foliate-js (EPUB) · vendored pdf.js (PDF) · mode-watcher (dark mode) ·
svelte-sonner (toasts) · pnpm.
Two experimental flags are on in svelte.config.js and the codebase depends on both:
kit.experimental.remoteFunctions and compilerOptions.experimental.async (await in components).
Tailwind v4 has no config file — the theme, colour tokens (hex, not oklch) and
@custom-variant dark all live in src/app.css. dark is the only custom variant defined, so
generated components that assume others — shadcn's slider ships data-horizontal: / data-vertical:
classes — silently produce no styles. Use the data-[orientation=…] form instead.
Talking to the backend
There are two mechanisms; pick deliberately.
1. Remote functions — the default. src/lib/api/*.remote.ts export query / command / form
functions from $app/server. Each takes a Zod schema from $lib/schema as its validator and runs
on the server, reaching the API through locals.api (the ApiClient in src/lib/server/api.ts):
export const getBook = query(stringCoerce, async (id): Promise<Book> => {
const { locals } = getRequestEvent();
const response = await locals.api.get(`/books/${id}`);
if (!response.ok) error(response.status === 404 ? 404 : 500, '…');
return await response.json();
});
Conventions: build query strings with createQueryParams from $lib/utils; on a failed response
throw SvelteKit's error(status, message); multipart uploads go through postMultipart /
putMultipart. Re-export new modules from src/lib/api/index.ts.
2. The catch-all proxy at src/routes/api/[...path]/+server.ts forwards GET/POST/PATCH/DELETE
to the backend with the auth header attached. Use it only where the browser itself must fetch
the backend — e.g. streaming a book file into the EPUB/PDF reader. It is not the general-purpose
path.
Auth
src/hooks.server.ts is a sequence of two handles: the first reads the authToken cookie,
constructs an ApiClient, validates it with GET /access/me and fills locals.user /
locals.authToken / locals.api (clearing the cookie if invalid); the second redirects any route
outside /login to the login page when there is no user.
The cookie is set in src/lib/api/auth.remote.ts (login) — httpOnly, secure, sameSite strict, one
week — and deleted by logout. The backend JWT never reaches client-side JS.
Schemas
src/lib/schema/*.ts— hand-written Zod schemas, used as remote-function input validators and as the source of the exported TS types. Mirrorbackend/src/chitai/schemas/when the API changes.src/lib/schema/common.ts— shared building blocks:stringCoerce/arrayCoercecoercion helpers,PaginatedResponse<T>, and the pagination / search / order query schemas that most list endpoints compose from.src/lib/schema/openapi/schema.d.ts— generated from the backend's OpenAPI document withopenapi-typescript. Never hand-edit; regenerate after backend API changes.
State
Client state lives in classes in src/lib/state/*.svelte.ts using $state / $derived, shared via
Svelte context with a module-level Symbol key and a setXState / getXState pair:
const LIBRARY_KEY = Symbol('LIBRARY');
export function setLibraryState(libraries: Library[]) {
return setContext(LIBRARY_KEY, new LibraryState(libraries));
}
export function getLibraryState() {
return getContext<ReturnType<typeof setLibraryState>>(LIBRARY_KEY);
}
Follow that pattern rather than introducing stores. library.svelte.ts is the reference — including
its optimistic-delete-with-rollback and toast handling. bookCollection / bookSelection /
bookOperations split list data, selection and mutations across three cooperating classes.
Components
src/lib/components/ui/— vendored shadcn-svelte (components.json) plus jsrepo blocks from@ieedan/shadcn-svelte-extras(jsrepo.json). Treat as generated: add components with the CLIs rather than hand-writing them, and prefer wrapping over editing.- App components live in
forms/,layout/,view/(browser, grid/list/table, filters, sort) andreader/(see The readers). cn()from$lib/utilsmerges Tailwind classes; theWithElementRef/WithoutChildhelpers there are the shadcn prop-typing conventions.
The readers
PDF is the pdf.js viewer vendored under static/pdfjs/, pointed at by an iframe. Untouched by
the EPUB work; leave it alone unless the task is about PDFs.
EPUB is built on foliate-js, copied verbatim into src/lib/vendor/foliate-js/ by
scripts/vendor-foliate.sh (pinned commit; see src/lib/vendor/foliate-js/README.chitai.md).
Upstream has no npm release and recommends a submodule; this repo has none and already vendors
pdf.js the same way, so it is copied instead. Only the import closure reachable from view.js is
vendored, and pdf.js in that directory is our stub, not upstream's — the real one imports a
bare @pdfjs/pdf.min.mjs that Rollup resolves at build time even though the path never runs.
Layout:
| Path | What |
|---|---|
lib/vendor/foliate-js/ |
The engine. Do not edit — vendor-foliate.sh overwrites it. |
lib/reader/foliate.ts |
Lazy loader for the custom elements. The only thing that imports $foliate. |
lib/reader/settings.ts · stylesheet.ts |
Defaults/bounds, and the CSS injected into the book. |
lib/reader/progress.ts |
Debounced progress writer with a sendBeacon flush. |
lib/state/reader-settings.svelte.ts |
Settings state, persisted to localStorage. |
components/reader/foliate-view.svelte |
Wraps <foliate-view>; owns the imperative lifecycle. |
components/reader/epub-reader.svelte |
The shell: chrome, TOC, errors, progress. |
Things that will bite:
$foliateis a Vite-only alias. It is deliberately absent fromkit.aliasand tsconfigpathsso TypeScript cannot resolve it and falls back to the ambient declaration inlib/reader/foliate-js.d.ts;src/lib/vendoris also in tsconfigexclude. Without both,checkJswalks ~11k lines of untyped JS. The declaration file must not be namedfoliate.d.ts— besidefoliate.ts, TypeScript takes it for that file's emitted declaration and drops it.- Never import the vendored code at module scope.
view.jscallscustomElements.defineand subclassesHTMLElementon import, so it must stay behindloadFoliate()insideonMount. SSR is otherwise on for the reader route. - Sections render in iframes, which swallow key events. Keyboard handlers are bound per section
document on the
loadevent, and modifier combinations are replayed onto the host window so app shortcuts (the sidebar's ctrl+B) still work while reading. - Renderer settings split two ways. Flow, gap, margins, column count and line width are
attributes set with
setAttribute(there is no JS property API, nomarginshorthand and nospread— a spread ismax-column-count: 2). Typography is CSS passed torenderer.setStyles, which takes a[before, after]pair: the first is prepended to the section head so the book overrides it, the second appended so it wins. User settings belong in the second, with!important, or the book's own CSS beats them. - Progress needs no locations pre-pass.
relocatecarries both a CFI and an overallfraction, which map straight ontoepub_cfiandpercentage.
Routing
Route groups carry the layout structure:
(root)— the authenticated shell (sidebar, header);+layout.server.tsloads the libraries.(root)/(library)— library-scoped pages:library/[libraryId]/view,book/[bookId], edit, and the readers atbook/[bookId]/read/{epub,pdf}/[fileId].+layout@.sveltebreakouts reset to the root layout for the login page and the full-screen reader.
Conventions
Prettier (.prettierrc): tabs, single quotes, no trailing commas, 100 columns, with the Svelte and
Tailwind plugins. Run pnpm check (svelte-check) and pnpm lint before considering work done —
but take a baseline first, because neither is clean (see below).
src/lib/vendor/ is excluded from Prettier, ESLint and svelte-check. Don't reformat vendored code.
Known rough edges
Observed in the current tree — don't mistake these for intentional patterns to copy:
pnpm checkis clean as of 2026-08-17 and CI blocks on it — 0 errors, 0 warnings. Any error you see is yours.src/lib/schema/openapi/schema.d.tsis current; regenerate it after any backend API change, withpnpm exec openapi-typescript http://localhost:8000/schema/openapi.json -o src/lib/schema/openapi/schema.d.tsagainst a backend running your branch — a stale server silently writes a stale file.- Prettier is clean and CI blocks on it — run
pnpm formatbefore finishing. Two things it must not touch are in.prettierignore: the vendored foliate-js, andsrc/lib/schema/openapi/schema.d.ts, whichopenapi-typescriptregenerates in its own style. pnpm exec eslint .reports 2 errors as of 2026-08-17, bothsvelte/no-at-html-tagsincollapsible-text.svelte. They are a genuine XSS hole, not a lint nit — seeTODO.md. CI runs eslint non-blocking (continue-on-error) only until that is fixed.static/pdfjs/is ignored alongsidesrc/lib/vendor/— it is vendored too, and linting it produced 1717 further errors.- Two rules are off for
**/*.svelteineslint.config.jsbecause they predate runes and misread them:no-useless-assignment(every$bindable()default) and@typescript-eslint/no-unused-expressions(a barebook;declaring an$effectdependency). A leading underscore marks an intentionally unused binding. src/routes/api/[...path]/+server.ts— all four handlers are annotatedRequestHandlerwhile the import of that type is commented out at line 4. It also buffers whole responses witharrayBuffer()and forwards noRangeheader, so book downloads are not streamed. Requests are streamed — POST and PATCH passrequest.bodythrough withduplex: 'half'(seebodyOf), because a zipped Calibre library upload cannot be held in this process. The response side is still buffered; seeTODO.md.src/app.d.ts—App.Locals["user"]is typed fromlucide-svelte'sUsericon component rather than theUserinterface in$lib/server/auth.- No CSP, which foliate's README asks for because EPUBs can carry scripts. See
TODO.mdfor why it is not enabled yet.