Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"typeAware": true,
"typeCheck": true
},
"ignorePatterns": ["*", "!src/", "!src/**"],
"ignorePatterns": ["*", "!src/", "!src/**", "src/core/api/generated/**"],
"env": {
"browser": true,
"builtin": true,
Expand Down
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@ pnpm lint # dprint -> oxlint -> stylelint

**Dev proxy:** Copy `proxy.config.default.js` to `proxy.config.js` and set the target if Shoko Server is not at `http://localhost:8111`. The dev server auto-opens the browser at `/webui/`.

**API codegen:** `pnpm orval` regenerates `src/core/api/generated/` (TypeScript types + Zod validation schemas) from Shoko Server's live OpenAPI spec, targeting whatever `proxy.config.js` (falling back to `proxy.config.default.js`) points at. Requires a running Shoko Server. Commit the diff — CI has no live server to regenerate from, so the output is checked in, not built on the fly.

## Repo Structure

- `src/pages` – Route-level components.
- `src/components` – Reusable UI components.
- `src/core` – API client (axios), Redux store, React Query, SignalR, router.
- `src/core/api` – Orval-generated types/Zod schemas (`generated/`, do not hand-edit) and the `validateResponse` runtime-validation helper.
- `src/hooks` – Custom React hooks.
- `src/css` – Global styles and Tailwind entry.
- `public/` – Static assets; `version.json` is generated here at build time.
Expand All @@ -41,9 +44,11 @@ pnpm lint # dprint -> oxlint -> stylelint
- `axiosPlex` — Plex endpoints (`/plex`)
- `axiosExternal` — Unconfigured base for external calls
- v3/v2/Plex clients auto-attach `apikey` from Redux; all unwrap `response.data`.
- These three also accept an optional `schema` field in the request config (e.g. `axios.get(url, { params, schema: FooResponse })`) — see API response validation below.
- **Real-time:** SignalR client in `src/core/signalr`, integrated as Redux middleware.
- **Redux:** Single-file store at `src/core/store.ts`. Root reducer clears all state on `AUTH_LOGOUT`. Full store persisted to `sessionStorage`; only `apiSession` persisted to `localStorage` (when `rememberUser` is true). Store is throttled to persist at most once per second. Re-exports typed `useDispatch`/`useSelector` — import from `@/core/store`, never from `react-redux` directly.
- **React Query:** Organized by API sub-path under `src/core/react-query/<endpoint>/` with `queries.ts`, `mutations.ts`, `types.ts`, and optional `helpers.ts`.
- **API response validation:** `src/core/api/generated/` holds Orval-generated types and Zod schemas from Shoko Server's OpenAPI spec (regenerate with `pnpm orval`; never hand-edit). Pass a generated schema via the `schema` key in a request's config (e.g. `axios.get('Tag/AniDB', { params, schema: GetTagAniDBResponse })`) — the `axios`/`axiosV2`/`axiosPlex` response interceptor (`src/core/axios.ts`) then validates the *raw* response against it automatically before returning, throwing `SchemaValidationError` on mismatch, and the call's resolved type is inferred as `z.infer` of the schema (no explicit `<T>` needed). `queryClient.ts` treats `SchemaValidationError` like the existing error path (toast, no retry) instead of the default 4x-retry behavior. Omitting `schema` falls back to today's untyped (`any`) behavior. This is applied incrementally; most endpoints still use hand-written types from `src/core/types/api/` and no `schema`.
- **Build:** Vite 8 with Rolldown. Base path `/webui/`. Hidden sourcemaps. React Compiler enabled via `@rolldown/plugin-babel`. Sentry plugin requires `SENTRY_AUTH_TOKEN`. `version.json` is auto-generated at build time from git hash + package version.
- **Tailwind:** v4 via Vite plugin. Entry point is `src/css/tailwind.css`.
- **Path alias:** `@/` maps to `src/` (configured in `vite.config.mjs` and `tsconfig.json`).
Expand All @@ -55,7 +60,7 @@ This project uses the **React Compiler** (via `@rolldown/plugin-babel`). The com
## Code Style

- **Formatter:** `dprint` (`.dprint.json`). Covers `src/**` only. Line width 120, single quotes (double quotes in JSX), always semicolons.
- **Linter:** Oxlint (`.oxlintrc.json`). Migrated from ESLint. Uses built-in plugins (typescript, react, import, jsx-a11y) and JS plugins (@tanstack/query, better-tailwindcss, sort-destructure-keys, @stylistic).
- **Linter:** Oxlint (`.oxlintrc.json`). Migrated from ESLint. Uses built-in plugins (typescript, react, import, jsx-a11y) and JS plugins (@tanstack/query, better-tailwindcss, sort-destructure-keys, @stylistic). `src/core/api/generated/**` is excluded (`ignorePatterns`) — Orval's generated output uses relative parent imports between its own files, which conflicts with this repo's hand-written-code import rules; `dprint` still formats it.
- **TypeScript:** Prefer `type` over `interface`. Prefer `T[]` syntax. Use consistent type imports. Multiline type members use semicolons; single-line members use commas.
- **Functions:** Arrow-function expressions only (`const Foo = () => ...`). Omit parens for single parameters; require them for block bodies.
- **Identifiers:** Minimum 3 characters. Exceptions: `cx`, `ID`, `id`, `_`, `__`. Object properties are exempt.
Expand Down Expand Up @@ -90,6 +95,7 @@ This project uses the **React Compiler** (via `@rolldown/plugin-babel`). The com
- Do NOT use `npm` or `yarn`; always use `pnpm add` / `pnpm remove`.
- Do not add explicit type annotations where TS inference is sufficient.
- Treat changes to `src/core/axios.ts`, `src/core/store.ts`, and auth-related logic with extra scrutiny.
- `src/core/api/generated/` is Orval-owned — never hand-edit; run `pnpm orval` against a live Shoko Server and commit the diff instead.
- If you modify files, styles, structures, configurations, or workflows mentioned in this file, update the corresponding `AGENTS.md` sections to keep them accurate.
- **Use `semver` for version comparisons** — hand-rolling version parsing with `Number.parseInt`/`split('.')` silently mishandles pre-release suffixes.
- **Use `dayjs` for date formatting/manipulation** — never use `new Date()` / `.toLocaleString()` for display. Always import from `@/core/util`: `import { dayjs } from '@/core/util'`. Plugins and locale are pre-configured there.
Expand Down
145 changes: 145 additions & 0 deletions orval.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { readFile, writeFile } from 'node:fs/promises';

import { defineConfig } from 'orval';

import type { InputTransformerFn } from 'orval';

const PROXY_TARGET_KEY = '^/(api|plex)/.*';
const DEFAULT_SHOKO_SERVER_URL = 'http://localhost:8111';
const GENERATED_SCHEMAS_FILE = 'src/core/api/generated/shokoServerAPI30.schemas.ts';
const ZOD_IMPORT = "import * as zod from 'zod';\n\n";

// Shoko Server's OpenAPI spec has a handful of operations (all under the
// "Group" tag's image endpoints) that declare a path parameter with no
// matching `{name}` segment in the route template — e.g. `groupID` is
// declared as a path param on `/Group/{seriesID}/Images/{imageType}`, which
// has no `{groupID}` segment. This is a real bug in the server's route
// attributes, not something wrong with this config. Orval's spec validator
// rejects it outright, so it's stripped here (rather than disabling
// validation globally, which would also hide genuine drift elsewhere) —
// remove this transformer once it's fixed server-side.
const dropOrphanedPathParameters: InputTransformerFn = (spec) => {
for (const [route, pathItem] of Object.entries(spec.paths ?? {})) {
for (const operation of Object.values(pathItem ?? {})) {
if (typeof operation !== 'object' || operation === null || !('parameters' in operation)) continue;

operation.parameters = (operation.parameters ?? []).filter(param =>
!('name' in param) || param.in !== 'path' || route.includes(`{${param.name}}`)
);
}
}

return spec;
};

// A couple of routes (e.g. `Series/Search`, `Series/AniDB/Search`) have both
// a current operation and an `@deprecated` legacy twin at the exact same
// route+verb-derived shape. With no `operationId` anywhere in this spec,
// Orval's fallback name synthesis can't tell them apart and emits the same
// export name for both, which fails to compile ("Cannot redeclare
// block-scoped variable"). Dropping deprecated operations fixes the
// collision at the root and is reasonable on its own merits — the WebUI
// shouldn't be generating clients for endpoints the server already flags as
// deprecated.
const dropDeprecatedOperations: InputTransformerFn = (spec) => {
for (const pathItem of Object.values(spec.paths ?? {})) {
for (const [verb, operation] of Object.entries(pathItem ?? {})) {
if (typeof operation !== 'object' || operation === null || !('deprecated' in operation)) continue;
if (operation.deprecated) delete (pathItem as Record<string, unknown>)[verb];
}
}

return spec;
};

const transformSpec: InputTransformerFn = async spec => dropDeprecatedOperations(await dropOrphanedPathParameters(spec));

const importProxyConfig = async (path: string) => {
const proxyModule = (await import(path)) as { default?: Record<string, unknown> } & Record<string, unknown>;
return proxyModule.default ?? proxyModule;
};

// Mirrors the same fallback chain vite.config.mjs uses for the dev proxy, so
// `pnpm orval` always targets whatever Shoko Server the developer already
// pointed their dev proxy at (falling back to the committed default).
const resolveShokoServerUrl = async () => {
const proxyConfig = await importProxyConfig('./proxy.config.js').catch(() =>
importProxyConfig('./proxy.config.default.js')
);
const target = proxyConfig[PROXY_TARGET_KEY];

return typeof target === 'string' ? target : DEFAULT_SHOKO_SERVER_URL;
};

// Orval 8.23's `generateReusableSchemas` (an experimental option — see its
// own doc comment) writes the shared, deduplicated component schemas to
// `shokoServerAPI30.schemas.ts` but omits the `import * as zod from 'zod'`
// header that file needs, even though every export in it calls `zod.*`.
// Patches it back in after generation. Remove once fixed upstream.
const fixMissingZodImport = async () => {
const contents = await readFile(GENERATED_SCHEMAS_FILE, 'utf8');
if (contents.startsWith(ZOD_IMPORT)) return;
await writeFile(GENERATED_SCHEMAS_FILE, ZOD_IMPORT + contents);
};

// So a stale `src/core/api/generated/` (committed, regenerated manually) is
// easy to spot: stamp every generated file with when it was generated and
// which Shoko Server build it was generated against, via the same `Init/Version`
// endpoint the app itself uses (`useVersionQuery`, `src/core/react-query/init/queries.ts`).
const resolveShokoServerVersion = async (shokoServerUrl: string) => {
try {
const response = await fetch(`${shokoServerUrl}/api/v3/Init/Version`);
const data = (await response.json()) as { Server?: { Version?: string } };
return data.Server?.Version ?? 'unknown';
} catch {
return 'unknown';
}
};

export default defineConfig(async () => {
const shokoServerUrl = await resolveShokoServerUrl();
const shokoServerVersion = await resolveShokoServerVersion(shokoServerUrl);
const generatedAt = new Date().toISOString();

return {
shoko: {
input: {
target: `${shokoServerUrl}/swagger/v3/swagger.json`,
override: {
transformer: transformSpec,
},
},
output: {
client: 'zod',
mode: 'tags-split',
target: 'src/core/api/generated',
clean: true,
override: {
zod: {
// One reusable schema per named OpenAPI component (e.g. `Tag`)
// instead of the shape being duplicated inline in every response
// schema that references it.
generateReusableSchemas: true,
},
header: info => [
`Generated by orval from Shoko Server's OpenAPI spec (API v${info.version}).`,
'Do not edit manually — run `pnpm orval` to regenerate.',
`Generated at: ${generatedAt}`,
`Shoko Server version at generation time: ${shokoServerVersion}`,
],
},
},
hooks: {
// src/core/api/generated/** is excluded from oxlint entirely
// (.oxlintrc.json) — Orval's tags-split output structurally uses
// relative parent imports between generated files, which the repo's
// hand-written-code lint rules disallow. dprint still formats it for
// readability.
afterAllFilesWrite: [
fixMissingZodImport,
'./node_modules/.bin/dprint fmt src/core/api/generated/**/*.ts',
],
},
},
};
});
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@
"semver": "^7.8.3",
"simple-icons": "^16.23.0",
"use-immer": "^0.11.0",
"usehooks-ts": "^3.1.1"
"usehooks-ts": "^3.1.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@rolldown/plugin-babel": "^0.2.3",
Expand Down Expand Up @@ -81,6 +82,7 @@
"eslint-plugin-sort-destructure-keys": "^3.0.0",
"husky": "^9.1.7",
"lint-staged": "^17.0.7",
"orval": "^8.23.0",
"oxlint": "^1.70.0",
"oxlint-tsgolint": "^0.23.0",
"stylelint": "^17.13.0",
Expand All @@ -97,6 +99,7 @@
"dprint:fix": "dprint fmt",
"stylelint": "stylelint \"src/css/*.css\"",
"tscheck": "tsc --noEmit",
"orval": "orval",
"lint": "pnpm dprint && pnpm oxlint && pnpm stylelint",
"build": "cross-env NODE_ENV=production vite build",
"build:debug": "cross-env NODE_ENV=development vite build",
Expand Down
Loading