evikit 1.0.1 → 1.0.3
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/README.md +92 -52
- package/bin/evikit-extract.js +10 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -52,8 +52,8 @@ cd mysite
|
|
|
52
52
|
npm init -y
|
|
53
53
|
|
|
54
54
|
# Routing and validation.
|
|
55
|
-
npm i accept-language classnames express
|
|
56
|
-
npm i -D cross-env vite vite-bundle-analyzer
|
|
55
|
+
npm i accept-language classnames evikit express hoofd preact preact-iso valibot
|
|
56
|
+
npm i -D @preact/preset-vite cross-env vite vite-bundle-analyzer
|
|
57
57
|
|
|
58
58
|
# API documentation.
|
|
59
59
|
npm i swagger-ui-express
|
|
@@ -68,25 +68,44 @@ Second, configure strict JSDoc type checking using TypeScript at `tsconfig.json`
|
|
|
68
68
|
npx tsc --init --outDir dist --allowJs --checkJs --noEmit --esModuleInterop
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
+
Third, open package.json and change `"type": "commonjs"` to `"type": "module"` because we want modern import syntax.
|
|
72
|
+
|
|
73
|
+
Fourth, add a `.gitignore`. I suggest starting with:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
node_modules
|
|
77
|
+
/dist
|
|
78
|
+
*~
|
|
79
|
+
*swp
|
|
80
|
+
.env
|
|
81
|
+
*.db
|
|
82
|
+
```
|
|
83
|
+
|
|
71
84
|
### Database schema
|
|
72
85
|
|
|
73
86
|
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
87
|
|
|
75
88
|
```js
|
|
76
89
|
import { primary, references } from "evikit/db";
|
|
77
|
-
import {
|
|
90
|
+
import { integer, number, object, optional, pipe, string } from "valibot";
|
|
78
91
|
|
|
79
92
|
export const Question = object({
|
|
80
93
|
createdAt: pipe(number(), integer()),
|
|
81
|
-
id: pipe(
|
|
82
|
-
|
|
94
|
+
id: pipe(
|
|
95
|
+
optional(string(), () => crypto.randomUUID()),
|
|
96
|
+
primary(),
|
|
97
|
+
),
|
|
98
|
+
text: string(),
|
|
83
99
|
});
|
|
84
100
|
|
|
85
101
|
export const Choice = object({
|
|
86
|
-
id: pipe(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
102
|
+
id: pipe(
|
|
103
|
+
optional(string(), () => crypto.randomUUID()),
|
|
104
|
+
primary(),
|
|
105
|
+
),
|
|
106
|
+
questionId: pipe(string(), references(Question, "id")),
|
|
107
|
+
text: string(),
|
|
108
|
+
votes: pipe(number(), integer()),
|
|
90
109
|
});
|
|
91
110
|
```
|
|
92
111
|
|
|
@@ -104,6 +123,8 @@ export const db = sqlite("data/local.db", { schema });
|
|
|
104
123
|
- See QuestionIdServer below for an example of `select` with a join.
|
|
105
124
|
- See VoteServer below for an example of `update` and `delete`.
|
|
106
125
|
|
|
126
|
+
Create an empty `data/.gitkeep` file.
|
|
127
|
+
|
|
107
128
|
### Routing
|
|
108
129
|
|
|
109
130
|
#### Language negotiation
|
|
@@ -114,10 +135,12 @@ Configure languages you're going to accept at `src/lib/index.js`:
|
|
|
114
135
|
import acceptLanguage from "accept-language";
|
|
115
136
|
|
|
116
137
|
export function acceptLanguages() {
|
|
117
|
-
acceptLanguage.languages(["en"
|
|
138
|
+
acceptLanguage.languages(["en"]);
|
|
118
139
|
}
|
|
119
140
|
```
|
|
120
141
|
|
|
142
|
+
Start with `en`. Add languages later when [translations](#translation) are ready.
|
|
143
|
+
|
|
121
144
|
#### Error page
|
|
122
145
|
|
|
123
146
|
Code an error page at `src/routes/error.js`:
|
|
@@ -146,13 +169,13 @@ export function ErrorPage({ children, error }) {
|
|
|
146
169
|
Add an API router at `src/api.js` (uncomment the routes one by one when we add them later):
|
|
147
170
|
|
|
148
171
|
```js
|
|
149
|
-
import { router } from "evikit/server";
|
|
172
|
+
import { route, router } from "evikit/server";
|
|
150
173
|
|
|
151
174
|
import pkg from "../package.json" with { type: "json" };
|
|
152
175
|
import { acceptLanguages } from "./lib/index.js";
|
|
153
|
-
// import { HomeServer } from "./routes/api/server.js";
|
|
154
176
|
// import { ChoiceServer } from "./routes/api/choice/server.js";
|
|
155
177
|
// import { QuestionIdServer } from "./routes/api/question/[id]/server.js";
|
|
178
|
+
// import { HomeServer } from "./routes/api/server.js";
|
|
156
179
|
// import { VoteServer } from "./routes/api/vote/server.js";
|
|
157
180
|
|
|
158
181
|
acceptLanguages();
|
|
@@ -180,11 +203,16 @@ import { Route } from "preact-iso";
|
|
|
180
203
|
import { acceptLanguages } from "./lib/index.js";
|
|
181
204
|
import { ErrorPage } from "./routes/error.js";
|
|
182
205
|
// import { HomePage } from "./routes/page.js";
|
|
183
|
-
// import { QuestionIdPage } from "./routes/question/page.js";
|
|
206
|
+
// import { QuestionIdPage } from "./routes/question/[id]/page.js";
|
|
184
207
|
|
|
185
208
|
acceptLanguages();
|
|
186
209
|
|
|
187
|
-
/** @param {{
|
|
210
|
+
/** @param {{ lang?: string, url: string }} data */
|
|
211
|
+
export async function prerender(data) {
|
|
212
|
+
return await ssr(App, data);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** @param {{ dehydratedState?: ReturnType<import("evikit").dehydrate> | undefined, i18n: ReturnType<import("evikit").useI18n> }} props */
|
|
188
216
|
function App({ dehydratedState, i18n }) {
|
|
189
217
|
return h(
|
|
190
218
|
Router,
|
|
@@ -195,11 +223,6 @@ function App({ dehydratedState, i18n }) {
|
|
|
195
223
|
);
|
|
196
224
|
}
|
|
197
225
|
|
|
198
|
-
/** @param {{ lang?: string, url: string }} data */
|
|
199
|
-
export async function prerender(data) {
|
|
200
|
-
return await ssr(App, data);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
226
|
hydrate(App, "#app");
|
|
204
227
|
```
|
|
205
228
|
|
|
@@ -261,6 +284,8 @@ npx vite build
|
|
|
261
284
|
|
|
262
285
|
You can add these commands (without npx) as `dev` and `build` scripts to your `package.json`.
|
|
263
286
|
|
|
287
|
+
_TODO_ Why does `npx vite build` return 'Unable to detect prerender entry script' sometimes? Re-running fixed it for me.
|
|
288
|
+
|
|
264
289
|
#### Production server
|
|
265
290
|
|
|
266
291
|
Add production server at `index.js`:
|
|
@@ -363,7 +388,7 @@ export const HomeServer = {
|
|
|
363
388
|
};
|
|
364
389
|
```
|
|
365
390
|
|
|
366
|
-
Uncomment the `/api` route in `src/api.js`.
|
|
391
|
+
Uncomment the `HomeServer` import and `/api` route in `src/api.js`.
|
|
367
392
|
|
|
368
393
|
Render a Preact page route: `src/routes/page.js`:
|
|
369
394
|
|
|
@@ -381,11 +406,11 @@ export function HomePage() {
|
|
|
381
406
|
return data
|
|
382
407
|
? ul(
|
|
383
408
|
{ class: "questions" },
|
|
384
|
-
|
|
409
|
+
data.Question.map((question) =>
|
|
385
410
|
li(
|
|
386
411
|
{ key: question.id },
|
|
387
412
|
a(
|
|
388
|
-
{ href: `/
|
|
413
|
+
{ href: `/question/${encodeURIComponent(question.id)}` },
|
|
389
414
|
question.text,
|
|
390
415
|
),
|
|
391
416
|
),
|
|
@@ -395,7 +420,7 @@ export function HomePage() {
|
|
|
395
420
|
}
|
|
396
421
|
```
|
|
397
422
|
|
|
398
|
-
Uncomment the `HomePage` route in `src/app.js`.
|
|
423
|
+
Uncomment the `HomePage` import and `/` route in `src/app.js`.
|
|
399
424
|
|
|
400
425
|
#### Forms
|
|
401
426
|
|
|
@@ -427,7 +452,6 @@ Server endpoint: `src/routes/api/question/[id]/server.js`:
|
|
|
427
452
|
```js
|
|
428
453
|
import { getI18n } from "evikit";
|
|
429
454
|
import { endpoint, json, text } from "evikit/server";
|
|
430
|
-
import { string } from "valibot";
|
|
431
455
|
|
|
432
456
|
import { db } from "../../../../lib/db/index.js";
|
|
433
457
|
import { QuestionIdGet } from "./types.js";
|
|
@@ -449,12 +473,11 @@ export const QuestionIdServer = {
|
|
|
449
473
|
};
|
|
450
474
|
```
|
|
451
475
|
|
|
452
|
-
Uncomment the `/question/:id` route in `src/api.js`.
|
|
476
|
+
Uncomment the `QuestionIdServer` import and `/question/:id` route in `src/api.js`.
|
|
453
477
|
|
|
454
|
-
Preact page route: `src/routes/question/[id]/page.js
|
|
478
|
+
Preact page route: `src/routes/question/[id]/page.js` (note that we're outside the `api/` directory now):
|
|
455
479
|
|
|
456
480
|
```js
|
|
457
|
-
import classNames from "classnames";
|
|
458
481
|
import { h1, main, useApi, useEnhance, useI18n } from "evikit";
|
|
459
482
|
import { useTitle } from "hoofd/preact";
|
|
460
483
|
import { h } from "preact";
|
|
@@ -462,6 +485,7 @@ import { h } from "preact";
|
|
|
462
485
|
import { ChoicePost } from "../../api/choice/types.js";
|
|
463
486
|
import { QuestionIdGet } from "../../api/question/[id]/types.js";
|
|
464
487
|
import { VotePost } from "../../api/vote/types.js";
|
|
488
|
+
import { ErrorPage } from "../../error.js";
|
|
465
489
|
|
|
466
490
|
export function QuestionIdPage() {
|
|
467
491
|
const { data, error } = useApi(QuestionIdGet);
|
|
@@ -485,7 +509,7 @@ export function QuestionIdPage() {
|
|
|
485
509
|
}
|
|
486
510
|
```
|
|
487
511
|
|
|
488
|
-
Uncomment the `QuestionIdPage` route in `src/app.js`.
|
|
512
|
+
Uncomment the `QuestionIdPage` import and `/question/:id` route in `src/app.js`.
|
|
489
513
|
|
|
490
514
|
##### Suggest own option
|
|
491
515
|
|
|
@@ -498,9 +522,9 @@ export const ChoicePost = {
|
|
|
498
522
|
input: object({
|
|
499
523
|
lang: string(),
|
|
500
524
|
questionId: string(),
|
|
501
|
-
text: string()
|
|
502
|
-
})
|
|
503
|
-
outputs: { 302: string(), 400: string() }
|
|
525
|
+
text: string(),
|
|
526
|
+
}),
|
|
527
|
+
outputs: { 302: string(), 400: string() },
|
|
504
528
|
};
|
|
505
529
|
```
|
|
506
530
|
|
|
@@ -510,9 +534,8 @@ Server endpoint: `src/routes/api/choice/server.js`:
|
|
|
510
534
|
import { getI18n } from "evikit";
|
|
511
535
|
import { endpoint, redirect, text } from "evikit/server";
|
|
512
536
|
import { ok } from "node:assert/strict";
|
|
513
|
-
import { object, string } from "valibot";
|
|
514
537
|
|
|
515
|
-
import { db } from "
|
|
538
|
+
import { db } from "../../../lib/db/index.js";
|
|
516
539
|
import { ChoicePost } from "./types.js";
|
|
517
540
|
|
|
518
541
|
export const ChoiceServer = {
|
|
@@ -538,24 +561,25 @@ export const ChoiceServer = {
|
|
|
538
561
|
return redirect(`/question/${encodeURIComponent(question.id)}`, 302, {
|
|
539
562
|
cookies: {
|
|
540
563
|
// Note: just an example. Use cookies sparingly, e.g. for session id.
|
|
541
|
-
|
|
564
|
+
ownVoteQuestionId: { value: choice.questionId },
|
|
542
565
|
},
|
|
543
566
|
});
|
|
544
567
|
}),
|
|
545
568
|
};
|
|
546
569
|
```
|
|
547
570
|
|
|
548
|
-
Uncomment the `/api/choice` route in `src/api.js`.
|
|
571
|
+
Uncomment the `ChoiceServer` import and `/api/choice` route in `src/api.js`.
|
|
549
572
|
|
|
550
573
|
Code a form view in place of `SUGGEST_FORM` in `src/routes/question/[id]/page.js`:
|
|
551
574
|
|
|
552
575
|
```js
|
|
553
|
-
import
|
|
576
|
+
import classNames from "classnames";
|
|
577
|
+
import { button, form, h2, input, label, p } from "evikit";
|
|
554
578
|
|
|
555
579
|
// ...
|
|
556
580
|
|
|
557
581
|
form(
|
|
558
|
-
{ action: "/api/choice", method: "POST",
|
|
582
|
+
{ action: "/api/choice", method: "POST", onSubmit: suggestPost.onSubmit },
|
|
559
583
|
input({ name: "questionId", type: "hidden", value: data?.question.id }),
|
|
560
584
|
h2({ class: "heading" }, i18n.gettext("Own choice")),
|
|
561
585
|
p(
|
|
@@ -567,7 +591,7 @@ form(
|
|
|
567
591
|
{ class: classNames("btn", suggestPost.busy && "btn-disabled") },
|
|
568
592
|
i18n.gettext("Suggest"),
|
|
569
593
|
),
|
|
570
|
-
)
|
|
594
|
+
),
|
|
571
595
|
```
|
|
572
596
|
|
|
573
597
|
##### Vote for choice
|
|
@@ -581,7 +605,7 @@ export const VotePost = {
|
|
|
581
605
|
input: object({
|
|
582
606
|
choiceId: string(),
|
|
583
607
|
lang: string(),
|
|
584
|
-
|
|
608
|
+
ownVoteQuestionId: optional(string()), // Coming from cookie.
|
|
585
609
|
}),
|
|
586
610
|
outputs: { 302: string(), 400: string() },
|
|
587
611
|
};
|
|
@@ -592,7 +616,6 @@ Server endpoint: `src/routes/api/vote/server.js`:
|
|
|
592
616
|
```js
|
|
593
617
|
import { getI18n } from "evikit";
|
|
594
618
|
import { endpoint, redirect, text } from "evikit/server";
|
|
595
|
-
import { string } from "valibot";
|
|
596
619
|
|
|
597
620
|
import { db } from "../../../lib/db/index.js";
|
|
598
621
|
import { VotePost } from "./types.js";
|
|
@@ -601,19 +624,16 @@ export const VoteServer = {
|
|
|
601
624
|
post: endpoint(VotePost, async ({ input }) => {
|
|
602
625
|
const i18n = await getI18n(input.lang);
|
|
603
626
|
|
|
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
627
|
const Choice = db.Choice.select();
|
|
612
628
|
const choice = Choice.find(({ id }) => id == input.choiceId);
|
|
613
629
|
if (!choice) {
|
|
614
630
|
return text(i18n.gettext("You didn't select a choice"), 400);
|
|
615
631
|
}
|
|
616
632
|
|
|
633
|
+
if (choice.questionId == input.ownVoteQuestionId) {
|
|
634
|
+
return text(i18n.gettext("Already voted"), 400);
|
|
635
|
+
}
|
|
636
|
+
|
|
617
637
|
db.Choice.update({
|
|
618
638
|
set: { votes: choice.votes + 1 },
|
|
619
639
|
where: { id: choice.id },
|
|
@@ -622,17 +642,17 @@ export const VoteServer = {
|
|
|
622
642
|
// To delete a choice: `db.Choice.delete({ id: choice.id })`.
|
|
623
643
|
// To delete multiple choices, pass an array of ids to `id`.
|
|
624
644
|
|
|
625
|
-
return redirect(`/question/${encodeURIComponent(
|
|
645
|
+
return redirect(`/question/${encodeURIComponent(choice.questionId)}`, 302);
|
|
626
646
|
}),
|
|
627
647
|
};
|
|
628
648
|
```
|
|
629
649
|
|
|
630
|
-
Uncomment the `/api/vote` route in `src/api.js`.
|
|
650
|
+
Uncomment the `VoteServer` import and `/api/vote` route in `src/api.js`.
|
|
631
651
|
|
|
632
652
|
Code a form view in place of `VOTE_FORM` in `src/routes/question/[id]/page.js`:
|
|
633
653
|
|
|
634
654
|
```js
|
|
635
|
-
import {
|
|
655
|
+
import { li, ul } from "evikit";
|
|
636
656
|
|
|
637
657
|
// ...
|
|
638
658
|
|
|
@@ -643,7 +663,18 @@ form(
|
|
|
643
663
|
data?.question.Choice.map((choice) =>
|
|
644
664
|
li(
|
|
645
665
|
{ key: choice.id },
|
|
646
|
-
input({
|
|
666
|
+
input({
|
|
667
|
+
id: choice.id,
|
|
668
|
+
name: "choiceId",
|
|
669
|
+
type: "radio",
|
|
670
|
+
value: choice.id,
|
|
671
|
+
}),
|
|
672
|
+
label(
|
|
673
|
+
{ for: choice.id },
|
|
674
|
+
choice.text,
|
|
675
|
+
": ",
|
|
676
|
+
i18n.ngettext("%1 vote", "%1 votes", choice.votes, choice.votes),
|
|
677
|
+
),
|
|
647
678
|
label({ for: choice.id }, choice.text),
|
|
648
679
|
),
|
|
649
680
|
),
|
|
@@ -652,7 +683,7 @@ form(
|
|
|
652
683
|
{ class: classNames("btn", votePost.busy && "btn-disabled") },
|
|
653
684
|
i18n.gettext("Vote"),
|
|
654
685
|
),
|
|
655
|
-
)
|
|
686
|
+
),
|
|
656
687
|
```
|
|
657
688
|
|
|
658
689
|
### Translation
|
|
@@ -745,6 +776,15 @@ npm i -D eslint-config-prettier prettier
|
|
|
745
776
|
My `eslint.config.js`:
|
|
746
777
|
|
|
747
778
|
```js
|
|
779
|
+
import { includeIgnoreFile } from "@eslint/compat";
|
|
780
|
+
import js from "@eslint/js";
|
|
781
|
+
import prettier from "eslint-config-prettier/flat";
|
|
782
|
+
import perfectionist from "eslint-plugin-perfectionist";
|
|
783
|
+
import { defineConfig } from "eslint/config";
|
|
784
|
+
import globals from "globals";
|
|
785
|
+
import { fileURLToPath } from "node:url";
|
|
786
|
+
import ts from "typescript-eslint";
|
|
787
|
+
|
|
748
788
|
export default defineConfig(
|
|
749
789
|
includeIgnoreFile(fileURLToPath(new URL("./.gitignore", import.meta.url))),
|
|
750
790
|
js.configs.recommended,
|
package/bin/evikit-extract.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
globSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
4
10
|
import { basename } from "node:path";
|
|
5
11
|
|
|
6
12
|
const pkg = JSON.parse(readFileSync("package.json", "utf-8"));
|
|
@@ -24,6 +30,9 @@ for (const output of globSync("po/*.po")) {
|
|
|
24
30
|
stdio: "inherit",
|
|
25
31
|
});
|
|
26
32
|
} else {
|
|
33
|
+
if (existsSync(output)) {
|
|
34
|
+
unlinkSync(output);
|
|
35
|
+
}
|
|
27
36
|
spawnSync(
|
|
28
37
|
"msginit",
|
|
29
38
|
[
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "evikit",
|
|
3
3
|
"description": "Preact/SQLite SSR framework for small web apps",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./index.js",
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
"license": "MIT",
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@fastify/busboy": "^3.2.0",
|
|
25
|
+
"@types/gettext-parser": "^9.0.0",
|
|
26
|
+
"@types/gettext.js": "^2.0.0",
|
|
27
|
+
"@types/lodash": "^4.17.24",
|
|
28
|
+
"@types/node": "^25",
|
|
25
29
|
"@valibot/to-json-schema": "^1.7.0",
|
|
26
30
|
"gettext-parser": "^9.0.2",
|
|
27
31
|
"gettext.js": "^2.0.3",
|
|
@@ -38,10 +42,6 @@
|
|
|
38
42
|
"devDependencies": {
|
|
39
43
|
"@eslint/compat": "^2.1.0",
|
|
40
44
|
"@eslint/js": "^10.0.1",
|
|
41
|
-
"@types/gettext-parser": "^9.0.0",
|
|
42
|
-
"@types/gettext.js": "^2.0.0",
|
|
43
|
-
"@types/lodash": "^4.17.24",
|
|
44
|
-
"@types/node": "^25",
|
|
45
45
|
"eslint": "^10.4.0",
|
|
46
46
|
"eslint-config-prettier": "^10.1.8",
|
|
47
47
|
"eslint-plugin-perfectionist": "^5.9.0",
|