evikit 2.0.0 → 2.2.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/README.md +154 -2
- package/package.json +1 -1
- package/server.d.ts +3 -1
- package/server.js +66 -9
- package/sqlite.js +4 -1
- package/use-enhance.js +20 -8
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Vite is a good foundation for building a server-rendered monolith SPA with an JS
|
|
|
13
13
|
- Show the language chosen by the user in browser settings
|
|
14
14
|
- Have SEO meta descriptions
|
|
15
15
|
- Generate API documentation with a Swagger overview
|
|
16
|
-
-
|
|
16
|
+
- Send multipart not urlencoded when there's a file
|
|
17
17
|
|
|
18
18
|
_TODO_ Add more simple language docs near code, don't overwhelm readers.
|
|
19
19
|
|
|
@@ -52,7 +52,7 @@ cd mysite
|
|
|
52
52
|
npm init -y
|
|
53
53
|
|
|
54
54
|
# Routing and validation.
|
|
55
|
-
npm i accept-language classnames evikit express hoofd preact preact-iso valibot
|
|
55
|
+
npm i accept-language classnames evikit express file-type hoofd preact preact-iso valibot
|
|
56
56
|
npm i -D @preact/preset-vite cross-env vite vite-bundle-analyzer
|
|
57
57
|
|
|
58
58
|
# API documentation.
|
|
@@ -688,6 +688,158 @@ form(
|
|
|
688
688
|
),
|
|
689
689
|
```
|
|
690
690
|
|
|
691
|
+
### File upload
|
|
692
|
+
|
|
693
|
+
Why not let each choice have an icon?
|
|
694
|
+
|
|
695
|
+
In `src/lib/db/schema.js`:
|
|
696
|
+
|
|
697
|
+
```js
|
|
698
|
+
import { instance } from "valibot";
|
|
699
|
+
|
|
700
|
+
// Other imports and Question schema...
|
|
701
|
+
|
|
702
|
+
export const Storage = object({
|
|
703
|
+
content: instance(Uint8Array),
|
|
704
|
+
id: pipe(string(), primary()),
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
export const Choice = object({
|
|
708
|
+
iconStorageId: pipe(string(), references(Storage, "id")),
|
|
709
|
+
// The rest of the Choice schema...
|
|
710
|
+
});
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
In `src/routes/api/choice/types.js`:
|
|
714
|
+
|
|
715
|
+
```js
|
|
716
|
+
import { file } from "valibot";
|
|
717
|
+
// Other imports...
|
|
718
|
+
|
|
719
|
+
export const ChoicePost = {
|
|
720
|
+
input: object({
|
|
721
|
+
icon: file(),
|
|
722
|
+
// The rest of the schema...
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
In `src/routes/api/choice/server.js`:
|
|
726
|
+
|
|
727
|
+
```js
|
|
728
|
+
import { fileTypeFromBuffer } from "file-type";
|
|
729
|
+
import { createHash } from "node:crypto";
|
|
730
|
+
import { parse, picklist } from "valibot";
|
|
731
|
+
|
|
732
|
+
// ...
|
|
733
|
+
if (!question) {
|
|
734
|
+
return text(i18n.gettext("Question does not exist"), 400);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const buffer = Buffer.from(await input.icon.arrayBuffer());
|
|
738
|
+
|
|
739
|
+
// Fail if user sent something else than an image.
|
|
740
|
+
const fileType = await fileTypeFromBuffer(buffer);
|
|
741
|
+
ok(fileType);
|
|
742
|
+
const { ext } = fileType;
|
|
743
|
+
parse(picklist(["gif", "jpg", "png", "webp"]), ext);
|
|
744
|
+
|
|
745
|
+
// Don't save another image if hash already known.
|
|
746
|
+
const iconStorageId = `${createHash("sha256").update(buffer).digest("hex")}.${ext}`;
|
|
747
|
+
db.Storage.upsert({
|
|
748
|
+
content: new Uint8Array(buffer.buffer),
|
|
749
|
+
id: iconStorageId,
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
const [choice] = db.Choice.upsert({
|
|
753
|
+
iconStorageId,
|
|
754
|
+
// ...
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
In `src/routes/api/question/[id]/types.js`:
|
|
758
|
+
|
|
759
|
+
```js
|
|
760
|
+
// ...
|
|
761
|
+
question: object({
|
|
762
|
+
...schema.Question.entries,
|
|
763
|
+
Choice: array(
|
|
764
|
+
object({
|
|
765
|
+
...schema.Choice.entries,
|
|
766
|
+
iconUrl: string(),
|
|
767
|
+
}),
|
|
768
|
+
),
|
|
769
|
+
}),
|
|
770
|
+
// ...
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
In `src/routes/api/question[id]/server.js`:
|
|
774
|
+
|
|
775
|
+
```js
|
|
776
|
+
import { fileTypeFromBuffer } from "file-type";
|
|
777
|
+
import { ok } from "node:assert/strict";
|
|
778
|
+
|
|
779
|
+
// ...
|
|
780
|
+
|
|
781
|
+
const Question = db.Question.select({
|
|
782
|
+
with: { Choice: { with: { iconStorage: true } } },
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
// ...
|
|
786
|
+
|
|
787
|
+
return json({
|
|
788
|
+
question: {
|
|
789
|
+
...question,
|
|
790
|
+
Choice: await Promise.all(
|
|
791
|
+
question.Choice.map(async (choice) => {
|
|
792
|
+
ok(choice.iconStorage);
|
|
793
|
+
const buffer = Buffer.from(choice.iconStorage.content.buffer);
|
|
794
|
+
const fileType = await fileTypeFromBuffer(buffer);
|
|
795
|
+
ok(fileType);
|
|
796
|
+
return {
|
|
797
|
+
...choice,
|
|
798
|
+
iconUrl: `data:${fileType.mime};base64,${buffer.toString("base64")}`,
|
|
799
|
+
};
|
|
800
|
+
}),
|
|
801
|
+
),
|
|
802
|
+
},
|
|
803
|
+
});
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
In `src/routes/question/[id]/page.js`:
|
|
807
|
+
|
|
808
|
+
```js
|
|
809
|
+
import { img } from "evikit";
|
|
810
|
+
// ...
|
|
811
|
+
|
|
812
|
+
form(
|
|
813
|
+
{
|
|
814
|
+
action: "/api/choice",
|
|
815
|
+
enctype: "multipart/form-data",
|
|
816
|
+
method: "POST",
|
|
817
|
+
onSubmit: suggestPost.onSubmit,
|
|
818
|
+
},
|
|
819
|
+
// ...
|
|
820
|
+
p(
|
|
821
|
+
{ class: "fieldset" },
|
|
822
|
+
label({ for: "icon" }, i18n.gettext("Icon")),
|
|
823
|
+
input({ id: "icon", name: "icon", type: "file" }),
|
|
824
|
+
),
|
|
825
|
+
// The rest of the /api/choice form...
|
|
826
|
+
),
|
|
827
|
+
|
|
828
|
+
form(
|
|
829
|
+
{ action: "/api/vote", method: "POST", onSubmit: votePost.onSubmit },
|
|
830
|
+
// ...
|
|
831
|
+
input({
|
|
832
|
+
id: choice.id,
|
|
833
|
+
name: "choiceId",
|
|
834
|
+
type: "radio",
|
|
835
|
+
value: choice.id,
|
|
836
|
+
}),
|
|
837
|
+
label(
|
|
838
|
+
{ for: choice.id },
|
|
839
|
+
img({ alt: "", height: "24", src: choice.iconUrl, width: "24" }),
|
|
840
|
+
// ...
|
|
841
|
+
```
|
|
842
|
+
|
|
691
843
|
### Translation
|
|
692
844
|
|
|
693
845
|
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.
|
package/package.json
CHANGED
package/server.d.ts
CHANGED
|
@@ -91,7 +91,9 @@ export function router(
|
|
|
91
91
|
| {
|
|
92
92
|
content: {
|
|
93
93
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
94
|
-
"application/x-www-form-urlencoded"
|
|
94
|
+
"application/x-www-form-urlencoded"?: { schema: any };
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
96
|
+
"multipart/form-data"?: { schema: any };
|
|
95
97
|
};
|
|
96
98
|
};
|
|
97
99
|
responses: Record<
|
package/server.js
CHANGED
|
@@ -20,6 +20,7 @@ import { Po } from "./i18n.js";
|
|
|
20
20
|
|
|
21
21
|
/** @type {import("./server-generic.js").ServerEndpoint} */
|
|
22
22
|
export function endpoint({ input, outputs }, handler) {
|
|
23
|
+
const arrayOf = ArrayOf(input);
|
|
23
24
|
const schema = union(
|
|
24
25
|
Object.entries(outputs).map(([status, schema]) =>
|
|
25
26
|
object({
|
|
@@ -34,7 +35,7 @@ export function endpoint({ input, outputs }, handler) {
|
|
|
34
35
|
return {
|
|
35
36
|
handler: async (req, res) => {
|
|
36
37
|
try {
|
|
37
|
-
/** @type {Record<string, string | string[]>} */
|
|
38
|
+
/** @type {Record<string, string | File | (string | File)[]>} */
|
|
38
39
|
const body = {};
|
|
39
40
|
if (req.headers["content-type"]) {
|
|
40
41
|
const busboy = new Busboy({
|
|
@@ -44,13 +45,39 @@ export function endpoint({ input, outputs }, handler) {
|
|
|
44
45
|
body[name] =
|
|
45
46
|
typeof body[name] == "undefined"
|
|
46
47
|
? value
|
|
47
|
-
: [
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
: body[name]),
|
|
51
|
-
value,
|
|
52
|
-
];
|
|
48
|
+
: Array.isArray(body[name])
|
|
49
|
+
? [...body[name], value]
|
|
50
|
+
: [body[name], value];
|
|
53
51
|
});
|
|
52
|
+
|
|
53
|
+
if (
|
|
54
|
+
Object.values(input.entries).some(
|
|
55
|
+
(entry) => entry.type == "file" || arrayOf(entry, "file"),
|
|
56
|
+
)
|
|
57
|
+
) {
|
|
58
|
+
busboy.on("file", (name, stream, filename) => {
|
|
59
|
+
const entry = input.entries[name];
|
|
60
|
+
if (entry && (entry.type == "file" || arrayOf(entry, "file"))) {
|
|
61
|
+
/** @type {ArrayBuffer[]} */
|
|
62
|
+
const buffers = [];
|
|
63
|
+
stream.on("data", (data) => {
|
|
64
|
+
buffers.push(data);
|
|
65
|
+
});
|
|
66
|
+
stream.on("end", () => {
|
|
67
|
+
const value = new File(buffers, filename);
|
|
68
|
+
body[name] =
|
|
69
|
+
typeof body[name] == "undefined"
|
|
70
|
+
? value
|
|
71
|
+
: Array.isArray(body[name])
|
|
72
|
+
? [...body[name], value]
|
|
73
|
+
: [body[name], value];
|
|
74
|
+
});
|
|
75
|
+
} else {
|
|
76
|
+
stream.resume();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
54
81
|
await new Promise((resolve, reject) => {
|
|
55
82
|
busboy.on("error", reject).on("finish", resolve);
|
|
56
83
|
req.pipe(busboy);
|
|
@@ -269,6 +296,7 @@ export function router({ pkg }, ...routes) {
|
|
|
269
296
|
path.replace(/:(\w+)/g, "{$1}"),
|
|
270
297
|
Object.fromEntries(
|
|
271
298
|
Object.entries(controller).map(([method, { input, outputs }]) => {
|
|
299
|
+
const arrayOf = ArrayOf(input);
|
|
272
300
|
const parameters = path
|
|
273
301
|
.split("/")
|
|
274
302
|
.filter((component) => /^:/.test(component))
|
|
@@ -286,7 +314,12 @@ export function router({ pkg }, ...routes) {
|
|
|
286
314
|
? {
|
|
287
315
|
requestBody: {
|
|
288
316
|
content: {
|
|
289
|
-
|
|
317
|
+
[Object.values(input.entries).some(
|
|
318
|
+
(entry) =>
|
|
319
|
+
entry.type == "file" || arrayOf(entry, "file"),
|
|
320
|
+
)
|
|
321
|
+
? "multipart/form-data"
|
|
322
|
+
: "application/x-www-form-urlencoded"]: {
|
|
290
323
|
schema: toJsonSchema(
|
|
291
324
|
object(
|
|
292
325
|
Object.fromEntries(
|
|
@@ -295,7 +328,13 @@ export function router({ pkg }, ...routes) {
|
|
|
295
328
|
),
|
|
296
329
|
),
|
|
297
330
|
),
|
|
298
|
-
{
|
|
331
|
+
{
|
|
332
|
+
ignoreActions: ["trim"],
|
|
333
|
+
overrideSchema: (context) =>
|
|
334
|
+
context.valibotSchema.type == "file"
|
|
335
|
+
? { format: "binary", type: "string" }
|
|
336
|
+
: context.jsonSchema,
|
|
337
|
+
},
|
|
299
338
|
),
|
|
300
339
|
},
|
|
301
340
|
},
|
|
@@ -426,3 +465,21 @@ export function text(output, status) {
|
|
|
426
465
|
status,
|
|
427
466
|
};
|
|
428
467
|
}
|
|
468
|
+
|
|
469
|
+
/** @param {import("valibot").ObjectSchema<any, any>} input */
|
|
470
|
+
function ArrayOf(input) {
|
|
471
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
472
|
+
const entries = Object.values(input);
|
|
473
|
+
/**
|
|
474
|
+
* @param {typeof entries[0]} entry
|
|
475
|
+
* @param {string} type
|
|
476
|
+
*/
|
|
477
|
+
return function arrayOf(entry, type) {
|
|
478
|
+
if (entry.type != "array") {
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
/** @type {import("valibot").ArraySchema<any, any>} */
|
|
482
|
+
const array = /** @type {any} */ (entry);
|
|
483
|
+
return array.item.type == type;
|
|
484
|
+
};
|
|
485
|
+
}
|
package/sqlite.js
CHANGED
|
@@ -71,7 +71,10 @@ export function sqlite({ pkg, schema }) {
|
|
|
71
71
|
: entry.expects == "number" ||
|
|
72
72
|
entry.expects == "(number | undefined)"
|
|
73
73
|
? "INTEGER"
|
|
74
|
-
:
|
|
74
|
+
: entry.expects == "Uint8Array" ||
|
|
75
|
+
entry.expects == "(Uint8Array | undefined)"
|
|
76
|
+
? "BLOB"
|
|
77
|
+
: fail(name),
|
|
75
78
|
};
|
|
76
79
|
}),
|
|
77
80
|
foreignKeys: Object.entries(entries)
|
package/use-enhance.js
CHANGED
|
@@ -21,14 +21,23 @@ export function useEnhance(schema, onSuccess) {
|
|
|
21
21
|
setBusy(true);
|
|
22
22
|
const form = /** @type {HTMLFormElement} */ (event.target);
|
|
23
23
|
let output;
|
|
24
|
+
|
|
24
25
|
try {
|
|
25
|
-
const body =
|
|
26
|
+
const body = form.querySelector('[type="file"]')
|
|
27
|
+
? new FormData()
|
|
28
|
+
: new URLSearchParams();
|
|
26
29
|
for (const input of [...form.querySelectorAll("input")]) {
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
if (!input.disabled) {
|
|
31
|
+
if (input.type == "file" && body instanceof FormData) {
|
|
32
|
+
for (const file of input.files || []) {
|
|
33
|
+
body.append(input.name, file, file.name);
|
|
34
|
+
}
|
|
35
|
+
} else if (
|
|
36
|
+
(input.type != "checkbox" && input.type != "radio") ||
|
|
37
|
+
input.checked
|
|
38
|
+
) {
|
|
39
|
+
body.append(input.name, input.value);
|
|
40
|
+
}
|
|
32
41
|
}
|
|
33
42
|
}
|
|
34
43
|
for (const select of [...form.querySelectorAll("select")]) {
|
|
@@ -41,11 +50,14 @@ export function useEnhance(schema, onSuccess) {
|
|
|
41
50
|
body.append(textarea.name, textarea.value);
|
|
42
51
|
}
|
|
43
52
|
}
|
|
53
|
+
|
|
44
54
|
const response = await fetch(form.action, {
|
|
45
|
-
body: body.toString(),
|
|
55
|
+
body: body instanceof FormData ? body : body.toString(),
|
|
46
56
|
headers: {
|
|
47
57
|
Accept: "application/json",
|
|
48
|
-
|
|
58
|
+
...(body instanceof FormData
|
|
59
|
+
? {}
|
|
60
|
+
: { "Content-Type": "application/x-www-form-urlencoded" }),
|
|
49
61
|
Cookie: jsdomCookie.setCookie.join("; "),
|
|
50
62
|
},
|
|
51
63
|
method: form.method,
|