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 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
- - _TODO_ Multipart forms
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "evikit",
3
3
  "description": "Preact/SQLite SSR framework for small web apps",
4
- "version": "2.0.0",
4
+ "version": "2.2.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./index.js",
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": { schema: any };
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
- ...(typeof body[name] == "string"
49
- ? [body[name]]
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
- "application/x-www-form-urlencoded": {
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
- { ignoreActions: ["trim"] },
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
- : fail(name),
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 = new URLSearchParams();
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
- !input.disabled &&
29
- ((input.type != "checkbox" && input.type != "radio") || input.checked)
30
- ) {
31
- body.append(input.name, input.value);
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
- "Content-Type": "application/x-www-form-urlencoded",
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,