# EviKit

[Preact](https://preactjs.com/) SSR framework using a [node:sqlite](https://nodejs.org/api/sqlite.html) ORM and [Vite](https://vite.dev/). For small web apps hosted on a VPS or in local network.

Vite is a good foundation for building a server-rendered monolith SPA with an JSON API and client hydration. I made a few modules that help me build such an SPA on top of Node.js, Preact, [Express](https://expressjs.com/), [Valibot](https://valibot.dev/) and popular libraries from their ecosystems. An SPA built with EviKit can:

- For each `/path`, fetch `/api/path` and SSR the page
- Send urlencoded forms and refresh `/api/path` without a full page reload
- Validate API input and output against your schemas
- Input is: cookies + url params + body (each overrides previous if key exists)
  - Note: PHP [removed cookies from this list](https://stackoverflow.com/questions/51538622/php-ini-request-order-security-concerns#comment90071163_51538946) but I think it's ok if used carefully
- Keep fetched data between link navigations
- Show the language chosen by the user in browser settings
- Have SEO meta descriptions
- Generate API documentation with a Swagger overview
- Send multipart not urlencoded when there's a file
- Distribute requests between listeners for each CPU core

_TODO_ Add more simple language docs near code, don't overwhelm readers.

## Quick start

> 😉 If you are new to Preact and the concept of Virtual DOM, it is a good idea to complete the [Preact tutorial](https://preactjs.com/tutorial/) first.

Run the [create-evikit](https://codeberg.org/nykula/create-evikit) initializer to start with a todo app example that you can see on the [EviKit website](https://evikit.js.org/):

```sh
npm init evikit my-app
cd my-app
npm run dev
```

Then open http://localhost:5173/, try editing the example routes and see changes live.

The initializer automates some of the steps from this tutorial, such as installing dependencies, adding scripts to your `package.json`, and creating typical files such as `index.html` and `vite.config.js`.

## Documentation

Let's see how to build a basic poll application using this framework. The application will be a site that lets people view polls, suggest their options and vote.

Our language will be standard modern JavaScript as supported by Node.js directly. This means no JSX or other extensions, except node_modules resolution.

- [Create project](#create-project)
- [Database schema](#database-schema)
- [Routing](#router)
  - [Language negotiation](#language-negotiation)
  - [Error page](#error-page)
  - [API router](#api-router)
  - [Page router](#page-router)
  - [HTML entry point](#html-entry-point)
  - [Development server](#development-server)
  - [Production server](#production-server)
  - [HTTPS origin](#https-origin)
- [Application logic](#application-logic)
  - [Data fetching](#data-fetching)
  - [Forms](#forms)
    - [File upload](#file-upload)
- [Translation](#translation)
- [Styling](#styling)
- _TODO_ [Testing](#testing)
- [Linting](#linting)

### Create project

First, install dependencies:

```sh
git init mysite
cd mysite
npm init -y

# Routing and validation.
npm i accept-language classnames evikit express file-type hoofd preact preact-iso valibot
npm i -D @preact/preset-vite cross-env vite vite-bundle-analyzer

# API documentation.
npm i swagger-ui-express

# Type safety.
npm i -D @types/express @types/node @types/swagger-ui-express typescript
```

Second, configure strict JSDoc type checking using TypeScript at `tsconfig.json`:

```sh
npx tsc --init --outDir dist --allowJs --checkJs --noEmit --esModuleInterop
```

Third, open package.json and change `"type": "commonjs"` to `"type": "module"` because we want modern import syntax.

Fourth, add a `.gitignore`. I suggest starting with:

```
node_modules
/dist
*~
*swp
*.mo
.env
```

### Database schema

Let's start by modelling a relational database schema. We have a question with multiple text choice options. Create `src/lib/db/schema.js`:

```js
import { primary, references } from "evikit";
import { integer, number, object, optional, pipe, string } from "valibot";

export const Question = object({
  createdAt: pipe(number(), integer()),
  id: pipe(
    optional(string(), () => crypto.randomUUID()),
    primary(),
  ),
  text: string(),
});

export const Choice = object({
  id: pipe(
    optional(string(), () => crypto.randomUUID()),
    primary(),
  ),
  questionId: pipe(string(), references(Question, "id")),
  text: string(),
  votes: pipe(number(), integer()),
});
```

Export the database at `src/lib/db/index.js`:

```js
import { sqlite } from "evikit/sqlite";

import pkg from "../../../package.json" with { type: "json" };
import * as schema from "./schema.js";

export const db = sqlite({ pkg, schema });
```

- See ChoiceServer below for an example of `upsert` returning a created row.
- See QuestionIdServer below for an example of `select` with a join.
- See VoteServer below for an example of `update` and `delete`.

The database is kept under the [OS-specific path](https://github.com/sindresorhus/env-paths) for your application, such as `~/.local/share/mysite-nodejs/db.sqlite3` on GNU/Linux.

If you want a temporary, in-memory database, omit `pkg`, pass just `{ schema }`.

### Routing

#### Language negotiation

Configure languages you're going to accept at `src/lib/index.js`:

```js
import acceptLanguage from "accept-language";

export function acceptLanguages() {
  acceptLanguage.languages(["en"]);
}
```

Start with `en`. Add languages later when [translations](#translation) are ready.

#### Error page

Code an error page at `src/routes/error.js`:

```js
import { a, h1, main, p, useI18n, useStatus } from "evikit";
import { useMeta, useTitle } from "hoofd/preact";

/** @param {{ children?: import("preact").ComponentChildren, error?: unknown }} props */
export function ErrorPage({ children, error }) {
  const i18n = useI18n();
  const status = useStatus(error);
  useMeta({ content: "noindex", name: "robots" });
  useTitle(status);

  return main(
    null,
    p({ class: "nav" }, a({ href: "/" }, i18n.gettext("Questions"))),
    h1({ class: "title" }, status),
    children,
  );
}
```

#### API router

Add an API router at `src/api.js` (uncomment the routes one by one when we add them later):

```js
import { route, router } from "evikit/server";

import pkg from "../package.json" with { type: "json" };
import { acceptLanguages } from "./lib/index.js";
// import { ChoiceServer } from "./routes/api/choice/server.js";
// import { QuestionIdServer } from "./routes/api/question/[id]/server.js";
// import { HomeServer } from "./routes/api/server.js";
// import { VoteServer } from "./routes/api/vote/server.js";

acceptLanguages();

export function api() {
  return router(
    { pkg },
    // route("/api", HomeServer),
    // route("/api/choice", ChoiceServer),
    // route("/api/question/:id", QuestionIdServer),
    // route("/api/vote", VoteServer)
  );
}
```

#### Page router

Add a Preact router at `src/app.js` (uncomment the routes one-by-one when we add them later):

```js
import { hydrate, Router, ssr } from "evikit";
import { h } from "preact";
import { Route } from "preact-iso";

import { acceptLanguages } from "./lib/index.js";
import { ErrorPage } from "./routes/error.js";
// import { HomePage } from "./routes/page.js";
// import { QuestionIdPage } from "./routes/question/[id]/page.js";

acceptLanguages();

/** @param {{ lang?: string, url: string }} data */
export async function prerender(data) {
  return await ssr(App, data);
}

/** @param {{ dehydratedState?: ReturnType<import("evikit").dehydrate> | undefined, i18n: ReturnType<import("evikit").useI18n> }} props */
function App({ dehydratedState, i18n }) {
  return h(
    Router,
    { dehydratedState, i18n },
    // h(Route, { component: HomePage, path: "/" }),
    // h(Route, { component: QuestionIdPage, path: "/question/:id" }),
    h(Route, { component: ErrorPage, default: true }),
  );
}

hydrate(App, "#app");
```

#### HTML entry point

Make an HTML template `index.html`, leaving placeholders for server-rendered content:

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="color-scheme" content="dark light" />
    <!--app-head-->
  </head>
  <body>
    <div id="app"><!--app-html--></div>
    <script prerender type="module" src="/src/app.js"></script>
  </body>
</html>
```

#### Development server

Setup Vite at `vite.config.js`:

```js
import preact from "@preact/preset-vite";
import { defineConfig } from "vite";
import { analyzer } from "vite-bundle-analyzer";

import { api } from "./src/api.js";

export default defineConfig({
  plugins: [
    analyzer({ analyzerMode: "static" }),
    {
      configureServer(server) {
        server.middlewares.use(api().onRequest);
      },
      name: "configure-server",
    },
    preact({ prerender: { enabled: true, renderTarget: "#app" } }),
  ],
});
```

The following commands should work now:

```sh
# Run dev server with hot reload for Preact and full restart on API change.
npx vite dev

# Build server and client for production.
npx tsc
npx vite build
```

You can add these commands (without npx) as `dev` and `build` scripts to your `package.json`.

_TODO_ Why does `npx vite build` return 'Unable to detect prerender entry script' sometimes? Re-running fixed it for me.

#### Production server

Add production server at `index.js`:

```js
import { listen, production } from "evikit/server";
import express from "express";
import { readFile } from "node:fs/promises";
import swaggerUi from "swagger-ui-express";

import { api } from "./src/api.js";
import { prerender } from "./src/app.js";

const app = express();
app.use(api().onRequest);
app.use("/openapi", swaggerUi.serve, swaggerUi.setup(api().openapi));
app.use("/", express.static("./dist", { index: false, redirect: false }));
app.use(
  production({
    html: await readFile("./dist/index.html", "utf-8"),
    prerender,
  }),
);
listen(app);
```

Configure port in `.env`:

```ini
ORIGIN=http://localhost:8000
PORT=8000
```

Run it:

```sh
npx cross-env NODE_ENV=production node --env-file=.env index.js
```

You can add this command (without npx) as `start` script to your `package.json`.

To see what your biggest dependencies are, analyze bundle size stats at http://localhost:8000/stats.html.

#### HTTPS origin

When you host your app on a domain and add a reverse proxy with HTTPS, such as Apache with [mod_md](https://httpd.apache.org/docs/2.4/mod/mod_md.html) enabled, change `ORIGIN` to your public address but keep your local `PORT` the same in `.env`:

```ini
ORIGIN=https://example.org
PORT=8000
```

### Application logic

#### Data fetching

We want a home page that lists all questions. It will consist of a JSON API server endpoint and an isomorphic route.

Declare API input and output types in `src/routes/api/types.js`:

```js
import { array, object, string } from "valibot";

import * as schema from "../../lib/db/schema.js";

export const HomeGet = {
  input: object({
    // `lang` comes from the accept-language header.
    // Falls back to "en" if not found in your acceptLanguages.
    lang: string(),
  }),
  outputs: {
    200: object({ Question: array(schema.Question) }),
  },
};
```

Code the API server endpoint in `src/routes/api/server.js`:

```js
import { getI18n } from "evikit";
import { endpoint, json } from "evikit/server";

import { db } from "../../lib/db/index.js";
import { HomeGet } from "./types.js";

export const HomeServer = {
  get: endpoint(HomeGet, async ({ input }) => {
    const i18n = await getI18n(input.lang);
    let Question = db.Question.select();
    if (!Question.length) {
      db.Question.upsert({
        createdAt: Date.now(),
        text: i18n.gettext("What's up?"),
      });
      Question = db.Question.select();
    }
    return json({ Question });
  }),
};
```

Uncomment the `HomeServer` import and `/api` route in `src/api.js`.

Render a Preact page route: `src/routes/page.js`:

```js
import { a, h1, li, ul, useApi, useI18n } from "evikit";
import { useTitle } from "hoofd/preact";

import { HomeGet } from "./api/types.js";

export function HomePage() {
  const { data } = useApi(HomeGet);
  const i18n = useI18n();
  useTitle(i18n.gettext("Questions"));

  return data
    ? ul(
        { class: "questions" },
        data.Question.map((question) =>
          li(
            { key: question.id },
            a(
              { href: `/question/${encodeURIComponent(question.id)}` },
              question.text,
            ),
          ),
        ),
      )
    : h1({ class: "loading" }, i18n.gettext("Loading..."));
}
```

Uncomment the `HomePage` import and `/` route in `src/app.js`.

#### Forms

Our question page will list one question with all its choices. It will give the user a form to vote for one of the existing choices. There will be another form for suggesting your own choice.

API input/output types: `src/routes/api/question/[id]/types.js`:

```js
import { array, object, string } from "valibot";

import * as schema from "../../../../lib/db/schema.js";

export const QuestionIdGet = {
  input: object({ id: string(), lang: string() }),
  outputs: {
    200: object({
      question: object({
        ...schema.Question.entries,
        Choice: array(schema.Choice),
      }),
    }),
    404: string(),
  },
};
```

Server endpoint: `src/routes/api/question/[id]/server.js`:

```js
import { getI18n } from "evikit";
import { endpoint, json, text } from "evikit/server";

import { db } from "../../../../lib/db/index.js";
import { QuestionIdGet } from "./types.js";

export const QuestionIdServer = {
  get: endpoint(QuestionIdGet, async ({ input }) => {
    const i18n = await getI18n(input.lang);
    const Question = db.Question.select({
      with: { Choice: true },
    });
    // It maps join results to objects, for example you can get:
    // Question[0]?.Choice[0]?.text
    const question = Question.find(({ id }) => id == input.id);
    if (!question) {
      return text(i18n.gettext("Question does not exist"), 404);
    }
    return json({ question });
  }),
};
```

Uncomment the `QuestionIdServer` import and `/question/:id` route in `src/api.js`.

Preact page route: `src/routes/question/[id]/page.js` (note that we're outside the `api/` directory now):

```js
import { h1, main, useApi, useEnhance, useI18n } from "evikit";
import { useTitle } from "hoofd/preact";
import { h } from "preact";

import { ChoicePost } from "../../api/choice/types.js";
import { QuestionIdGet } from "../../api/question/[id]/types.js";
import { VotePost } from "../../api/vote/types.js";
import { ErrorPage } from "../../error.js";

export function QuestionIdPage() {
  const { data, error } = useApi(QuestionIdGet);
  const votePost = useEnhance(VotePost);
  const suggestPost = useEnhance(ChoicePost);
  const i18n = useI18n();
  const title = data?.question.text || i18n.gettext("Loading...");
  useTitle(title);

  if (!data && error) {
    return h(ErrorPage, { error });
  }

  return main(
    h1({ class: "title" }, title),

    // VOTE_FORM: replace with form view from the "Vote for choice" section.

    // SUGGEST_FORM: replace with form view from the "Suggest own option" section.
  );
}
```

Uncomment the `QuestionIdPage` import and `/question/:id` route in `src/app.js`.

##### Suggest own option

Types: `src/routes/api/choice/types.js`:

```js
import { object, string } from "valibot";

export const ChoicePost = {
  input: object({
    lang: string(),
    questionId: string(),
    text: string(),
  }),
  outputs: { 302: string(), 400: string() },
};
```

Server endpoint: `src/routes/api/choice/server.js`:

```js
import { getI18n } from "evikit";
import { endpoint, redirect, text } from "evikit/server";
import { ok } from "node:assert/strict";

import { db } from "../../../lib/db/index.js";
import { ChoicePost } from "./types.js";

export const ChoiceServer = {
  post: endpoint(ChoicePost, async ({ input }) => {
    const i18n = await getI18n(input.lang);

    const Question = db.Question.select();
    const question = Question.find(({ id }) => id == input.questionId);
    if (!question) {
      return text(i18n.gettext("Question does not exist"), 400);
    }

    const [choice] = db.Choice.upsert({
      questionId: question.id,
      text: input.text,
      votes: 1,
    });
    ok(choice);
    // The returned choice has a generated id.
    // To replace an existing choice, don't omit the optional id.
    // To create multiple choices, pass multiple arguments to upsert.

    return redirect(`/question/${encodeURIComponent(question.id)}`, 302, {
      cookies: {
        // Note: just an example. Use cookies sparingly, e.g. for session id.
        ownVoteQuestionId: { value: choice.questionId },
      },
    });
  }),
};
```

Uncomment the `ChoiceServer` import and `/api/choice` route in `src/api.js`.

Code a form view in place of `SUGGEST_FORM` in `src/routes/question/[id]/page.js`:

```js
import classNames from "classnames";
import { button, form, h2, input, label, p } from "evikit";

// ...

form(
  { action: "/api/choice", method: "POST", onSubmit: suggestPost.onSubmit },
  input({ name: "questionId", type: "hidden", value: data?.question.id }),
  h2({ class: "heading" }, i18n.gettext("Own choice")),
  p(
    { class: "fieldset" },
    label({ for: "text" }, i18n.gettext("Text")),
    input({ id: "text", name: "text" }),
  ),
  button(
    { class: classNames("btn", suggestPost.busy && "btn-disabled") },
    i18n.gettext("Suggest"),
  ),
),
```

##### Vote for choice

Types: `src/routes/api/vote/types.js`:

```js
import { object, optional, string } from "valibot";

export const VotePost = {
  input: object({
    choiceId: string(),
    lang: string(),
    ownVoteQuestionId: optional(string()), // Coming from cookie.
  }),
  outputs: { 302: string(), 400: string() },
};
```

Server endpoint: `src/routes/api/vote/server.js`:

```js
import { getI18n } from "evikit";
import { endpoint, redirect, text } from "evikit/server";

import { db } from "../../../lib/db/index.js";
import { VotePost } from "./types.js";

export const VoteServer = {
  post: endpoint(VotePost, async ({ input }) => {
    const i18n = await getI18n(input.lang);

    const Choice = db.Choice.select();
    const choice = Choice.find(({ id }) => id == input.choiceId);
    if (!choice) {
      return text(i18n.gettext("You didn't select a choice"), 400);
    }

    if (choice.questionId == input.ownVoteQuestionId) {
      return text(i18n.gettext("Already voted"), 400);
    }

    db.Choice.update({
      set: { votes: choice.votes + 1 },
      where: { id: choice.id },
      // To update multiple choices, pass an array of ids to `where.id`.
    });
    // To delete a choice: `db.Choice.delete({ id: choice.id })`.
    // To delete multiple choices, pass an array of ids to `id`.

    return redirect(`/question/${encodeURIComponent(choice.questionId)}`, 302);
  }),
};
```

Uncomment the `VoteServer` import and `/api/vote` route in `src/api.js`.

Code a form view in place of `VOTE_FORM` in `src/routes/question/[id]/page.js`:

```js
import { li, ul } from "evikit";

// ...

form(
  { action: "/api/vote", method: "POST", onSubmit: votePost.onSubmit },
  ul(
    { class: "choices" },
    data?.question.Choice.map((choice) =>
      li(
        { key: choice.id },
        input({
          id: choice.id,
          name: "choiceId",
          type: "radio",
          value: choice.id,
        }),
        label(
          { for: choice.id },
          choice.text,
          ": ",
          i18n.ngettext("%1 vote", "%1 votes", choice.votes, choice.votes),
        ),
        label({ for: choice.id }, choice.text),
      ),
    ),
  ),
  button(
    { class: classNames("btn", votePost.busy && "btn-disabled") },
    i18n.gettext("Vote"),
  ),
),
```

##### File upload

Why not let each choice have an icon?

In `src/lib/db/schema.js`:

```js
import { instance } from "valibot";

// Other imports and Question schema...

export const Storage = object({
  content: instance(Uint8Array),
  id: pipe(string(), primary()),
});

export const Choice = object({
  iconStorageId: pipe(string(), references(Storage, "id")),
  // The rest of the Choice schema...
});
```

In `src/routes/api/choice/types.js`:

```js
import { file } from "valibot";
// Other imports...

export const ChoicePost = {
  input: object({
    icon: file(),
// The rest of the schema...
```

In `src/routes/api/choice/server.js`:

```js
import { fileTypeFromBuffer } from "file-type";
import { createHash } from "node:crypto";
import { parse, picklist } from "valibot";

// ...
if (!question) {
  return text(i18n.gettext("Question does not exist"), 400);
}

const buffer = Buffer.from(await input.icon.arrayBuffer());

// Fail if user sent something else than an image.
const fileType = await fileTypeFromBuffer(buffer);
ok(fileType);
const { ext } = fileType;
parse(picklist(["gif", "jpg", "png", "webp"]), ext);

// Don't save another image if hash already known.
const iconStorageId = `${createHash("sha256").update(buffer).digest("hex")}.${ext}`;
db.Storage.upsert({
  content: new Uint8Array(buffer.buffer),
  id: iconStorageId,
});

const [choice] = db.Choice.upsert({
  iconStorageId,
// ...
```

In `src/routes/api/question/[id]/types.js`:

```js
// ...
question: object({
  ...schema.Question.entries,
  Choice: array(
    object({
      ...schema.Choice.entries,
      iconUrl: string(),
    }),
  ),
}),
// ...
```

In `src/routes/api/question[id]/server.js`:

```js
import { fileTypeFromBuffer } from "file-type";
import { ok } from "node:assert/strict";

// ...

const Question = db.Question.select({
  with: { Choice: { with: { iconStorage: true } } },
});

// ...

return json({
  question: {
    ...question,
    Choice: await Promise.all(
      question.Choice.map(async (choice) => {
        ok(choice.iconStorage);
        const buffer = Buffer.from(choice.iconStorage.content.buffer);
        const fileType = await fileTypeFromBuffer(buffer);
        ok(fileType);
        return {
          ...choice,
          iconUrl: `data:${fileType.mime};base64,${buffer.toString("base64")}`,
        };
      }),
    ),
  },
});
```

In `src/routes/question/[id]/page.js`:

```js
import { img } from "evikit";
// ...

form(
  {
    action: "/api/choice",
    enctype: "multipart/form-data",
    method: "POST",
    onSubmit: suggestPost.onSubmit,
  },
  // ...
  p(
    { class: "fieldset" },
    label({ for: "icon" }, i18n.gettext("Icon")),
    input({ id: "icon", name: "icon", type: "file" }),
  ),
  // The rest of the /api/choice form...
),

form(
  { action: "/api/vote", method: "POST", onSubmit: votePost.onSubmit },
  // ...
  input({
    id: choice.id,
    name: "choiceId",
    type: "radio",
    value: choice.id,
  }),
  label(
    { for: choice.id },
    img({ alt: "", height: "24", src: choice.iconUrl, width: "24" }),
// ...
```

### Translation

For the Ukrainian language, which has the `uk` [two-letter language code](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes), create an empty file `po/uk.po`. It will use the [gettext](https://www.gnu.org/software/gettext/) format, which is well-known to professional translators.

Install the `gettext` package from your distribution repositories or from [here for Windows](https://mlocati.github.io/articles/gettext-iconv-windows.html).

Extract translatable strings from code:

```sh
npx evikit-extract
```

You can add this command (without npx) as an `extract` script in your `package.json`. On every launch, it will add newly found strings to every translation file, as well as clean up the ones now unused.

Now, translate `po/uk.po` using [Poedit](https://poedit.net/) or your favorite gettext-compatible translation editor. After releasing your source code, you can crowdsource translations at [Codeberg Translate](https://translate.codeberg.org/).

After `po/uk.po` is sufficiently ready (80% or more?), change `src/lib/index.js` like this:

```js
import acceptLanguage from "accept-language";

export function acceptLanguages() {
  acceptLanguage.languages(["en", "uk"]);
}
```

### Styling

EviKit is decoupled from styling, it's up to you how to design your app. I've had good experience with [daisyUI](https://daisyui.com/). Let's see how to set it up.

```sh
npm i -D @tailwindcss/vite daisyui
```

Add the Tailwind plugin at `vite.config.js`:

```js
import tailwindcss from "@tailwindcss/vite";
// ...

export default defineConfig({
  // ...
  plugins: [
    // ...
    tailwindcss(),
  ],
});
```

Import Tailwind and daisyUI at `index.css`:

```css
@import "tailwindcss";
@plugin "daisyui";
```

Change your `index.html` like this:

```html
...
<html ... class="font-sans">
  <head>
    ...
    <link rel="stylesheet" href="/index.css" />
    <!--app-head-->
  </head>
  ...
</html>
```

Style your app however you like.

### Background worker

You can run something repeatedly in main process, not in listeners for each CPU core. In development, it will restart whenever you edit api routes.

Add to `src/lib/db/index.js`:

```js
import { daemon } from "evikit/server";

// ...

const unique = crypto.randomUUID();
daemon(() => {
  const interval = setInterval(() => {
    console.log({ now: Date.now(), unique });
  }, 1000);
  return () => clearInterval(interval);
});
```

### Testing

A practical integration test for EviKit itself is [Lanquiz](https://codeberg.org/nykula/lanquiz), an app to host quizzes in LAN from a laptop, which is built on top of EviKit.

_TODO_ Write testing examples using [jsdom](https://github.com/jsdom/jsdom) and [node:test](https://nodejs.org/api/test.html).

_TODO_ Explain how I test EviKit locally.

### Linting

To automatically rearrange my whitespace and properties so that I don't have to, and to find potential mistakes, I use [ESLint](https://eslint.org/) with [Perfectionist](https://github.com/azat-io/eslint-plugin-perfectionist) plugin, [Prettier](https://github.com/prettier/prettier) and [typescript-eslint](https://typescript-eslint.io/):

```sh
npm i -D @eslint/compat @eslint/js eslint eslint-plugin-perfectionist globals typescript-eslint
npm i -D eslint-config-prettier prettier
```

My `eslint.config.js`:

```js
import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js";
import prettier from "eslint-config-prettier/flat";
import perfectionist from "eslint-plugin-perfectionist";
import { defineConfig } from "eslint/config";
import globals from "globals";
import { fileURLToPath } from "node:url";
import ts from "typescript-eslint";

export default defineConfig(
  includeIgnoreFile(fileURLToPath(new URL("./.gitignore", import.meta.url))),
  js.configs.recommended,
  ...ts.configs.strict,
  ...ts.configs.stylistic,
  perfectionist.configs["recommended-natural"],
  prettier,
  {
    languageOptions: {
      globals: { ...globals.browser, ...globals.node },
    },
  },
);
```

Formatting command:

```sh
npx prettier --write .
npx eslint --fix .
```

Just check:

```sh
npx prettier --check .
npx eslint .
```

You can add these commands (without npx) as `format` and `lint` scripts in your `package.json`.

## License

SPDX-License-Identifier: MIT
