diff --git a/dependency-cruiser.cjs b/dependency-cruiser.cjs
index 5ef822c..3e11c2e 100644
--- a/dependency-cruiser.cjs
+++ b/dependency-cruiser.cjs
@@ -1,22 +1,43 @@
module.exports = {
forbidden: [
+ {
+ name: "no-circular",
+ severity: "error",
+ from: {},
+ to: { circular: true },
+ },
+ {
+ name: "slices-must-use-public-api",
+ severity: "error",
+ from: {
+ path: "^src/(app|pages|features|entities|shared)/([^/]+)/",
+ },
+ to: {
+ path: "^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\\.ts$)",
+ pathNot: "^src/$1/$2/",
+ },
+ },
{
name: "shared-must-not-depend-on-layers",
+ severity: "error",
from: { path: "^src/shared/" },
to: { path: "^src/(entities|features|pages|app)/" },
},
{
name: "entities-must-not-depend-on-higher-layers",
+ severity: "error",
from: { path: "^src/entities/" },
to: { path: "^src/(features|pages|app)/" },
},
{
name: "features-must-not-depend-on-higher-layers",
+ severity: "error",
from: { path: "^src/features/" },
to: { path: "^src/(pages|app)/" },
},
{
name: "pages-must-not-depend-on-app",
+ severity: "error",
from: { path: "^src/pages/" },
to: { path: "^src/app/" },
},
diff --git a/package.json b/package.json
index 5c9c1af..91a657c 100644
--- a/package.json
+++ b/package.json
@@ -19,8 +19,10 @@
"openapi:check": "node scripts/openapi-check.mjs",
"hygiene:check": "node scripts/repository-hygiene-check.mjs",
"knip": "knip",
- "deps": "depcruise src --config dependency-cruiser.cjs --output-type err-long",
- "verify": "npm run openapi:check && npm run hygiene:check && npm run typecheck && npm run lint && npm run test:run && npm run test:coverage && npm run knip && npm run deps && npm run build",
+ "deps": "node --test scripts/dependency-cruiser-config.test.mjs && depcruise src --config dependency-cruiser.cjs --output-type err-long",
+ "bundle:check": "node --test scripts/bundle-budget.test.mjs && node scripts/bundle-budget.mjs",
+ "bundle:baseline": "npm run build && node scripts/bundle-budget.mjs baseline",
+ "verify": "npm run openapi:check && npm run hygiene:check && npm run typecheck && npm run lint && npm run test:run && npm run test:coverage && npm run knip && npm run deps && npm run build && npm run bundle:check",
"prepare": "husky",
"preview": "vite preview"
},
diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs
new file mode 100644
index 0000000..9aec207
--- /dev/null
+++ b/scripts/bundle-budget.mjs
@@ -0,0 +1,144 @@
+import { statSync } from "node:fs";
+import { readFile, writeFile } from "node:fs/promises";
+import { resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+
+export const INITIAL_BUNDLE_BUDGET_BYTES = 500 * 1024;
+
+function manifestEntries(manifest) {
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
+ throw new Error("Vite manifest must be an object");
+ }
+ return Object.entries(manifest);
+}
+
+function assetFile(entry, entryName) {
+ if (!entry || typeof entry.file !== "string" || !entry.file.endsWith(".js")) {
+ throw new Error(
+ `Manifest entry '${entryName}' must reference a JavaScript file`,
+ );
+ }
+ return entry.file;
+}
+
+export function getInitialAssetFiles(manifest) {
+ const entries = manifestEntries(manifest);
+ const entryCandidates = entries.filter(
+ ([, entry]) => entry?.isEntry === true,
+ );
+ if (entryCandidates.length !== 1) {
+ throw new Error("Vite manifest must contain exactly one JavaScript entry");
+ }
+
+ const files = [];
+ const visitedEntries = new Set();
+ const visit = (entryName) => {
+ if (visitedEntries.has(entryName)) return;
+ visitedEntries.add(entryName);
+ const entry = manifest[entryName];
+ if (!entry)
+ throw new Error(`Vite manifest is missing eager import '${entryName}'`);
+ files.push(assetFile(entry, entryName));
+ for (const importedEntry of entry.imports ?? []) visit(importedEntry);
+ };
+
+ visit(entryCandidates[0][0]);
+ return files;
+}
+
+export function measureAssets(files, getSize) {
+ return files.reduce((total, file) => {
+ const size = getSize(file);
+ if (!Number.isSafeInteger(size) || size < 0) {
+ throw new Error(`Cannot measure bundle asset '${file}'`);
+ }
+ return total + size;
+ }, 0);
+}
+
+export function assertBundleBudget(initialBytes) {
+ if (initialBytes > INITIAL_BUNDLE_BUDGET_BYTES) {
+ throw new Error(
+ `Initial JavaScript payload is ${initialBytes} bytes; budget is ${INITIAL_BUNDLE_BUDGET_BYTES} bytes`,
+ );
+ }
+}
+
+export function getLazyRouteAssetSizes(manifest, getSize, baseline) {
+ const routeEntries = manifestEntries(manifest).filter(
+ ([, entry]) =>
+ entry?.isDynamicEntry === true && typeof entry.src === "string",
+ );
+ if (routeEntries.length === 0) {
+ throw new Error("Vite manifest must contain lazy route chunks");
+ }
+
+ const routeSizes = Object.fromEntries(
+ routeEntries
+ .map(([entryName, entry]) => [
+ entry.src,
+ getSize(assetFile(entry, entryName)),
+ ])
+ .sort(([left], [right]) => left.localeCompare(right)),
+ );
+ if (!baseline) return routeSizes;
+
+ for (const [route, size] of Object.entries(routeSizes)) {
+ if (!Number.isSafeInteger(baseline[route])) {
+ throw new Error(`Missing baseline for lazy route chunk '${route}'`);
+ }
+ if (size > baseline[route]) {
+ throw new Error(
+ `Lazy route chunk '${route}' is ${size} bytes; baseline is ${baseline[route]} bytes`,
+ );
+ }
+ }
+ return routeSizes;
+}
+
+const distributionDirectory = resolve("dist");
+const manifestPath = resolve(distributionDirectory, "bundle-manifest.json");
+const baselinePath = resolve("scripts/lazy-route-bundle-baseline.json");
+
+async function loadJson(path) {
+ return JSON.parse(await readFile(path, "utf8"));
+}
+
+function getAssetSize(file) {
+ return statSync(resolve(distributionDirectory, file)).size;
+}
+
+async function run(mode) {
+ const manifest = await loadJson(manifestPath);
+ const initialBytes = measureAssets(
+ getInitialAssetFiles(manifest),
+ getAssetSize,
+ );
+ assertBundleBudget(initialBytes);
+ const baseline =
+ mode === "baseline" ? undefined : await loadJson(baselinePath);
+ const lazyRouteSizes = getLazyRouteAssetSizes(
+ manifest,
+ getAssetSize,
+ baseline,
+ );
+
+ if (mode === "baseline") {
+ await writeFile(
+ baselinePath,
+ `${JSON.stringify(lazyRouteSizes, null, "\t")}\n`,
+ );
+ }
+ console.log(`Initial JavaScript payload: ${initialBytes} bytes`);
+}
+
+if (
+ process.argv[1] &&
+ import.meta.url === pathToFileURL(process.argv[1]).href
+) {
+ const mode = process.argv[2] ?? "check";
+ if (mode !== "check" && mode !== "baseline") {
+ throw new Error("Usage: node scripts/bundle-budget.mjs [check|baseline]");
+ }
+ await run(mode);
+}
diff --git a/scripts/bundle-budget.test.mjs b/scripts/bundle-budget.test.mjs
new file mode 100644
index 0000000..82e1de9
--- /dev/null
+++ b/scripts/bundle-budget.test.mjs
@@ -0,0 +1,85 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ assertBundleBudget,
+ getInitialAssetFiles,
+ getLazyRouteAssetSizes,
+ INITIAL_BUNDLE_BUDGET_BYTES,
+ measureAssets,
+} from "./bundle-budget.mjs";
+
+const manifest = {
+ "index.html": {
+ file: "assets/entry.js",
+ isEntry: true,
+ imports: ["_shared"],
+ dynamicImports: ["src/pages/account/ui/AccountPage.tsx"],
+ },
+ _shared: { file: "assets/shared.js", imports: ["_runtime"] },
+ _runtime: { file: "assets/runtime.js" },
+ "src/pages/account/ui/AccountPage.tsx": {
+ file: "assets/account.js",
+ isDynamicEntry: true,
+ src: "src/pages/account/ui/AccountPage.tsx",
+ },
+ "src/app/routes/ProtectedLayout.tsx": {
+ file: "assets/protected-layout.js",
+ isDynamicEntry: true,
+ src: "src/app/routes/ProtectedLayout.tsx",
+ },
+};
+
+const sizes = new Map([
+ ["assets/entry.js", 200 * 1024],
+ ["assets/shared.js", 175 * 1024],
+ ["assets/runtime.js", 100 * 1024],
+ ["assets/account.js", 250 * 1024],
+ ["assets/protected-layout.js", 50 * 1024],
+]);
+
+test("measures only the eager import graph of the entry bundle", () => {
+ const assets = getInitialAssetFiles(manifest);
+ assert.deepEqual(assets, [
+ "assets/entry.js",
+ "assets/shared.js",
+ "assets/runtime.js",
+ ]);
+ assert.equal(
+ measureAssets(assets, (asset) => sizes.get(asset)),
+ 475 * 1024,
+ );
+});
+
+test("rejects an initial bundle above the configured budget", () => {
+ assert.throws(
+ () => assertBundleBudget(INITIAL_BUNDLE_BUDGET_BYTES + 1),
+ /JavaScript payload/,
+ );
+});
+
+test("rejects malformed manifests and lazy-route size regressions", () => {
+ assert.throws(() => getInitialAssetFiles({}), /entry/);
+ assert.deepEqual(
+ getLazyRouteAssetSizes(manifest, (asset) => sizes.get(asset)),
+ {
+ "src/app/routes/ProtectedLayout.tsx": 50 * 1024,
+ "src/pages/account/ui/AccountPage.tsx": 250 * 1024,
+ },
+ );
+ assert.throws(
+ () =>
+ getLazyRouteAssetSizes(manifest, (asset) => sizes.get(asset), {
+ "src/app/routes/ProtectedLayout.tsx": 50 * 1024,
+ "src/pages/account/ui/AccountPage.tsx": 249 * 1024,
+ }),
+ /route chunk/,
+ );
+ assert.throws(
+ () =>
+ getLazyRouteAssetSizes(manifest, (asset) => sizes.get(asset), {
+ "src/pages/account/ui/AccountPage.tsx": 250 * 1024,
+ }),
+ /Missing baseline/,
+ );
+});
diff --git a/scripts/dependency-cruiser-config.test.mjs b/scripts/dependency-cruiser-config.test.mjs
new file mode 100644
index 0000000..570c841
--- /dev/null
+++ b/scripts/dependency-cruiser-config.test.mjs
@@ -0,0 +1,117 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+
+const scriptsDirectory = dirname(fileURLToPath(import.meta.url));
+const projectDirectory = dirname(scriptsDirectory);
+const dependencyCruiser = join(
+ projectDirectory,
+ "node_modules/dependency-cruiser/bin/dependency-cruise.mjs",
+);
+const dependencyConfig = join(projectDirectory, "dependency-cruiser.cjs");
+
+async function withFixture(files, run) {
+ const fixtureDirectory = await mkdtemp(join(tmpdir(), "wealth-ledger-deps-"));
+ try {
+ await Promise.all(
+ Object.entries(files).map(async ([relativePath, contents]) => {
+ const filePath = join(fixtureDirectory, relativePath);
+ await mkdir(dirname(filePath), { recursive: true });
+ await writeFile(filePath, contents);
+ }),
+ );
+ return await run(fixtureDirectory);
+ } finally {
+ await rm(fixtureDirectory, { recursive: true, force: true });
+ }
+}
+
+function cruise(fixtureDirectory) {
+ return spawnSync(
+ process.execPath,
+ [
+ dependencyCruiser,
+ "src",
+ "--config",
+ dependencyConfig,
+ "--output-type",
+ "err-long",
+ ],
+ { cwd: fixtureDirectory, encoding: "utf8" },
+ );
+}
+
+function output(result) {
+ return `${result.stdout}${result.stderr}`;
+}
+
+test("rejects a source import cycle", async () => {
+ await withFixture(
+ {
+ "tsconfig.app.json": JSON.stringify({ compilerOptions: {} }),
+ "src/shared/i18n/index.ts": 'export { value } from "./zod";\n',
+ "src/shared/i18n/zod.ts": 'export { value } from ".";\n',
+ },
+ (fixtureDirectory) => {
+ const result = cruise(fixtureDirectory);
+ assert.notEqual(result.status, 0, output(result));
+ assert.match(output(result), /no-circular/);
+ },
+ );
+});
+
+test("rejects a deep import into another slice", async () => {
+ await withFixture(
+ {
+ "tsconfig.app.json": JSON.stringify({ compilerOptions: {} }),
+ "src/features/auth/ui/Form.ts": 'import "../../../shared/i18n/zod";\n',
+ "src/shared/i18n/index.ts": "export {};\n",
+ "src/shared/i18n/zod.ts": "export {};\n",
+ },
+ (fixtureDirectory) => {
+ const result = cruise(fixtureDirectory);
+ assert.notEqual(result.status, 0, output(result));
+ assert.match(output(result), /slices-must-use-public-api/);
+ },
+ );
+});
+
+test("rejects an index.tsx import from another slice", async () => {
+ await withFixture(
+ {
+ "tsconfig.app.json": JSON.stringify({ compilerOptions: {} }),
+ "src/features/auth/ui/Form.ts": 'import "../../../shared/i18n";\n',
+ "src/shared/i18n/index.tsx": "export {};\n",
+ },
+ (fixtureDirectory) => {
+ const result = cruise(fixtureDirectory);
+ assert.notEqual(result.status, 0, output(result));
+ assert.match(output(result), /slices-must-use-public-api/);
+ },
+ );
+});
+
+test("allows imports within a slice and through another slice public API", async () => {
+ await withFixture(
+ {
+ "tsconfig.app.json": JSON.stringify({ compilerOptions: {} }),
+ "src/features/auth/model/local.ts": "export const local = true;\n",
+ "src/features/auth/ui/Form.ts": [
+ 'import { local } from "../model/local";\n',
+ 'import { value } from "../../../shared/i18n";\n',
+ "void local;\n",
+ "void value;\n",
+ ].join(""),
+ "src/shared/i18n/index.ts": 'export { value } from "./zod";\n',
+ "src/shared/i18n/zod.ts": "export const value = true;\n",
+ },
+ (fixtureDirectory) => {
+ const result = cruise(fixtureDirectory);
+ assert.equal(result.status, 0, output(result));
+ },
+ );
+});
diff --git a/scripts/lazy-route-bundle-baseline.json b/scripts/lazy-route-bundle-baseline.json
new file mode 100644
index 0000000..de340ba
--- /dev/null
+++ b/scripts/lazy-route-bundle-baseline.json
@@ -0,0 +1,11 @@
+{
+ "src/app/routes/ProtectedLayout.tsx": 3137,
+ "src/pages/account-events/index.ts": 4468,
+ "src/pages/account-operations/index.ts": 13861,
+ "src/pages/account/index.ts": 17018,
+ "src/pages/accounts/index.ts": 6174,
+ "src/pages/login/index.ts": 669,
+ "src/pages/not-found/index.ts": 850,
+ "src/pages/register/index.ts": 675,
+ "src/pages/settings/index.ts": 38876
+}
diff --git a/src/app/routes/AppRoutes.test.tsx b/src/app/routes/AppRoutes.test.tsx
index 5eabbcf..0d99aab 100644
--- a/src/app/routes/AppRoutes.test.tsx
+++ b/src/app/routes/AppRoutes.test.tsx
@@ -1,6 +1,7 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import { HttpResponse, http } from "msw";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -10,6 +11,7 @@ import {
AccountsPageError,
AccountsPageLoading,
} from "@/pages/accounts";
+import { server } from "@/test/server";
import { theme } from "../theme";
import { AppRoutes } from "./AppRoutes";
@@ -89,6 +91,30 @@ describe("AppRoutes", () => {
);
});
+ it("lazy-loads the protected shell for an authenticated accounts route", async () => {
+ server.use(
+ http.post("*/api/v1/auth/refresh", () =>
+ HttpResponse.json({
+ accessToken: "test-token",
+ user: { id: "user-1", email: "user@example.com" },
+ }),
+ ),
+ );
+ renderRoutes("/accounts");
+
+ await waitFor(() =>
+ expect(
+ screen.getByRole("heading", {
+ level: 2,
+ name: "Нет инвестиционных счетов",
+ }),
+ ).toBeInTheDocument(),
+ );
+ expect(
+ screen.getByRole("navigation", { name: "WealthLedger" }),
+ ).toBeInTheDocument();
+ });
+
it.each([
["loading", AccountsPageLoading, "status"],
["error", AccountsPageError, "alert"],
diff --git a/src/app/routes/AppRoutes.tsx b/src/app/routes/AppRoutes.tsx
index 8c7bf2c..1ca6180 100644
--- a/src/app/routes/AppRoutes.tsx
+++ b/src/app/routes/AppRoutes.tsx
@@ -1,15 +1,48 @@
+import { Loader } from "@mantine/core";
+import { lazy, Suspense } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router";
import { useAuth } from "@/features/auth";
-import { AccountPage } from "@/pages/account";
-import { AccountEventsPage } from "@/pages/account-events";
-import { AccountOperationsPage } from "@/pages/account-operations";
-import { AccountsPage } from "@/pages/accounts";
-import { LoginPage } from "@/pages/login";
-import { NotFoundPage } from "@/pages/not-found";
-import { RegisterPage } from "@/pages/register";
-import { SettingsPage } from "@/pages/settings";
-import { AppShell } from "@/shared/ui";
-import { AppBreadcrumbs } from "../ui/AppBreadcrumbs";
+
+const AccountPage = lazy(async () => {
+ const { AccountPage } = await import("@/pages/account");
+ return { default: AccountPage };
+});
+const AccountEventsPage = lazy(async () => {
+ const { AccountEventsPage } = await import("@/pages/account-events");
+ return { default: AccountEventsPage };
+});
+const AccountOperationsPage = lazy(async () => {
+ const { AccountOperationsPage } = await import("@/pages/account-operations");
+ return { default: AccountOperationsPage };
+});
+const AccountsPage = lazy(async () => {
+ const { AccountsPage } = await import("@/pages/accounts");
+ return { default: AccountsPage };
+});
+const LoginPage = lazy(async () => {
+ const { LoginPage } = await import("@/pages/login");
+ return { default: LoginPage };
+});
+const NotFoundPage = lazy(async () => {
+ const { NotFoundPage } = await import("@/pages/not-found");
+ return { default: NotFoundPage };
+});
+const RegisterPage = lazy(async () => {
+ const { RegisterPage } = await import("@/pages/register");
+ return { default: RegisterPage };
+});
+const SettingsPage = lazy(async () => {
+ const { SettingsPage } = await import("@/pages/settings");
+ return { default: SettingsPage };
+});
+const ProtectedLayout = lazy(async () => {
+ const { ProtectedLayout } = await import("./ProtectedLayout");
+ return { default: ProtectedLayout };
+});
+
+function LazyPage({ children }: { children: React.ReactNode }) {
+ return }>{children};
+}
function Protected({ children }: { children: React.ReactNode }) {
const { user, ready } = useAuth();
@@ -40,7 +73,9 @@ export function AppRoutes() {
path="/login"
element={
-
+
+
+
}
/>
@@ -48,30 +83,70 @@ export function AppRoutes() {
path="/register"
element={
-
+
+
+
}
/>
- } />
+
+
+
}
>
- } />
- } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
}
+ element={
+
+
+
+ }
/>
}
+ element={
+
+
+
+ }
+ />
+
+
+
+ }
/>
- } />
- } />
+
+
+
+ }
+ />
);
}
diff --git a/src/app/routes/ProtectedLayout.tsx b/src/app/routes/ProtectedLayout.tsx
new file mode 100644
index 0000000..e62bf97
--- /dev/null
+++ b/src/app/routes/ProtectedLayout.tsx
@@ -0,0 +1,6 @@
+import { AppBreadcrumbs } from "@/app/ui";
+import { AppShell } from "@/shared/ui";
+
+export function ProtectedLayout() {
+ return } />;
+}
diff --git a/src/app/routes/index.ts b/src/app/routes/index.ts
index 1ca058c..504c1bd 100644
--- a/src/app/routes/index.ts
+++ b/src/app/routes/index.ts
@@ -1 +1,2 @@
export { AppRoutes } from "./AppRoutes";
+export { RouteErrorBoundary } from "./RouteErrorBoundary";
diff --git a/src/app/ui/App.tsx b/src/app/ui/App.tsx
index b0b9fb4..3bf376c 100644
--- a/src/app/ui/App.tsx
+++ b/src/app/ui/App.tsx
@@ -1,8 +1,7 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router";
-import { AppRoutes } from "@/app/routes";
-import { RouteErrorBoundary } from "@/app/routes/RouteErrorBoundary";
+import { AppRoutes, RouteErrorBoundary } from "@/app/routes";
import { theme } from "@/app/theme";
import { AuthProvider } from "@/features/auth";
diff --git a/src/app/ui/index.ts b/src/app/ui/index.ts
new file mode 100644
index 0000000..0f97dc8
--- /dev/null
+++ b/src/app/ui/index.ts
@@ -0,0 +1 @@
+export { AppBreadcrumbs } from "./AppBreadcrumbs";
diff --git a/src/features/auth/ui/AuthForm.tsx b/src/features/auth/ui/AuthForm.tsx
index 233137d..9c761d0 100644
--- a/src/features/auth/ui/AuthForm.tsx
+++ b/src/features/auth/ui/AuthForm.tsx
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { z } from "zod";
-import { validationMessage } from "../../../shared/i18n";
+import { validationMessage } from "@/shared/i18n";
function isEmailCharacterInvalid(character: string): boolean {
return (
diff --git a/src/shared/i18n/i18n.ts b/src/shared/i18n/i18n.ts
new file mode 100644
index 0000000..fee305d
--- /dev/null
+++ b/src/shared/i18n/i18n.ts
@@ -0,0 +1,13 @@
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+
+import { ru } from "./locales/ru";
+
+void i18n.use(initReactI18next).init({
+ resources: { ru },
+ lng: "ru",
+ fallbackLng: "ru",
+ interpolation: { escapeValue: false },
+});
+
+export { i18n };
diff --git a/src/shared/i18n/index.ts b/src/shared/i18n/index.ts
index 0ded77c..72970a0 100644
--- a/src/shared/i18n/index.ts
+++ b/src/shared/i18n/index.ts
@@ -1,14 +1 @@
-import i18n from "i18next";
-import { initReactI18next } from "react-i18next";
-
-import { ru } from "./locales/ru";
-
-void i18n.use(initReactI18next).init({
- resources: { ru },
- lng: "ru",
- fallbackLng: "ru",
- interpolation: { escapeValue: false },
-});
-
export { validationMessage } from "./zod";
-export { i18n };
diff --git a/src/shared/i18n/zod.ts b/src/shared/i18n/zod.ts
index 29b4851..b152e9b 100644
--- a/src/shared/i18n/zod.ts
+++ b/src/shared/i18n/zod.ts
@@ -1,6 +1,6 @@
import type { ParseKeys } from "i18next";
-import { i18n } from ".";
+import { i18n } from "./i18n";
export function validationMessage(
key: ParseKeys,
diff --git a/vite.config.ts b/vite.config.ts
index 4c8a6bc..82ab4fa 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -7,4 +7,5 @@ import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
+ build: { manifest: "bundle-manifest.json" },
});
diff --git a/vitest.config.ts b/vitest.config.ts
index 48779e5..4e76acf 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,14 +1,25 @@
+import { fileURLToPath, URL } from "node:url";
import react from "@vitejs/plugin-react";
-import { defineConfig } from "vitest/config";
+import { configDefaults, defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
test: {
environment: "jsdom",
+ exclude: [...configDefaults.exclude, "scripts/**/*.test.mjs"],
setupFiles: "./src/test/setup.ts",
- coverage: { reporter: ["text", "html"] },
+ coverage: {
+ all: true,
+ include: ["src/**/*.{ts,tsx}"],
+ exclude: ["src/test/**", "src/**/*.test.{ts,tsx}"],
+ reporter: ["text", "html"],
+ thresholds: {
+ statements: 82,
+ branches: 75,
+ functions: 75,
+ lines: 83,
+ },
+ },
},
});
-
-import { fileURLToPath, URL } from "node:url";