evikit 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +781 -0
- package/bin/evikit-extract.js +36 -0
- package/db.d.ts +242 -0
- package/db.js +128 -0
- package/eslint.config.js +22 -0
- package/html-element.d.ts +8 -0
- package/html-element.js +12 -0
- package/html.js +33 -0
- package/i18n.js +59 -0
- package/index.d.ts +116 -0
- package/index.js +208 -0
- package/jsdom-cookie.js +5 -0
- package/package.json +53 -0
- package/query.js +231 -0
- package/server-generic.d.ts +95 -0
- package/server.d.ts +114 -0
- package/server.js +429 -0
- package/sqlite.d.ts +8 -0
- package/sqlite.js +475 -0
- package/tsconfig.json +30 -0
- package/use-api.d.ts +12 -0
- package/use-api.js +46 -0
- package/use-enhance.d.ts +19 -0
- package/use-enhance.js +99 -0
- package/use-status.js +18 -0
package/README.md
ADDED
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
# EviKit
|
|
2
|
+
|
|
3
|
+
[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.
|
|
4
|
+
|
|
5
|
+
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:
|
|
6
|
+
|
|
7
|
+
- For each `/path`, fetch `/api/path` and SSR the page
|
|
8
|
+
- Send urlencoded forms and refresh `/api/path` without a full page reload
|
|
9
|
+
- Validate API input and output against your schemas
|
|
10
|
+
- Input is: cookies + url params + body (each overrides previous if key exists)
|
|
11
|
+
- 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
|
|
12
|
+
- Keep fetched data between link navigations
|
|
13
|
+
- Show the language chosen by the user in browser settings
|
|
14
|
+
- Have SEO meta descriptions
|
|
15
|
+
- Generate API documentation with a Swagger overview
|
|
16
|
+
- _TODO_ Multipart forms
|
|
17
|
+
|
|
18
|
+
_TODO_ Add more simple language docs near code, don't overwhelm readers.
|
|
19
|
+
|
|
20
|
+
## Documentation
|
|
21
|
+
|
|
22
|
+
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.
|
|
23
|
+
|
|
24
|
+
Our language will be standard modern JavaScript as supported by Node.js directly. This means no JSX or other extensions, except node_modules resolution.
|
|
25
|
+
|
|
26
|
+
- [Create project](#create-project)
|
|
27
|
+
- [Database schema](#database-schema)
|
|
28
|
+
- [Routing](#router)
|
|
29
|
+
- [Language negotiation](#language-negotiation)
|
|
30
|
+
- [Error page](#error-page)
|
|
31
|
+
- [API router](#api-router)
|
|
32
|
+
- [Page router](#page-router)
|
|
33
|
+
- [HTML entry point](#html-entry-point)
|
|
34
|
+
- [Development server](#development-server)
|
|
35
|
+
- [Production server](#production-server)
|
|
36
|
+
- [HTTPS origin](#https-origin)
|
|
37
|
+
- [Application logic](#application-logic)
|
|
38
|
+
- [Data fetching](#data-fetching)
|
|
39
|
+
- [Forms](#forms)
|
|
40
|
+
- [Translation](#translation)
|
|
41
|
+
- [Styling](#styling)
|
|
42
|
+
- _TODO_ [Testing](#testing)
|
|
43
|
+
- [Linting](#linting)
|
|
44
|
+
|
|
45
|
+
### Create project
|
|
46
|
+
|
|
47
|
+
First, install dependencies:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
git init mysite
|
|
51
|
+
cd mysite
|
|
52
|
+
npm init -y
|
|
53
|
+
|
|
54
|
+
# Routing and validation.
|
|
55
|
+
npm i accept-language classnames express evikit hoofd preact preact-iso valibot
|
|
56
|
+
npm i -D cross-env vite vite-bundle-analyzer
|
|
57
|
+
|
|
58
|
+
# API documentation.
|
|
59
|
+
npm i swagger-ui-express
|
|
60
|
+
|
|
61
|
+
# Type safety.
|
|
62
|
+
npm i -D @types/express @types/node @types/swagger-ui-express typescript
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Second, configure strict JSDoc type checking using TypeScript at `tsconfig.json`:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
npx tsc --init --outDir dist --allowJs --checkJs --noEmit --esModuleInterop
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Database schema
|
|
72
|
+
|
|
73
|
+
Let's start by modelling a relational database schema. We have a question with multiple text choice options. Create `src/lib/db/schema.js`:
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
import { primary, references } from "evikit/db";
|
|
77
|
+
import { number, integer, object, optional, pipe, string } from "valibot";
|
|
78
|
+
|
|
79
|
+
export const Question = object({
|
|
80
|
+
createdAt: pipe(number(), integer()),
|
|
81
|
+
id: pipe(optional(string(), () => crypto.randomUUID()), primary()),
|
|
82
|
+
text: string()
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
export const Choice = object({
|
|
86
|
+
id: pipe(optional(string(), () => crypto.randomUUID()), primary()),
|
|
87
|
+
questionId: pipe(string(), references(Question, "id"))
|
|
88
|
+
text: string()
|
|
89
|
+
votes: pipe(number(), integer())
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Export the database at `src/lib/db/index.js`:
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
import { sqlite } from "evikit/sqlite";
|
|
97
|
+
|
|
98
|
+
import * as schema from "./schema.js";
|
|
99
|
+
|
|
100
|
+
export const db = sqlite("data/local.db", { schema });
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- See ChoiceServer below for an example of `upsert` returning a created row.
|
|
104
|
+
- See QuestionIdServer below for an example of `select` with a join.
|
|
105
|
+
- See VoteServer below for an example of `update` and `delete`.
|
|
106
|
+
|
|
107
|
+
### Routing
|
|
108
|
+
|
|
109
|
+
#### Language negotiation
|
|
110
|
+
|
|
111
|
+
Configure languages you're going to accept at `src/lib/index.js`:
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
import acceptLanguage from "accept-language";
|
|
115
|
+
|
|
116
|
+
export function acceptLanguages() {
|
|
117
|
+
acceptLanguage.languages(["en", "uk"]);
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
#### Error page
|
|
122
|
+
|
|
123
|
+
Code an error page at `src/routes/error.js`:
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
import { a, h1, main, p, useI18n, useStatus } from "evikit";
|
|
127
|
+
import { useMeta, useTitle } from "hoofd/preact";
|
|
128
|
+
|
|
129
|
+
/** @param {{ children?: import("preact").ComponentChildren, error?: unknown }} props */
|
|
130
|
+
export function ErrorPage({ children, error }) {
|
|
131
|
+
const i18n = useI18n();
|
|
132
|
+
const status = useStatus(error);
|
|
133
|
+
useMeta({ content: "noindex", name: "robots" });
|
|
134
|
+
useTitle(status);
|
|
135
|
+
|
|
136
|
+
return main(
|
|
137
|
+
p({ class: "nav" }, a({ href: "/" }, i18n.gettext("Questions"))),
|
|
138
|
+
h1({ class: "title" }, status),
|
|
139
|
+
children,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
#### API router
|
|
145
|
+
|
|
146
|
+
Add an API router at `src/api.js` (uncomment the routes one by one when we add them later):
|
|
147
|
+
|
|
148
|
+
```js
|
|
149
|
+
import { router } from "evikit/server";
|
|
150
|
+
|
|
151
|
+
import pkg from "../package.json" with { type: "json" };
|
|
152
|
+
import { acceptLanguages } from "./lib/index.js";
|
|
153
|
+
// import { HomeServer } from "./routes/api/server.js";
|
|
154
|
+
// import { ChoiceServer } from "./routes/api/choice/server.js";
|
|
155
|
+
// import { QuestionIdServer } from "./routes/api/question/[id]/server.js";
|
|
156
|
+
// import { VoteServer } from "./routes/api/vote/server.js";
|
|
157
|
+
|
|
158
|
+
acceptLanguages();
|
|
159
|
+
|
|
160
|
+
export function api() {
|
|
161
|
+
return router(
|
|
162
|
+
{ pkg },
|
|
163
|
+
// route("/api", HomeServer),
|
|
164
|
+
// route("/api/choice", ChoiceServer),
|
|
165
|
+
// route("/api/question/:id", QuestionIdServer),
|
|
166
|
+
// route("/api/vote", VoteServer)
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
#### Page router
|
|
172
|
+
|
|
173
|
+
Add a Preact router at `src/app.js` (uncomment the routes one-by-one when we add them later):
|
|
174
|
+
|
|
175
|
+
```js
|
|
176
|
+
import { hydrate, Router, ssr } from "evikit";
|
|
177
|
+
import { h } from "preact";
|
|
178
|
+
import { Route } from "preact-iso";
|
|
179
|
+
|
|
180
|
+
import { acceptLanguages } from "./lib/index.js";
|
|
181
|
+
import { ErrorPage } from "./routes/error.js";
|
|
182
|
+
// import { HomePage } from "./routes/page.js";
|
|
183
|
+
// import { QuestionIdPage } from "./routes/question/page.js";
|
|
184
|
+
|
|
185
|
+
acceptLanguages();
|
|
186
|
+
|
|
187
|
+
/** @param {{ dehydratedState?: ReturnType<import("evikit").dehydrate> | undefined, i18n?: ReturnType<import("evikit").useI18n> | undefined }} props */
|
|
188
|
+
function App({ dehydratedState, i18n }) {
|
|
189
|
+
return h(
|
|
190
|
+
Router,
|
|
191
|
+
{ dehydratedState, i18n },
|
|
192
|
+
// h(Route, { component: HomePage, path: "/" }),
|
|
193
|
+
// h(Route, { component: QuestionIdPage, path: "/question/:id" }),
|
|
194
|
+
h(Route, { component: ErrorPage, default: true }),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** @param {{ lang?: string, url: string }} data */
|
|
199
|
+
export async function prerender(data) {
|
|
200
|
+
return await ssr(App, data);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
hydrate(App, "#app");
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
#### HTML entry point
|
|
207
|
+
|
|
208
|
+
Make an HTML template `index.html`, leaving placeholders for server-rendered content:
|
|
209
|
+
|
|
210
|
+
```html
|
|
211
|
+
<!doctype html>
|
|
212
|
+
<html lang="en">
|
|
213
|
+
<head>
|
|
214
|
+
<meta charset="utf-8" />
|
|
215
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
216
|
+
<meta name="color-scheme" content="dark light" />
|
|
217
|
+
<!--app-head-->
|
|
218
|
+
</head>
|
|
219
|
+
<body>
|
|
220
|
+
<div id="app"><!--app-html--></div>
|
|
221
|
+
<script prerender type="module" src="/src/app.js"></script>
|
|
222
|
+
</body>
|
|
223
|
+
</html>
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
#### Development server
|
|
227
|
+
|
|
228
|
+
Setup Vite at `vite.config.js`:
|
|
229
|
+
|
|
230
|
+
```js
|
|
231
|
+
import preact from "@preact/preset-vite";
|
|
232
|
+
import { defineConfig } from "vite";
|
|
233
|
+
import { analyzer } from "vite-bundle-analyzer";
|
|
234
|
+
|
|
235
|
+
import { api } from "./src/api.js";
|
|
236
|
+
|
|
237
|
+
export default defineConfig({
|
|
238
|
+
plugins: [
|
|
239
|
+
analyzer({ analyzerMode: "static" }),
|
|
240
|
+
{
|
|
241
|
+
configureServer(server) {
|
|
242
|
+
server.middlewares.use(api().onRequest);
|
|
243
|
+
},
|
|
244
|
+
name: "configure-server",
|
|
245
|
+
},
|
|
246
|
+
preact({ prerender: { enabled: true, renderTarget: "#app" } }),
|
|
247
|
+
],
|
|
248
|
+
});
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
The following commands should work now:
|
|
252
|
+
|
|
253
|
+
```sh
|
|
254
|
+
# Run dev server with hot reload for Preact and full restart on API change.
|
|
255
|
+
npx vite dev
|
|
256
|
+
|
|
257
|
+
# Build server and client for production.
|
|
258
|
+
npx tsc
|
|
259
|
+
npx vite build
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
You can add these commands (without npx) as `dev` and `build` scripts to your `package.json`.
|
|
263
|
+
|
|
264
|
+
#### Production server
|
|
265
|
+
|
|
266
|
+
Add production server at `index.js`:
|
|
267
|
+
|
|
268
|
+
```js
|
|
269
|
+
import { listen, production } from "evikit/server";
|
|
270
|
+
import express from "express";
|
|
271
|
+
import { readFile } from "node:fs/promises";
|
|
272
|
+
import swaggerUi from "swagger-ui-express";
|
|
273
|
+
|
|
274
|
+
import { api } from "./src/api.js";
|
|
275
|
+
import { prerender } from "./src/app.js";
|
|
276
|
+
|
|
277
|
+
const app = express();
|
|
278
|
+
app.use(api().onRequest);
|
|
279
|
+
app.use("/openapi", swaggerUi.serve, swaggerUi.setup(api().openapi));
|
|
280
|
+
app.use("/", express.static("./dist", { index: false, redirect: false }));
|
|
281
|
+
app.use(
|
|
282
|
+
production({
|
|
283
|
+
html: await readFile("./dist/index.html", "utf-8"),
|
|
284
|
+
prerender,
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
listen(app);
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Configure port in `.env`:
|
|
291
|
+
|
|
292
|
+
```ini
|
|
293
|
+
ORIGIN=http://localhost:8000
|
|
294
|
+
PORT=8000
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Run it:
|
|
298
|
+
|
|
299
|
+
```sh
|
|
300
|
+
npx cross-env NODE_ENV=production node --env-file=.env index.js
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
You can add this command (without npx) as `start` script to your `package.json`.
|
|
304
|
+
|
|
305
|
+
To see what your biggest dependencies are, analyze bundle size stats at http://localhost:8000/stats.html.
|
|
306
|
+
|
|
307
|
+
#### HTTPS origin
|
|
308
|
+
|
|
309
|
+
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`:
|
|
310
|
+
|
|
311
|
+
```ini
|
|
312
|
+
ORIGIN=https://example.org
|
|
313
|
+
PORT=8000
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Application logic
|
|
317
|
+
|
|
318
|
+
#### Data fetching
|
|
319
|
+
|
|
320
|
+
We want a home page that lists all questions. It will consist of a JSON API server endpoint and an isomorphic route.
|
|
321
|
+
|
|
322
|
+
Declare API input and output types in `src/routes/api/types.js`:
|
|
323
|
+
|
|
324
|
+
```js
|
|
325
|
+
import { array, object, string } from "valibot";
|
|
326
|
+
|
|
327
|
+
import * as schema from "../../lib/db/schema.js";
|
|
328
|
+
|
|
329
|
+
export const HomeGet = {
|
|
330
|
+
input: object({
|
|
331
|
+
// `lang` comes from the accept-language header.
|
|
332
|
+
// Falls back to "en" if not found in your acceptLanguages.
|
|
333
|
+
lang: string(),
|
|
334
|
+
}),
|
|
335
|
+
outputs: {
|
|
336
|
+
200: object({ Question: array(schema.Question) }),
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Code the API server endpoint in `src/routes/api/server.js`:
|
|
342
|
+
|
|
343
|
+
```js
|
|
344
|
+
import { getI18n } from "evikit";
|
|
345
|
+
import { endpoint, json } from "evikit/server";
|
|
346
|
+
|
|
347
|
+
import { db } from "../../lib/db/index.js";
|
|
348
|
+
import { HomeGet } from "./types.js";
|
|
349
|
+
|
|
350
|
+
export const HomeServer = {
|
|
351
|
+
get: endpoint(HomeGet, async ({ input }) => {
|
|
352
|
+
const i18n = await getI18n(input.lang);
|
|
353
|
+
let Question = db.Question.select();
|
|
354
|
+
if (!Question.length) {
|
|
355
|
+
db.Question.upsert({
|
|
356
|
+
createdAt: Date.now(),
|
|
357
|
+
text: i18n.gettext("What's up?"),
|
|
358
|
+
});
|
|
359
|
+
Question = db.Question.select();
|
|
360
|
+
}
|
|
361
|
+
return json({ Question });
|
|
362
|
+
}),
|
|
363
|
+
};
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Uncomment the `/api` route in `src/api.js`.
|
|
367
|
+
|
|
368
|
+
Render a Preact page route: `src/routes/page.js`:
|
|
369
|
+
|
|
370
|
+
```js
|
|
371
|
+
import { a, h1, li, ul, useApi, useI18n } from "evikit";
|
|
372
|
+
import { useTitle } from "hoofd/preact";
|
|
373
|
+
|
|
374
|
+
import { HomeGet } from "./api/types.js";
|
|
375
|
+
|
|
376
|
+
export function HomePage() {
|
|
377
|
+
const { data } = useApi(HomeGet);
|
|
378
|
+
const i18n = useI18n();
|
|
379
|
+
useTitle(i18n.gettext("Questions"));
|
|
380
|
+
|
|
381
|
+
return data
|
|
382
|
+
? ul(
|
|
383
|
+
{ class: "questions" },
|
|
384
|
+
questions.map((question) =>
|
|
385
|
+
li(
|
|
386
|
+
{ key: question.id },
|
|
387
|
+
a(
|
|
388
|
+
{ href: `/polls/${encodeURIComponent(question.id)}` },
|
|
389
|
+
question.text,
|
|
390
|
+
),
|
|
391
|
+
),
|
|
392
|
+
),
|
|
393
|
+
)
|
|
394
|
+
: h1({ class: "loading" }, i18n.gettext("Loading..."));
|
|
395
|
+
}
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Uncomment the `HomePage` route in `src/app.js`.
|
|
399
|
+
|
|
400
|
+
#### Forms
|
|
401
|
+
|
|
402
|
+
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.
|
|
403
|
+
|
|
404
|
+
API input/output types: `src/routes/api/question/[id]/types.js`:
|
|
405
|
+
|
|
406
|
+
```js
|
|
407
|
+
import { array, object, string } from "valibot";
|
|
408
|
+
|
|
409
|
+
import * as schema from "../../../../lib/db/schema.js";
|
|
410
|
+
|
|
411
|
+
export const QuestionIdGet = {
|
|
412
|
+
input: object({ id: string(), lang: string() }),
|
|
413
|
+
outputs: {
|
|
414
|
+
200: object({
|
|
415
|
+
question: object({
|
|
416
|
+
...schema.Question.entries,
|
|
417
|
+
Choice: array(schema.Choice),
|
|
418
|
+
}),
|
|
419
|
+
}),
|
|
420
|
+
404: string(),
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
Server endpoint: `src/routes/api/question/[id]/server.js`:
|
|
426
|
+
|
|
427
|
+
```js
|
|
428
|
+
import { getI18n } from "evikit";
|
|
429
|
+
import { endpoint, json, text } from "evikit/server";
|
|
430
|
+
import { string } from "valibot";
|
|
431
|
+
|
|
432
|
+
import { db } from "../../../../lib/db/index.js";
|
|
433
|
+
import { QuestionIdGet } from "./types.js";
|
|
434
|
+
|
|
435
|
+
export const QuestionIdServer = {
|
|
436
|
+
get: endpoint(QuestionIdGet, async ({ input }) => {
|
|
437
|
+
const i18n = await getI18n(input.lang);
|
|
438
|
+
const Question = db.Question.select({
|
|
439
|
+
with: { Choice: true },
|
|
440
|
+
});
|
|
441
|
+
// It maps join results to objects, for example you can get:
|
|
442
|
+
// Question[0]?.Choice[0]?.text
|
|
443
|
+
const question = Question.find(({ id }) => id == input.id);
|
|
444
|
+
if (!question) {
|
|
445
|
+
return text(i18n.gettext("Question does not exist"), 404);
|
|
446
|
+
}
|
|
447
|
+
return json({ question });
|
|
448
|
+
}),
|
|
449
|
+
};
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
Uncomment the `/question/:id` route in `src/api.js`.
|
|
453
|
+
|
|
454
|
+
Preact page route: `src/routes/question/[id]/page.js`:
|
|
455
|
+
|
|
456
|
+
```js
|
|
457
|
+
import classNames from "classnames";
|
|
458
|
+
import { h1, main, useApi, useEnhance, useI18n } from "evikit";
|
|
459
|
+
import { useTitle } from "hoofd/preact";
|
|
460
|
+
import { h } from "preact";
|
|
461
|
+
|
|
462
|
+
import { ChoicePost } from "../../api/choice/types.js";
|
|
463
|
+
import { QuestionIdGet } from "../../api/question/[id]/types.js";
|
|
464
|
+
import { VotePost } from "../../api/vote/types.js";
|
|
465
|
+
|
|
466
|
+
export function QuestionIdPage() {
|
|
467
|
+
const { data, error } = useApi(QuestionIdGet);
|
|
468
|
+
const votePost = useEnhance(VotePost);
|
|
469
|
+
const suggestPost = useEnhance(ChoicePost);
|
|
470
|
+
const i18n = useI18n();
|
|
471
|
+
const title = data?.question.text || i18n.gettext("Loading...");
|
|
472
|
+
useTitle(title);
|
|
473
|
+
|
|
474
|
+
if (!data && error) {
|
|
475
|
+
return h(ErrorPage, { error });
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return main(
|
|
479
|
+
h1({ class: "title" }, title),
|
|
480
|
+
|
|
481
|
+
// VOTE_FORM: replace with form view from the "Vote for choice" section.
|
|
482
|
+
|
|
483
|
+
// SUGGEST_FORM: replace with form view from the "Suggest own option" section.
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
Uncomment the `QuestionIdPage` route in `src/app.js`.
|
|
489
|
+
|
|
490
|
+
##### Suggest own option
|
|
491
|
+
|
|
492
|
+
Types: `src/routes/api/choice/types.js`:
|
|
493
|
+
|
|
494
|
+
```js
|
|
495
|
+
import { object, string } from "valibot";
|
|
496
|
+
|
|
497
|
+
export const ChoicePost = {
|
|
498
|
+
input: object({
|
|
499
|
+
lang: string(),
|
|
500
|
+
questionId: string(),
|
|
501
|
+
text: string()
|
|
502
|
+
})
|
|
503
|
+
outputs: { 302: string(), 400: string() }
|
|
504
|
+
};
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
Server endpoint: `src/routes/api/choice/server.js`:
|
|
508
|
+
|
|
509
|
+
```js
|
|
510
|
+
import { getI18n } from "evikit";
|
|
511
|
+
import { endpoint, redirect, text } from "evikit/server";
|
|
512
|
+
import { ok } from "node:assert/strict";
|
|
513
|
+
import { object, string } from "valibot";
|
|
514
|
+
|
|
515
|
+
import { db } from " ../../../lib/db/index.js";
|
|
516
|
+
import { ChoicePost } from "./types.js";
|
|
517
|
+
|
|
518
|
+
export const ChoiceServer = {
|
|
519
|
+
post: endpoint(ChoicePost, async ({ input }) => {
|
|
520
|
+
const i18n = await getI18n(input.lang);
|
|
521
|
+
|
|
522
|
+
const Question = db.Question.select();
|
|
523
|
+
const question = Question.find(({ id }) => id == input.questionId);
|
|
524
|
+
if (!question) {
|
|
525
|
+
return text(i18n.gettext("Question does not exist"), 400);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const [choice] = db.Choice.upsert({
|
|
529
|
+
questionId: question.id,
|
|
530
|
+
text: input.text,
|
|
531
|
+
votes: 1,
|
|
532
|
+
});
|
|
533
|
+
ok(choice);
|
|
534
|
+
// The returned choice has a generated id.
|
|
535
|
+
// To replace an existing choice, don't omit the optional id.
|
|
536
|
+
// To create multiple choices, pass multiple arguments to upsert.
|
|
537
|
+
|
|
538
|
+
return redirect(`/question/${encodeURIComponent(question.id)}`, 302, {
|
|
539
|
+
cookies: {
|
|
540
|
+
// Note: just an example. Use cookies sparingly, e.g. for session id.
|
|
541
|
+
ownSuggestionId: choice.id,
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
}),
|
|
545
|
+
};
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
Uncomment the `/api/choice` route in `src/api.js`.
|
|
549
|
+
|
|
550
|
+
Code a form view in place of `SUGGEST_FORM` in `src/routes/question/[id]/page.js`:
|
|
551
|
+
|
|
552
|
+
```js
|
|
553
|
+
import { button, form, h2, input, label } from "evikit";
|
|
554
|
+
|
|
555
|
+
// ...
|
|
556
|
+
|
|
557
|
+
form(
|
|
558
|
+
{ action: "/api/choice", method: "POST", submit: suggestPost.onSubmit },
|
|
559
|
+
input({ name: "questionId", type: "hidden", value: data?.question.id }),
|
|
560
|
+
h2({ class: "heading" }, i18n.gettext("Own choice")),
|
|
561
|
+
p(
|
|
562
|
+
{ class: "fieldset" },
|
|
563
|
+
label({ for: "text" }, i18n.gettext("Text")),
|
|
564
|
+
input({ id: "text", name: "text" }),
|
|
565
|
+
),
|
|
566
|
+
button(
|
|
567
|
+
{ class: classNames("btn", suggestPost.busy && "btn-disabled") },
|
|
568
|
+
i18n.gettext("Suggest"),
|
|
569
|
+
),
|
|
570
|
+
);
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
##### Vote for choice
|
|
574
|
+
|
|
575
|
+
Types: `src/routes/api/vote/types.js`:
|
|
576
|
+
|
|
577
|
+
```js
|
|
578
|
+
import { object, optional, string } from "valibot";
|
|
579
|
+
|
|
580
|
+
export const VotePost = {
|
|
581
|
+
input: object({
|
|
582
|
+
choiceId: string(),
|
|
583
|
+
lang: string(),
|
|
584
|
+
ownSuggestionId: optional(string()), // Coming from cookie.
|
|
585
|
+
}),
|
|
586
|
+
outputs: { 302: string(), 400: string() },
|
|
587
|
+
};
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
Server endpoint: `src/routes/api/vote/server.js`:
|
|
591
|
+
|
|
592
|
+
```js
|
|
593
|
+
import { getI18n } from "evikit";
|
|
594
|
+
import { endpoint, redirect, text } from "evikit/server";
|
|
595
|
+
import { string } from "valibot";
|
|
596
|
+
|
|
597
|
+
import { db } from "../../../lib/db/index.js";
|
|
598
|
+
import { VotePost } from "./types.js";
|
|
599
|
+
|
|
600
|
+
export const VoteServer = {
|
|
601
|
+
post: endpoint(VotePost, async ({ input }) => {
|
|
602
|
+
const i18n = await getI18n(input.lang);
|
|
603
|
+
|
|
604
|
+
if (input.choiceId == input.ownSuggestionId) {
|
|
605
|
+
return text(
|
|
606
|
+
i18n.gettext("You suggested this choice, it already has your vote"),
|
|
607
|
+
400,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const Choice = db.Choice.select();
|
|
612
|
+
const choice = Choice.find(({ id }) => id == input.choiceId);
|
|
613
|
+
if (!choice) {
|
|
614
|
+
return text(i18n.gettext("You didn't select a choice"), 400);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
db.Choice.update({
|
|
618
|
+
set: { votes: choice.votes + 1 },
|
|
619
|
+
where: { id: choice.id },
|
|
620
|
+
// To update multiple choices, pass an array of ids to `where.id`.
|
|
621
|
+
});
|
|
622
|
+
// To delete a choice: `db.Choice.delete({ id: choice.id })`.
|
|
623
|
+
// To delete multiple choices, pass an array of ids to `id`.
|
|
624
|
+
|
|
625
|
+
return redirect(`/question/${encodeURIComponent(question.id)}`, 302);
|
|
626
|
+
}),
|
|
627
|
+
};
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
Uncomment the `/api/vote` route in `src/api.js`.
|
|
631
|
+
|
|
632
|
+
Code a form view in place of `VOTE_FORM` in `src/routes/question/[id]/page.js`:
|
|
633
|
+
|
|
634
|
+
```js
|
|
635
|
+
import { button, form, input, label, li, ul } from "evikit";
|
|
636
|
+
|
|
637
|
+
// ...
|
|
638
|
+
|
|
639
|
+
form(
|
|
640
|
+
{ action: "/api/vote", method: "POST", onSubmit: votePost.onSubmit },
|
|
641
|
+
ul(
|
|
642
|
+
{ class: "choices" },
|
|
643
|
+
data?.question.Choice.map((choice) =>
|
|
644
|
+
li(
|
|
645
|
+
{ key: choice.id },
|
|
646
|
+
input({ id: choice.id, name: "choiceId", type: "radio" }),
|
|
647
|
+
label({ for: choice.id }, choice.text),
|
|
648
|
+
),
|
|
649
|
+
),
|
|
650
|
+
),
|
|
651
|
+
button(
|
|
652
|
+
{ class: classNames("btn", votePost.busy && "btn-disabled") },
|
|
653
|
+
i18n.gettext("Vote"),
|
|
654
|
+
),
|
|
655
|
+
);
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
### Translation
|
|
659
|
+
|
|
660
|
+
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.
|
|
661
|
+
|
|
662
|
+
Extract translatable strings from code:
|
|
663
|
+
|
|
664
|
+
```sh
|
|
665
|
+
npx evikit-extract
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
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.
|
|
669
|
+
|
|
670
|
+
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/).
|
|
671
|
+
|
|
672
|
+
After `po/uk.po` is sufficiently ready (80% or more?), change `src/lib/index.js` like this:
|
|
673
|
+
|
|
674
|
+
```js
|
|
675
|
+
import acceptLanguage from "accept-language";
|
|
676
|
+
|
|
677
|
+
export function acceptLanguages() {
|
|
678
|
+
acceptLanguage.languages(["en", "uk"]);
|
|
679
|
+
}
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
### Styling
|
|
683
|
+
|
|
684
|
+
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.
|
|
685
|
+
|
|
686
|
+
```sh
|
|
687
|
+
npm i -D @tailwindcss/vite daisyui
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
Add the Tailwind plugin at `vite.config.js`:
|
|
691
|
+
|
|
692
|
+
```js
|
|
693
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
694
|
+
// ...
|
|
695
|
+
|
|
696
|
+
export default defineConfig({
|
|
697
|
+
// ...
|
|
698
|
+
plugins: [
|
|
699
|
+
// ...
|
|
700
|
+
tailwindcss(),
|
|
701
|
+
],
|
|
702
|
+
});
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
Import Tailwind and daisyUI at `index.css`:
|
|
706
|
+
|
|
707
|
+
```css
|
|
708
|
+
@import "tailwindcss";
|
|
709
|
+
@plugin "daisyui";
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
Change your `index.html` like this:
|
|
713
|
+
|
|
714
|
+
```html
|
|
715
|
+
...
|
|
716
|
+
<html ... class="font-sans">
|
|
717
|
+
<head>
|
|
718
|
+
...
|
|
719
|
+
<link rel="stylesheet" href="/index.css" />
|
|
720
|
+
<!--app-head-->
|
|
721
|
+
</head>
|
|
722
|
+
...
|
|
723
|
+
</html>
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
Style your app however you like.
|
|
727
|
+
|
|
728
|
+
### Testing
|
|
729
|
+
|
|
730
|
+
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.
|
|
731
|
+
|
|
732
|
+
_TODO_ Write testing examples using [jsdom](https://github.com/jsdom/jsdom) and [node:test](https://nodejs.org/api/test.html).
|
|
733
|
+
|
|
734
|
+
_TODO_ Explain how I test EviKit locally.
|
|
735
|
+
|
|
736
|
+
### Linting
|
|
737
|
+
|
|
738
|
+
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/):
|
|
739
|
+
|
|
740
|
+
```sh
|
|
741
|
+
npm i -D @eslint/compat @eslint/js eslint eslint-plugin-perfectionist globals typescript-eslint
|
|
742
|
+
npm i -D eslint-config-prettier prettier
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
My `eslint.config.js`:
|
|
746
|
+
|
|
747
|
+
```js
|
|
748
|
+
export default defineConfig(
|
|
749
|
+
includeIgnoreFile(fileURLToPath(new URL("./.gitignore", import.meta.url))),
|
|
750
|
+
js.configs.recommended,
|
|
751
|
+
...ts.configs.strict,
|
|
752
|
+
...ts.configs.stylistic,
|
|
753
|
+
perfectionist.configs["recommended-natural"],
|
|
754
|
+
prettier,
|
|
755
|
+
{
|
|
756
|
+
languageOptions: {
|
|
757
|
+
globals: { ...globals.browser, ...globals.node },
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
);
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
Formatting command:
|
|
764
|
+
|
|
765
|
+
```sh
|
|
766
|
+
npx prettier --write .
|
|
767
|
+
npx eslint --fix .
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
Just check:
|
|
771
|
+
|
|
772
|
+
```sh
|
|
773
|
+
npx prettier --check .
|
|
774
|
+
npx eslint .
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
You can add these commands (without npx) as `format` and `lint` scripts in your `package.json`.
|
|
778
|
+
|
|
779
|
+
## License
|
|
780
|
+
|
|
781
|
+
SPDX-License-Identifier: MIT
|