@hyperspan/framework 0.1.3 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspan/framework",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "main": "dist/server.js",
6
6
  "public": true,
@@ -9,15 +9,19 @@
9
9
  },
10
10
  "exports": {
11
11
  ".": {
12
- "types": "./dist/server.d.ts",
12
+ "types": "./src/server.ts",
13
13
  "default": "./dist/server.js"
14
14
  },
15
15
  "./server": {
16
- "types": "./dist/server.d.ts",
16
+ "types": "./src/server.ts",
17
17
  "default": "./dist/server.js"
18
18
  },
19
+ "./actions": {
20
+ "types": "./src/actions.ts",
21
+ "default": "./src/actions.ts"
22
+ },
19
23
  "./assets": {
20
- "types": "./dist/assets.d.ts",
24
+ "types": "./src/assets.ts",
21
25
  "default": "./dist/assets.js"
22
26
  }
23
27
  },
@@ -59,7 +63,7 @@
59
63
  "typescript": "^5.0.0"
60
64
  },
61
65
  "dependencies": {
62
- "@hyperspan/html": "^0.1.2",
66
+ "@hyperspan/html": "^0.1.4",
63
67
  "@preact/compat": "^18.3.1",
64
68
  "hono": "^4.7.4",
65
69
  "isbot": "^5.1.25",
@@ -0,0 +1,95 @@
1
+ import z from 'zod';
2
+ import { createAction, THSFormData } from './actions';
3
+ import { describe, it, expect } from 'bun:test';
4
+ import { html, render, type TmplHtml } from '@hyperspan/html';
5
+ import type { Context } from 'hono';
6
+
7
+ describe('createAction', () => {
8
+ const formWithNameOnly = ({ data }: THSFormData) => {
9
+ return html`
10
+ <form>
11
+ <p>
12
+ Name:
13
+ <input type="text" name="name" value="${data.name || ''}" />
14
+ </p>
15
+ <button type="submit">Submit</button>
16
+ </form>
17
+ `;
18
+ };
19
+
20
+ describe('with form content', () => {
21
+ it('should create an action with a form that renders provided data', async () => {
22
+ const schema = z.object({
23
+ name: z.string(),
24
+ });
25
+ const action = createAction(schema).form(formWithNameOnly);
26
+
27
+ const formResponse = render(action.render({ data: { name: 'John' } }) as TmplHtml);
28
+ expect(formResponse).toContain('value="John"');
29
+ });
30
+ });
31
+
32
+ describe('when data is valid', () => {
33
+ it('should run the handler and return the result', async () => {
34
+ const schema = z.object({
35
+ name: z.string().nonempty(),
36
+ });
37
+ const action = createAction(schema)
38
+ .form(formWithNameOnly)
39
+ .handler((c, { data }) => {
40
+ return html`<div>Thanks for submitting the form, ${data.name}!</div>`;
41
+ })
42
+ .error((c, { error }) => {
43
+ return html`<div>There was an error! ${error?.message}</div>`;
44
+ });
45
+
46
+ // Mock context to run action
47
+ const mockContext = {
48
+ req: {
49
+ formData: async () => {
50
+ const formData = new FormData();
51
+ formData.append('name', 'John');
52
+ return formData;
53
+ },
54
+ },
55
+ } as Context;
56
+
57
+ const response = await action.run('POST', mockContext);
58
+
59
+ const formResponse = render(response as TmplHtml);
60
+ expect(formResponse).toContain('Thanks for submitting the form, John!');
61
+ });
62
+ });
63
+
64
+ describe('when data is invalid', () => {
65
+ it('should return the content of the form with error', async () => {
66
+ const schema = z.object({
67
+ name: z.string().nonempty(),
68
+ });
69
+ const action = createAction(schema)
70
+ .form(formWithNameOnly)
71
+ .handler((c, { data }) => {
72
+ return html`<div>Thanks for submitting the form, ${data.name}!</div>`;
73
+ })
74
+ .error((c, { error }) => {
75
+ return html`<div>There was an error! ${error?.message}</div>`;
76
+ });
77
+
78
+ // Mock context to run action
79
+ const mockContext = {
80
+ req: {
81
+ formData: async () => {
82
+ const formData = new FormData();
83
+ formData.append('name', ''); // No name = error
84
+ return formData;
85
+ },
86
+ },
87
+ } as Context;
88
+
89
+ const response = await action.run('POST', mockContext);
90
+
91
+ const formResponse = render(response as TmplHtml);
92
+ expect(formResponse).toContain('There was an error!');
93
+ });
94
+ });
95
+ });
package/src/actions.ts ADDED
@@ -0,0 +1,189 @@
1
+ import { html } from '@hyperspan/html';
2
+ import * as z from 'zod';
3
+ import { HTTPException } from 'hono/http-exception';
4
+
5
+ import type { THSResponseTypes } from './server';
6
+ import type { Context } from 'hono';
7
+
8
+ /**
9
+ * Actions = Form + route handler
10
+ * Automatically handles and parses form data
11
+ *
12
+ * INITIAL IDEA OF HOW THIS WILL WORK:
13
+ * ---
14
+ * 1. Renders component as initial form markup for GET request
15
+ * 2. Bind form onSubmit function to custom client JS handling
16
+ * 3. Submits form with JavaScript fetch()
17
+ * 4. Replaces form content with content from server
18
+ * 5. All validation and save logic is on the server
19
+ * 6. Handles any Exception thrown on server as error displayed in client
20
+ */
21
+ export interface HSAction<T extends z.ZodTypeAny> {
22
+ _kind: string;
23
+ form(renderForm: (data: z.infer<T>) => THSResponseTypes): HSAction<T>;
24
+ handler(handler: (c: Context, { data }: { data: z.infer<T> }) => THSResponseTypes): HSAction<T>;
25
+ error(
26
+ handler: (
27
+ c: Context,
28
+ { data, error }: { data: z.infer<T>; error?: z.ZodError | Error }
29
+ ) => THSResponseTypes
30
+ ): HSAction<T>;
31
+ render(props?: { data: z.infer<T>; error?: z.ZodError | Error }): THSResponseTypes;
32
+ run(method: 'GET' | 'POST', c: Context): Promise<THSResponseTypes>;
33
+ }
34
+
35
+ export function createAction<T extends z.ZodTypeAny>(schema: T | null = null) {
36
+ let _handler: Parameters<HSAction<T>['handler']>[0] | null = null,
37
+ _form: Parameters<HSAction<T>['form']>[0] | null = null,
38
+ _errorHandler: Parameters<HSAction<T>['error']>[0] | null = null;
39
+
40
+ const api: HSAction<T> = {
41
+ _kind: 'hsAction',
42
+ form(renderForm) {
43
+ _form = renderForm;
44
+ return api;
45
+ },
46
+
47
+ /**
48
+ * Process form data
49
+ *
50
+ * Returns result from form processing if successful
51
+ * Re-renders form with data and error information otherwise
52
+ */
53
+ handler(handler) {
54
+ _handler = handler;
55
+ return api;
56
+ },
57
+
58
+ error(handler) {
59
+ _errorHandler = handler;
60
+ return api;
61
+ },
62
+
63
+ /**
64
+ * Get form renderer method
65
+ */
66
+ render(data) {
67
+ const form = _form ? _form(data || { data: {} }) : null;
68
+ return form ? html`<hs-action>${form}</hs-action>` : null;
69
+ },
70
+
71
+ /**
72
+ * Run action
73
+ *
74
+ * Returns result from form processing if successful
75
+ * Re-renders form with data and error information otherwise
76
+ */
77
+ async run(method: 'GET' | 'POST', c: Context) {
78
+ if (method === 'GET') {
79
+ return api.render();
80
+ }
81
+
82
+ if (method !== 'POST') {
83
+ throw new HTTPException(405, { message: 'Actions only support GET and POST requests' });
84
+ }
85
+
86
+ const formData = await c.req.formData();
87
+ const jsonData = formDataToJSON(formData);
88
+ const schemaData = schema ? schema.safeParse(jsonData) : null;
89
+ const data = schemaData?.success ? (schemaData.data as z.infer<T>) : {};
90
+ let error: z.ZodError | Error | null = null;
91
+
92
+ try {
93
+ if (schema && schemaData?.error) {
94
+ throw schemaData.error;
95
+ }
96
+
97
+ if (!_handler) {
98
+ throw new Error('Action handler not set! Every action must have a handler.');
99
+ }
100
+
101
+ return _handler(c, { data });
102
+ } catch (e) {
103
+ error = e as Error | z.ZodError;
104
+ }
105
+
106
+ if (error && _errorHandler) {
107
+ return _errorHandler(c, { data, error });
108
+ }
109
+
110
+ return api.render({ data, error });
111
+ },
112
+ };
113
+
114
+ return api;
115
+ }
116
+
117
+ /**
118
+ * Form route handler helper
119
+ */
120
+ export type THSHandlerResponse = (context: Context) => THSResponseTypes | Promise<THSResponseTypes>;
121
+
122
+ /**
123
+ * Return JSON data structure for a given FormData object
124
+ * Accounts for array fields (e.g. name="options[]" or <select multiple>)
125
+ *
126
+ * @link https://stackoverflow.com/a/75406413
127
+ */
128
+ export function formDataToJSON(formData: FormData): Record<string, string | string[]> {
129
+ let object = {};
130
+
131
+ /**
132
+ * Parses FormData key xxx`[x][x][x]` fields into array
133
+ */
134
+ const parseKey = (key: string) => {
135
+ const subKeyIdx = key.indexOf('[');
136
+
137
+ if (subKeyIdx !== -1) {
138
+ const keys = [key.substring(0, subKeyIdx)];
139
+ key = key.substring(subKeyIdx);
140
+
141
+ for (const match of key.matchAll(/\[(?<key>.*?)]/gm)) {
142
+ if (match.groups) {
143
+ keys.push(match.groups.key);
144
+ }
145
+ }
146
+ return keys;
147
+ } else {
148
+ return [key];
149
+ }
150
+ };
151
+
152
+ /**
153
+ * Recursively iterates over keys and assigns key/values to object
154
+ */
155
+ const assign = (keys: string[], value: FormDataEntryValue, object: any): void => {
156
+ const key = keys.shift();
157
+
158
+ // When last key in the iterations
159
+ if (key === '' || key === undefined) {
160
+ return object.push(value);
161
+ }
162
+
163
+ if (Reflect.has(object, key)) {
164
+ // If key has been found, but final pass - convert the value to array
165
+ if (keys.length === 0) {
166
+ if (!Array.isArray(object[key])) {
167
+ object[key] = [object[key], value];
168
+ return;
169
+ }
170
+ }
171
+ // Recurse again with found object
172
+ return assign(keys, value, object[key]);
173
+ }
174
+
175
+ // Create empty object for key, if next key is '' do array instead, otherwise set value
176
+ if (keys.length >= 1) {
177
+ object[key] = keys[0] === '' ? [] : {};
178
+ return assign(keys, value, object[key]);
179
+ } else {
180
+ object[key] = value;
181
+ }
182
+ };
183
+
184
+ for (const pair of formData.entries()) {
185
+ assign(parseKey(pair[0]), pair[1], object);
186
+ }
187
+
188
+ return object;
189
+ }
@@ -1,5 +1,5 @@
1
- import {html} from '@hyperspan/html';
2
- import {Idiomorph} from './idiomorph.esm';
1
+ import { html } from '@hyperspan/html';
2
+ import { Idiomorph } from './idiomorph.esm';
3
3
 
4
4
  /**
5
5
  * Used for streaming content from the server to the client.
@@ -20,7 +20,7 @@ function htmlAsyncContentObserver() {
20
20
  asyncContent.forEach((el: any) => {
21
21
  try {
22
22
  // Also observe child nodes for nested async content
23
- asyncContentObserver.observe(el.content, {childList: true, subtree: true});
23
+ asyncContentObserver.observe(el.content, { childList: true, subtree: true });
24
24
 
25
25
  const slotId = el.id.replace('_content', '');
26
26
  const slotEl = document.getElementById(slotId);
@@ -41,10 +41,71 @@ function htmlAsyncContentObserver() {
41
41
  }
42
42
  });
43
43
  });
44
- asyncContentObserver.observe(document.body, {childList: true, subtree: true});
44
+ asyncContentObserver.observe(document.body, { childList: true, subtree: true });
45
45
  }
46
46
  }
47
47
  htmlAsyncContentObserver();
48
48
 
49
+ /**
50
+ * Server action component to handle the client-side form submission and HTML replacement
51
+ */
52
+ class HSAction extends HTMLElement {
53
+ constructor() {
54
+ super();
55
+ }
56
+
57
+ // Element is mounted in the DOM
58
+ connectedCallback() {
59
+ const form = this.querySelector('form');
60
+
61
+ if (form) {
62
+ form.addEventListener('submit', (e) => {
63
+ formSubmitToRoute(e, form as HTMLFormElement);
64
+ });
65
+ }
66
+ }
67
+ }
68
+ window.customElements.define('hs-action', HSAction);
69
+
70
+ /**
71
+ * Submit form data to route and replace contents with response
72
+ */
73
+ function formSubmitToRoute(e: Event, form: HTMLFormElement) {
74
+ e.preventDefault();
75
+
76
+ const formUrl = form.getAttribute('action') || '';
77
+ const formData = new FormData(form);
78
+ const method = form.getAttribute('method')?.toUpperCase() || 'POST';
79
+
80
+ let response: Response;
81
+
82
+ fetch(formUrl, { body: formData, method })
83
+ .then((res: Response) => {
84
+ // @TODO: Handle redirects with some custom server thing?
85
+ // This... actually won't work, because fetch automatically follows all redirects (a 3xx response will never be returned to the client)
86
+ const isRedirect = [301, 302].includes(res.status);
87
+
88
+ // Is response a redirect? If so, let's follow it in the client!
89
+ if (isRedirect) {
90
+ const newUrl = res.headers.get('Location');
91
+ if (newUrl) {
92
+ window.location.assign(newUrl);
93
+ }
94
+ return '';
95
+ }
96
+
97
+ response = res;
98
+ return res.text();
99
+ })
100
+ .then((content: string) => {
101
+ // No content = DO NOTHING (redirect or something else happened)
102
+ if (!content) {
103
+ return;
104
+ }
105
+
106
+ Idiomorph.morph(form, content);
107
+ });
108
+ }
109
+
49
110
  // @ts-ignore
50
111
  window.html = html;