@hyperspan/framework 0.1.2 → 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/build.ts +22 -20
- package/dist/assets.d.ts +1 -1
- package/dist/assets.js +3 -159
- package/dist/server.d.ts +33 -67
- package/dist/server.js +183 -562
- package/package.json +16 -8
- package/src/actions.test.ts +95 -0
- package/src/actions.ts +189 -0
- package/src/assets.ts +11 -10
- package/src/clientjs/hyperspan-client.ts +65 -4
- package/src/server.ts +192 -220
- package/dist/index.d.ts +0 -132
- package/dist/index.js +0 -2477
- package/src/index.ts +0 -1
package/package.json
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspan/framework",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Hyperspan Web Framework",
|
|
5
|
-
"main": "dist/
|
|
5
|
+
"main": "dist/server.js",
|
|
6
6
|
"public": true,
|
|
7
7
|
"publishConfig": {
|
|
8
8
|
"access": "public"
|
|
9
9
|
},
|
|
10
10
|
"exports": {
|
|
11
11
|
".": {
|
|
12
|
-
"types": "./
|
|
13
|
-
"default": "./dist/
|
|
12
|
+
"types": "./src/server.ts",
|
|
13
|
+
"default": "./dist/server.js"
|
|
14
|
+
},
|
|
15
|
+
"./server": {
|
|
16
|
+
"types": "./src/server.ts",
|
|
17
|
+
"default": "./dist/server.js"
|
|
18
|
+
},
|
|
19
|
+
"./actions": {
|
|
20
|
+
"types": "./src/actions.ts",
|
|
21
|
+
"default": "./src/actions.ts"
|
|
14
22
|
},
|
|
15
23
|
"./assets": {
|
|
16
|
-
"types": "./
|
|
24
|
+
"types": "./src/assets.ts",
|
|
17
25
|
"default": "./dist/assets.js"
|
|
18
26
|
}
|
|
19
27
|
},
|
|
@@ -38,10 +46,10 @@
|
|
|
38
46
|
"url": "https://github.com/vlucas/hyperspan/issues"
|
|
39
47
|
},
|
|
40
48
|
"scripts": {
|
|
41
|
-
"build": "bun ./build.ts",
|
|
49
|
+
"build": "bun ./build.ts && sed -i '' -e '$ d' dist/assets.js",
|
|
42
50
|
"clean": "rm -rf dist",
|
|
43
51
|
"test": "bun test",
|
|
44
|
-
"prepack": "
|
|
52
|
+
"prepack": "bun run clean && bun run build"
|
|
45
53
|
},
|
|
46
54
|
"devDependencies": {
|
|
47
55
|
"@types/bun": "^1.1.9",
|
|
@@ -55,7 +63,7 @@
|
|
|
55
63
|
"typescript": "^5.0.0"
|
|
56
64
|
},
|
|
57
65
|
"dependencies": {
|
|
58
|
-
"@hyperspan/html": "^0.1.
|
|
66
|
+
"@hyperspan/html": "^0.1.4",
|
|
59
67
|
"@preact/compat": "^18.3.1",
|
|
60
68
|
"hono": "^4.7.4",
|
|
61
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
|
+
}
|
package/src/assets.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {html} from '@hyperspan/html';
|
|
2
|
-
import {md5} from './clientjs/md5';
|
|
3
|
-
import {readdir} from 'node:fs/promises';
|
|
4
|
-
import {resolve} from 'node:path';
|
|
1
|
+
import { html } from '@hyperspan/html';
|
|
2
|
+
import { md5 } from './clientjs/md5';
|
|
3
|
+
import { readdir } from 'node:fs/promises';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
5
|
|
|
6
6
|
const IS_PROD = process.env.NODE_ENV === 'production';
|
|
7
7
|
const PWD = import.meta.dir;
|
|
@@ -9,7 +9,7 @@ const PWD = import.meta.dir;
|
|
|
9
9
|
/**
|
|
10
10
|
* Build client JS for end users (minimal JS for Hyperspan to work)
|
|
11
11
|
*/
|
|
12
|
-
export const clientJSFiles = new Map<string, {src: string; type?: string}>();
|
|
12
|
+
export const clientJSFiles = new Map<string, { src: string; type?: string }>();
|
|
13
13
|
export async function buildClientJS() {
|
|
14
14
|
const sourceFile = resolve(PWD, '../', './src/clientjs/hyperspan-client.ts');
|
|
15
15
|
const output = await Bun.build({
|
|
@@ -20,8 +20,8 @@ export async function buildClientJS() {
|
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
const jsFile = output.outputs[0].path.split('/').reverse()[0];
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
|
|
24
|
+
clientJSFiles.set('_hs', { src: '/_hs/js/' + jsFile });
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
@@ -71,6 +71,7 @@ export function hyperspanStyleTags() {
|
|
|
71
71
|
*/
|
|
72
72
|
export function hyperspanScriptTags() {
|
|
73
73
|
const jsFiles = Array.from(clientJSFiles.entries());
|
|
74
|
+
|
|
74
75
|
return html`
|
|
75
76
|
<script type="importmap">
|
|
76
77
|
{
|
|
@@ -84,13 +85,13 @@ export function hyperspanScriptTags() {
|
|
|
84
85
|
}
|
|
85
86
|
</script>
|
|
86
87
|
${jsFiles.map(
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
([key, file]) =>
|
|
89
|
+
html`<script
|
|
89
90
|
id="js-${key}"
|
|
90
91
|
type="${file.type || 'text/javascript'}"
|
|
91
92
|
src="${file.src}"
|
|
92
93
|
></script>`
|
|
93
|
-
|
|
94
|
+
)}
|
|
94
95
|
`;
|
|
95
96
|
}
|
|
96
97
|
|
|
@@ -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;
|