@hyperspan/framework 0.5.5 → 1.0.0-alpha.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.
@@ -1,106 +0,0 @@
1
- import { z } from 'zod/v4';
2
- import { unstable__createAction } from './actions';
3
- import { describe, it, expect } from 'bun:test';
4
- import { html, render, type HSHtml } from '@hyperspan/html';
5
- import type { THSContext } from './server';
6
-
7
- describe('createAction', () => {
8
- const formWithNameOnly = (c: THSContext, { data }: { data?: { name: string } }) => {
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 = unstable__createAction(schema, formWithNameOnly);
26
- const mockContext = {
27
- req: {
28
- method: 'POST',
29
- formData: async () => {
30
- const formData = new FormData();
31
- formData.append('name', 'John');
32
- return formData;
33
- },
34
- },
35
- } as THSContext;
36
-
37
- const formResponse = render(action.render(mockContext, { data: { name: 'John' } }) as HSHtml);
38
- expect(formResponse).toContain('value="John"');
39
- });
40
- });
41
-
42
- describe('when data is valid', () => {
43
- it('should run the handler and return the result', async () => {
44
- const schema = z.object({
45
- name: z.string().nonempty(),
46
- });
47
- const action = unstable__createAction(schema, formWithNameOnly)
48
- .post((c, { data }) => {
49
- return html`<div>Thanks for submitting the form, ${data?.name}!</div>`;
50
- })
51
- .error((c, { error }) => {
52
- return html`<div>There was an error! ${error?.message}</div>`;
53
- });
54
-
55
- // Mock context to run action
56
- const mockContext = {
57
- req: {
58
- method: 'POST',
59
- formData: async () => {
60
- const formData = new FormData();
61
- formData.append('name', 'John');
62
- return formData;
63
- },
64
- },
65
- } as THSContext;
66
-
67
- const response = await action.run(mockContext);
68
-
69
- const formResponse = render(response as HSHtml);
70
- expect(formResponse).toContain('Thanks for submitting the form, John!');
71
- });
72
- });
73
-
74
- describe.skip('when data is invalid', () => {
75
- it('should return the content of the form with error', async () => {
76
- const schema = z.object({
77
- name: z.string().nonempty(),
78
- });
79
- const action = unstable__createAction(schema)
80
- .form(formWithNameOnly)
81
- .post((c, { data }) => {
82
- return html`<div>Thanks for submitting the form, ${data?.name}!</div>`;
83
- })
84
- .error((c, { error }) => {
85
- return html`<div>There was an error! ${error?.message}</div>`;
86
- });
87
-
88
- // Mock context to run action
89
- const mockContext = {
90
- req: {
91
- method: 'POST',
92
- formData: async () => {
93
- const formData = new FormData();
94
- formData.append('name', ''); // No name = error
95
- return formData;
96
- },
97
- },
98
- } as THSContext;
99
-
100
- const response = await action.run(mockContext);
101
-
102
- const formResponse = render(response as HSHtml);
103
- expect(formResponse).toContain('There was an error!');
104
- });
105
- });
106
- });
package/src/actions.ts DELETED
@@ -1,256 +0,0 @@
1
- import { html, HSHtml } from '@hyperspan/html';
2
- import * as z from 'zod/v4';
3
- import { HTTPException } from 'hono/http-exception';
4
- import { assetHash } from './assets';
5
- import { IS_PROD, returnHTMLResponse, type THSContext, type THSResponseTypes } from './server';
6
- import type { MiddlewareHandler } from 'hono';
7
- import type { HandlerResponse, Next, TypedResponse } from 'hono/types';
8
-
9
- /**
10
- * Actions = Form + route handler
11
- * Automatically handles and parses form data
12
- *
13
- * HOW THIS WORKS:
14
- * ---
15
- * 1. Renders in any template as initial form markup with action.render()
16
- * 2. Binds form onSubmit function to custom client JS handling via <hs-action> web component
17
- * 3. Submits form with JavaScript fetch() + FormData as normal POST form submission
18
- * 4. All validation and save logic is run on the server
19
- * 5. Replaces form content in place with HTML response content from server via the Idiomorph library
20
- * 6. Handles any Exception thrown on server as error displayed back to user on the page
21
- */
22
- export type TActionResponse =
23
- | THSResponseTypes
24
- | HandlerResponse<any>
25
- | TypedResponse<any, any, any>;
26
- export interface HSAction<T extends z.ZodTypeAny> {
27
- _kind: string;
28
- _route: string;
29
- _form: Parameters<HSAction<T>['form']>[0];
30
- form(
31
- renderForm: (
32
- c: THSContext,
33
- { data, error }: { data?: Partial<z.infer<T>>; error?: z.ZodError | Error }
34
- ) => HSHtml | void | null | Promise<HSHtml | void | null>
35
- ): HSAction<T>;
36
- post(
37
- handler: (
38
- c: THSContext,
39
- { data }: { data?: Partial<z.infer<T>> }
40
- ) => TActionResponse | Promise<TActionResponse>
41
- ): HSAction<T>;
42
- error(
43
- handler: (
44
- c: THSContext,
45
- { data, error }: { data?: Partial<z.infer<T>>; error?: z.ZodError | Error }
46
- ) => TActionResponse
47
- ): HSAction<T>;
48
- render(
49
- c: THSContext,
50
- props?: { data?: Partial<z.infer<T>>; error?: z.ZodError | Error }
51
- ): TActionResponse;
52
- run(c: THSContext): TActionResponse | Promise<TActionResponse>;
53
- middleware: (
54
- middleware: Array<
55
- | MiddlewareHandler
56
- | ((context: THSContext) => TActionResponse | Promise<TActionResponse> | void | Promise<void>)
57
- >
58
- ) => HSAction<T>;
59
- _getRouteHandlers: () => Array<
60
- | MiddlewareHandler
61
- | ((context: THSContext, next: Next) => TActionResponse | Promise<TActionResponse>)
62
- | ((context: THSContext) => TActionResponse | Promise<TActionResponse>)
63
- >;
64
- }
65
-
66
- export function unstable__createAction<T extends z.ZodTypeAny>(
67
- schema: T | null = null,
68
- form: Parameters<HSAction<T>['form']>[0]
69
- ) {
70
- let _handler: Parameters<HSAction<T>['post']>[0] | null = null,
71
- _form: Parameters<HSAction<T>['form']>[0] = form,
72
- _errorHandler: Parameters<HSAction<T>['error']>[0] | null = null,
73
- _middleware: Array<
74
- | MiddlewareHandler
75
- | ((context: THSContext, next: Next) => TActionResponse | Promise<TActionResponse>)
76
- | ((context: THSContext) => TActionResponse | Promise<TActionResponse>)
77
- > = [];
78
-
79
- const api: HSAction<T> = {
80
- _kind: 'hsAction',
81
- _route: `/__actions/${assetHash(_form.toString())}`,
82
- _form,
83
- form(renderForm) {
84
- _form = renderForm;
85
- return api;
86
- },
87
- /**
88
- * Process form data
89
- *
90
- * Returns result from form processing if successful
91
- * Re-renders form with data and error information otherwise
92
- */
93
- post(handler) {
94
- _handler = handler;
95
- return api;
96
- },
97
- /**
98
- * Cusotm error handler if you want to display something other than the default
99
- */
100
- error(handler) {
101
- _errorHandler = handler;
102
- return api;
103
- },
104
- /**
105
- * Add middleware specific to this route
106
- */
107
- middleware(middleware) {
108
- _middleware = middleware;
109
- return api;
110
- },
111
- /**
112
- * Get form renderer method
113
- */
114
- render(c: THSContext, formState?: { data?: Partial<z.infer<T>>; error?: z.ZodError | Error }) {
115
- const form = _form ? _form(c, formState || {}) : null;
116
- return form ? html`<hs-action url="${this._route}">${form}</hs-action>` : null;
117
- },
118
-
119
- _getRouteHandlers() {
120
- return [
121
- ..._middleware,
122
- async (c: THSContext) => {
123
- const response = await returnHTMLResponse(c, () => api.run(c));
124
-
125
- // Replace redirects with special header because fetch() automatically follows redirects
126
- // and we want to redirect the user to the actual full page instead
127
- if ([301, 302, 307, 308].includes(response.status)) {
128
- response.headers.set('X-Redirect-Location', response.headers.get('Location') || '/');
129
- response.headers.delete('Location');
130
- }
131
-
132
- return response;
133
- },
134
- ];
135
- },
136
-
137
- /**
138
- * Run action
139
- *
140
- * Returns result from form processing if successful
141
- * Re-renders form with data and error information otherwise
142
- */
143
- async run(c) {
144
- const method = c.req.method;
145
-
146
- if (method === 'GET') {
147
- return await api.render(c);
148
- }
149
-
150
- if (method !== 'POST') {
151
- throw new HTTPException(405, { message: 'Actions only support GET and POST requests' });
152
- }
153
-
154
- const formData = await c.req.formData();
155
- const jsonData = unstable__formDataToJSON(formData) as Partial<z.infer<T>>;
156
- const schemaData = schema ? schema.safeParse(jsonData) : null;
157
- const data = schemaData?.success ? (schemaData.data as Partial<z.infer<T>>) : jsonData;
158
- let error: z.ZodError | Error | null = null;
159
-
160
- try {
161
- if (schema && schemaData?.error) {
162
- throw schemaData.error;
163
- }
164
-
165
- if (!_handler) {
166
- throw new Error('Action POST handler not set! Every action must have a POST handler.');
167
- }
168
-
169
- return await _handler(c, { data });
170
- } catch (e) {
171
- error = e as Error | z.ZodError;
172
- !IS_PROD && console.error(error);
173
- }
174
-
175
- if (error && _errorHandler) {
176
- // @ts-ignore
177
- return await returnHTMLResponse(c, () => _errorHandler(c, { data, error }), {
178
- status: 400,
179
- });
180
- }
181
-
182
- return await returnHTMLResponse(c, () => api.render(c, { data, error }), { status: 400 });
183
- },
184
- };
185
-
186
- return api;
187
- }
188
-
189
- /**
190
- * Return JSON data structure for a given FormData object
191
- * Accounts for array fields (e.g. name="options[]" or <select multiple>)
192
- *
193
- * @link https://stackoverflow.com/a/75406413
194
- */
195
- export function unstable__formDataToJSON(formData: FormData): Record<string, string | string[]> {
196
- let object = {};
197
-
198
- /**
199
- * Parses FormData key xxx`[x][x][x]` fields into array
200
- */
201
- const parseKey = (key: string) => {
202
- const subKeyIdx = key.indexOf('[');
203
-
204
- if (subKeyIdx !== -1) {
205
- const keys = [key.substring(0, subKeyIdx)];
206
- key = key.substring(subKeyIdx);
207
-
208
- for (const match of key.matchAll(/\[(?<key>.*?)]/gm)) {
209
- if (match.groups) {
210
- keys.push(match.groups.key);
211
- }
212
- }
213
- return keys;
214
- } else {
215
- return [key];
216
- }
217
- };
218
-
219
- /**
220
- * Recursively iterates over keys and assigns key/values to object
221
- */
222
- const assign = (keys: string[], value: FormDataEntryValue, object: any): void => {
223
- const key = keys.shift();
224
-
225
- // When last key in the iterations
226
- if (key === '' || key === undefined) {
227
- return object.push(value);
228
- }
229
-
230
- if (Reflect.has(object, key)) {
231
- // If key has been found, but final pass - convert the value to array
232
- if (keys.length === 0) {
233
- if (!Array.isArray(object[key])) {
234
- object[key] = [object[key], value];
235
- return;
236
- }
237
- }
238
- // Recurse again with found object
239
- return assign(keys, value, object[key]);
240
- }
241
-
242
- // Create empty object for key, if next key is '' do array instead, otherwise set value
243
- if (keys.length >= 1) {
244
- object[key] = keys[0] === '' ? [] : {};
245
- return assign(keys, value, object[key]);
246
- } else {
247
- object[key] = value;
248
- }
249
- };
250
-
251
- for (const pair of formData.entries()) {
252
- assign(parseKey(pair[0]), pair[1], object);
253
- }
254
-
255
- return object;
256
- }
package/src/assets.ts DELETED
@@ -1,176 +0,0 @@
1
- import { html } from '@hyperspan/html';
2
- import { createHash } from 'node:crypto';
3
- import { readdir } from 'node:fs/promises';
4
- import { resolve } from 'node:path';
5
-
6
- export type THSIslandOptions = {
7
- ssr?: boolean;
8
- loading?: 'lazy' | undefined;
9
- };
10
-
11
- const IS_PROD = process.env.NODE_ENV === 'production';
12
- const PWD = import.meta.dir;
13
-
14
- export const CLIENTJS_PUBLIC_PATH = '/_hs/js';
15
- export const ISLAND_PUBLIC_PATH = '/_hs/js/islands';
16
- export const clientImportMap = new Map<string, string>();
17
-
18
- /**
19
- * Build client JS for end users (minimal JS for Hyperspan to work)
20
- */
21
- export const clientJSFiles = new Map<string, { src: string; type?: string }>();
22
- export async function buildClientJS() {
23
- const sourceFile = resolve(PWD, '../', './src/clientjs/hyperspan-client.ts');
24
- const output = await Bun.build({
25
- entrypoints: [sourceFile],
26
- outdir: `./public/${CLIENTJS_PUBLIC_PATH}`,
27
- naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
28
- minify: IS_PROD,
29
- });
30
-
31
- const jsFile = output.outputs[0].path.split('/').reverse()[0];
32
-
33
- clientJSFiles.set('_hs', { src: `${CLIENTJS_PUBLIC_PATH}/${jsFile}` });
34
- }
35
-
36
- /**
37
- * Render a client JS module as a script tag
38
- */
39
- export function renderClientJS<T>(module: T, loadScript?: ((module: T) => void) | string) {
40
- // @ts-ignore
41
- if (!module.__CLIENT_JS) {
42
- throw new Error(
43
- `[Hyperspan] Client JS was not loaded by Hyperspan! Ensure the filename ends with .client.ts to use this render method.`
44
- );
45
- }
46
-
47
- return html.raw(
48
- // @ts-ignore
49
- module.__CLIENT_JS.renderScriptTag({
50
- loadScript: loadScript
51
- ? typeof loadScript === 'string'
52
- ? loadScript
53
- : functionToString(loadScript)
54
- : undefined,
55
- })
56
- );
57
- }
58
-
59
- /**
60
- * Convert a function to a string (results in loss of context!)
61
- * Handles named, async, and arrow functions
62
- */
63
- export function functionToString(fn: any) {
64
- let str = fn.toString().trim();
65
-
66
- // Ensure consistent output & handle async
67
- if (!str.includes('function ')) {
68
- if (str.includes('async ')) {
69
- str = 'async function ' + str.replace('async ', '');
70
- } else {
71
- str = 'function ' + str;
72
- }
73
- }
74
-
75
- const lines = str.split('\n');
76
- const firstLine = lines[0];
77
- const lastLine = lines[lines.length - 1];
78
-
79
- // Arrow function conversion
80
- if (!lastLine?.includes('}')) {
81
- return str.replace('=> ', '{ return ') + '; }';
82
- }
83
-
84
- // Cleanup arrow function
85
- if (firstLine.includes('=>')) {
86
- return str.replace('=> ', '');
87
- }
88
-
89
- return str;
90
- }
91
-
92
- /**
93
- * Find client CSS file built for end users
94
- * @TODO: Build this in code here vs. relying on tailwindcss CLI tool from package scripts
95
- */
96
- export const clientCSSFiles = new Map<string, string>();
97
- export async function buildClientCSS() {
98
- if (clientCSSFiles.has('_hs')) {
99
- return clientCSSFiles.get('_hs');
100
- }
101
-
102
- // Find file already built from tailwindcss CLI
103
- const cssDir = './public/_hs/css/';
104
- const cssFiles = await readdir(cssDir);
105
- let foundCSSFile: string = '';
106
-
107
- for (const file of cssFiles) {
108
- // Only looking for CSS files
109
- if (!file.endsWith('.css')) {
110
- continue;
111
- }
112
-
113
- foundCSSFile = file.replace(cssDir, '');
114
- clientCSSFiles.set('_hs', foundCSSFile);
115
- break;
116
- }
117
-
118
- if (!foundCSSFile) {
119
- console.log(`Unable to build CSS files from ${cssDir}`);
120
- }
121
- }
122
-
123
- /**
124
- * Output HTML style tag for Hyperspan app
125
- */
126
- export function hyperspanStyleTags() {
127
- const cssFiles = Array.from(clientCSSFiles.entries());
128
- return html`${cssFiles.map(
129
- ([_, file]) => html`<link rel="stylesheet" href="/_hs/css/${file}" />`
130
- )}`;
131
- }
132
-
133
- /**
134
- * Output HTML script tag for Hyperspan app
135
- * Required for functioning streaming so content can pop into place properly once ready
136
- */
137
- export function hyperspanScriptTags() {
138
- const jsFiles = Array.from(clientJSFiles.entries());
139
-
140
- return html`
141
- <script type="importmap">
142
- {"imports": ${Object.fromEntries(clientImportMap)}}
143
- </script>
144
- ${jsFiles.map(
145
- ([key, file]) =>
146
- html`<script
147
- id="js-${key}"
148
- type="${file.type || 'text/javascript'}"
149
- src="${file.src}"
150
- ></script>`
151
- )}
152
- `;
153
- }
154
-
155
- export function assetHash(content: string): string {
156
- return createHash('md5').update(content).digest('hex');
157
- }
158
-
159
- /**
160
- * Island defaults
161
- */
162
- export const ISLAND_DEFAULTS: () => THSIslandOptions = () => ({
163
- ssr: true,
164
- loading: undefined,
165
- });
166
-
167
- export function renderIsland(Component: any, props: any, options = ISLAND_DEFAULTS()) {
168
- // Render island with its own logic
169
- if (Component.__HS_ISLAND?.render) {
170
- return html.raw(Component.__HS_ISLAND.render(props, options));
171
- }
172
-
173
- throw new Error(
174
- `Module ${Component.name} was not loaded with an island plugin! Did you forget to install an island plugin and add it to the createServer() 'islandPlugins' config?`
175
- );
176
- }