evikit 1.0.1 → 1.0.2

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 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 evikit hoofd preact preact-iso valibot
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 { number, integer, object, optional, pipe, string } from "valibot";
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(optional(string(), () => crypto.randomUUID()), primary()),
82
- text: string()
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(optional(string(), () => crypto.randomUUID()), primary()),
87
- questionId: pipe(string(), references(Question, "id"))
88
- text: string()
89
- votes: pipe(number(), integer())
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", "uk"]);
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,10 +203,15 @@ 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
 
210
+ /** @param {{ lang?: string, url: string }} data */
211
+ export async function prerender(data) {
212
+ return await ssr(App, data);
213
+ }
214
+
187
215
  /** @param {{ dehydratedState?: ReturnType<import("evikit").dehydrate> | undefined, i18n?: ReturnType<import("evikit").useI18n> | undefined }} props */
188
216
  function App({ dehydratedState, i18n }) {
189
217
  return h(
@@ -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
- questions.map((question) =>
409
+ data.Question.map((question) =>
385
410
  li(
386
411
  { key: question.id },
387
412
  a(
388
- { href: `/polls/${encodeURIComponent(question.id)}` },
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 " ../../../lib/db/index.js";
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
- ownSuggestionId: choice.id,
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 { button, form, h2, input, label } from "evikit";
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", submit: suggestPost.onSubmit },
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
- ownSuggestionId: optional(string()), // Coming from cookie.
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(question.id)}`, 302);
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 { button, form, input, label, li, ul } from "evikit";
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({ id: choice.id, name: "choiceId", type: "radio" }),
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,
@@ -1,6 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from "node:child_process";
3
- import { existsSync, globSync, readFileSync, writeFileSync } from "node:fs";
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.1",
4
+ "version": "1.0.2",
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",