@hyperspan/framework 1.0.0-alpha.3 → 1.0.0-alpha.5
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 +4 -4
- package/src/actions.ts +22 -130
- package/src/client/_hs/hyperspan-actions.client.ts +98 -0
- package/src/client/_hs/hyperspan-scripts.client.ts +31 -0
- package/src/client/_hs/hyperspan-streaming.client.ts +94 -0
- package/src/client/js.ts +0 -20
- package/src/index.ts +2 -0
- package/src/plugins.ts +1 -3
- package/src/server.ts +20 -10
- package/src/types.ts +29 -2
- package/src/utils.ts +69 -0
- package/src/clientjs/hyperspan-client.ts +0 -224
- /package/src/{clientjs → client/_hs}/idiomorph.ts +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspan/framework",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.5",
|
|
4
4
|
"description": "Hyperspan Web Framework",
|
|
5
5
|
"main": "src/server.ts",
|
|
6
6
|
"types": "src/server.ts",
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
},
|
|
11
11
|
"exports": {
|
|
12
12
|
".": {
|
|
13
|
-
"types": "./src/
|
|
14
|
-
"default": "./src/
|
|
13
|
+
"types": "./src/index.ts",
|
|
14
|
+
"default": "./src/index.ts"
|
|
15
15
|
},
|
|
16
16
|
"./server": {
|
|
17
17
|
"types": "./src/server.ts",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"typescript": "^5.9.3"
|
|
81
81
|
},
|
|
82
82
|
"dependencies": {
|
|
83
|
-
"@hyperspan/html": "0.
|
|
83
|
+
"@hyperspan/html": "^1.0.0-alpha",
|
|
84
84
|
"zod": "^4.1.12"
|
|
85
85
|
}
|
|
86
86
|
}
|
package/src/actions.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { html, HSHtml } from '@hyperspan/html';
|
|
2
|
-
import { createRoute,
|
|
2
|
+
import { createRoute, returnHTMLResponse } from './server';
|
|
3
3
|
import * as z from 'zod/v4';
|
|
4
4
|
import type { Hyperspan as HS } from './types';
|
|
5
|
-
import { assetHash } from './utils';
|
|
5
|
+
import { assetHash, formDataToJSON } from './utils';
|
|
6
|
+
import * as actionsClient from './client/_hs/hyperspan-actions.client';
|
|
7
|
+
import { renderClientJS } from './client/js';
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Actions = Form + route handler
|
|
@@ -16,45 +18,15 @@ import { assetHash } from './utils';
|
|
|
16
18
|
* 4. All validation and save logic is run on the server
|
|
17
19
|
* 5. Replaces form content in place with HTML response content from server via the Idiomorph library
|
|
18
20
|
* 6. Handles any Exception thrown on server as error displayed back to user on the page
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
export type HSFormHandler<T extends z.ZodTypeAny> = (
|
|
22
|
-
c: HS.Context,
|
|
23
|
-
{ data, error }: { data?: Partial<z.infer<T>>; error?: z.ZodError | Error }
|
|
24
|
-
) => HSActionResponse;
|
|
25
|
-
export interface HSAction<T extends z.ZodTypeAny> {
|
|
26
|
-
_kind: 'hsAction';
|
|
27
|
-
_name: string;
|
|
28
|
-
_path(): string;
|
|
29
|
-
_form: null | HSFormHandler<T>;
|
|
30
|
-
form(form: HSFormHandler<T>): HSAction<T>;
|
|
31
|
-
post(
|
|
32
|
-
handler: (
|
|
33
|
-
c: HS.Context,
|
|
34
|
-
{ data }: { data?: Partial<z.infer<T>> }
|
|
35
|
-
) => HSActionResponse
|
|
36
|
-
): HSAction<T>;
|
|
37
|
-
error(
|
|
38
|
-
handler: (
|
|
39
|
-
c: HS.Context,
|
|
40
|
-
{ data, error }: { data?: Partial<z.infer<T>>; error?: z.ZodError | Error }
|
|
41
|
-
) => HSActionResponse
|
|
42
|
-
): HSAction<T>;
|
|
43
|
-
render(
|
|
44
|
-
c: HS.Context,
|
|
45
|
-
props?: { data?: Partial<z.infer<T>>; error?: z.ZodError | Error }
|
|
46
|
-
): HSActionResponse;
|
|
47
|
-
middleware: (middleware: Array<HS.MiddlewareFunction>) => HSAction<T>;
|
|
48
|
-
fetch(request: Request): Response | Promise<Response>;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function createAction<T extends z.ZodTypeAny>(params: { name: string; schema?: T }) {
|
|
21
|
+
*/
|
|
22
|
+
export function createAction<T extends z.ZodTypeAny>(params: { name: string; schema?: T }): HS.Action<T> {
|
|
52
23
|
const { name, schema } = params;
|
|
24
|
+
const path = `/__actions/${assetHash(name)}`;
|
|
53
25
|
|
|
54
|
-
let _handler: Parameters<
|
|
55
|
-
let _errorHandler: Parameters<
|
|
26
|
+
let _handler: Parameters<HS.Action<T>['post']>[0] | null = null;
|
|
27
|
+
let _errorHandler: Parameters<HS.Action<T>['errorHandler']>[0] | null = null;
|
|
56
28
|
|
|
57
|
-
const route = createRoute()
|
|
29
|
+
const route = createRoute({ path, name })
|
|
58
30
|
.get((c: HS.Context) => api.render(c))
|
|
59
31
|
.post(async (c: HS.Context) => {
|
|
60
32
|
// Parse form data
|
|
@@ -99,18 +71,18 @@ export function createAction<T extends z.ZodTypeAny>(params: { name: string; sch
|
|
|
99
71
|
return await returnHTMLResponse(c, () => api.render(c, { data, error }), { status: 400 });
|
|
100
72
|
});
|
|
101
73
|
|
|
102
|
-
const api:
|
|
74
|
+
const api: HS.Action<T> = {
|
|
103
75
|
_kind: 'hsAction',
|
|
104
|
-
|
|
76
|
+
_config: route._config,
|
|
105
77
|
_path() {
|
|
106
|
-
return
|
|
78
|
+
return path;
|
|
107
79
|
},
|
|
108
80
|
_form: null,
|
|
109
81
|
/**
|
|
110
82
|
* Form to render
|
|
111
83
|
* This will be wrapped in a <hs-action> web component and submitted via fetch()
|
|
112
84
|
*/
|
|
113
|
-
form(form:
|
|
85
|
+
form(form: HS.ActionFormHandler<T>) {
|
|
114
86
|
api._form = form;
|
|
115
87
|
return api;
|
|
116
88
|
},
|
|
@@ -125,102 +97,22 @@ export function createAction<T extends z.ZodTypeAny>(params: { name: string; sch
|
|
|
125
97
|
return api;
|
|
126
98
|
},
|
|
127
99
|
/**
|
|
128
|
-
*
|
|
100
|
+
* Get form renderer method
|
|
129
101
|
*/
|
|
130
|
-
|
|
102
|
+
render(c: HS.Context, props?: HS.ActionProps<T>) {
|
|
103
|
+
const formContent = api._form ? api._form(c, props || {}) : null;
|
|
104
|
+
return formContent ? html`<hs-action url="${this._path()}">${formContent}</hs-action>${renderClientJS(actionsClient)}` : null;
|
|
105
|
+
},
|
|
106
|
+
errorHandler(handler) {
|
|
131
107
|
_errorHandler = handler;
|
|
132
108
|
return api;
|
|
133
109
|
},
|
|
134
|
-
|
|
135
|
-
* Add middleware specific to this route
|
|
136
|
-
*/
|
|
137
|
-
middleware(middleware) {
|
|
110
|
+
middleware(middleware: Array<HS.MiddlewareFunction>) {
|
|
138
111
|
route.middleware(middleware);
|
|
139
112
|
return api;
|
|
140
113
|
},
|
|
141
|
-
|
|
142
|
-
* Get form renderer method
|
|
143
|
-
*/
|
|
144
|
-
render(c: HS.Context, props?: { data?: Partial<z.infer<T>>; error?: z.ZodError | Error }) {
|
|
145
|
-
const formContent = api._form ? api._form(c, props || {}) : null;
|
|
146
|
-
return formContent ? html`<hs-action url="${this._path()}">${formContent}</hs-action>` : null;
|
|
147
|
-
},
|
|
148
|
-
/**
|
|
149
|
-
* Run action route handler
|
|
150
|
-
*/
|
|
151
|
-
fetch(request: Request) {
|
|
152
|
-
return route.fetch(request);
|
|
153
|
-
},
|
|
114
|
+
fetch: route.fetch,
|
|
154
115
|
};
|
|
155
116
|
|
|
156
117
|
return api;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Return JSON data structure for a given FormData object
|
|
161
|
-
* Accounts for array fields (e.g. name="options[]" or <select multiple>)
|
|
162
|
-
*
|
|
163
|
-
* @link https://stackoverflow.com/a/75406413
|
|
164
|
-
*/
|
|
165
|
-
export function formDataToJSON(formData: FormData): Record<string, string | string[]> {
|
|
166
|
-
let object = {};
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Parses FormData key xxx`[x][x][x]` fields into array
|
|
170
|
-
*/
|
|
171
|
-
const parseKey = (key: string) => {
|
|
172
|
-
const subKeyIdx = key.indexOf('[');
|
|
173
|
-
|
|
174
|
-
if (subKeyIdx !== -1) {
|
|
175
|
-
const keys = [key.substring(0, subKeyIdx)];
|
|
176
|
-
key = key.substring(subKeyIdx);
|
|
177
|
-
|
|
178
|
-
for (const match of key.matchAll(/\[(?<key>.*?)]/gm)) {
|
|
179
|
-
if (match.groups) {
|
|
180
|
-
keys.push(match.groups.key);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
return keys;
|
|
184
|
-
} else {
|
|
185
|
-
return [key];
|
|
186
|
-
}
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Recursively iterates over keys and assigns key/values to object
|
|
191
|
-
*/
|
|
192
|
-
const assign = (keys: string[], value: FormDataEntryValue, object: any): void => {
|
|
193
|
-
const key = keys.shift();
|
|
194
|
-
|
|
195
|
-
// When last key in the iterations
|
|
196
|
-
if (key === '' || key === undefined) {
|
|
197
|
-
return object.push(value);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (Reflect.has(object, key)) {
|
|
201
|
-
// If key has been found, but final pass - convert the value to array
|
|
202
|
-
if (keys.length === 0) {
|
|
203
|
-
if (!Array.isArray(object[key])) {
|
|
204
|
-
object[key] = [object[key], value];
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
// Recurse again with found object
|
|
209
|
-
return assign(keys, value, object[key]);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Create empty object for key, if next key is '' do array instead, otherwise set value
|
|
213
|
-
if (keys.length >= 1) {
|
|
214
|
-
object[key] = keys[0] === '' ? [] : {};
|
|
215
|
-
return assign(keys, value, object[key]);
|
|
216
|
-
} else {
|
|
217
|
-
object[key] = value;
|
|
218
|
-
}
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
for (const pair of formData.entries()) {
|
|
222
|
-
assign(parseKey(pair[0]), pair[1], object);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
return object;
|
|
226
118
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Idiomorph } from './idiomorph';
|
|
2
|
+
import { lazyLoadScripts } from './hyperspan-scripts.client';
|
|
3
|
+
|
|
4
|
+
const actionFormObserver = new MutationObserver((list) => {
|
|
5
|
+
list.forEach((mutation) => {
|
|
6
|
+
mutation.addedNodes.forEach((node) => {
|
|
7
|
+
if (node && ('closest' in node || node instanceof HTMLFormElement)) {
|
|
8
|
+
bindHSActionForm(
|
|
9
|
+
(node as HTMLElement).closest('hs-action') as HSAction,
|
|
10
|
+
node instanceof HTMLFormElement
|
|
11
|
+
? node
|
|
12
|
+
: ((node as HTMLElement | HTMLFormElement).querySelector('form') as HTMLFormElement)
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Server action component to handle the client-side form submission and HTML replacement
|
|
21
|
+
*/
|
|
22
|
+
class HSAction extends HTMLElement {
|
|
23
|
+
constructor() {
|
|
24
|
+
super();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
connectedCallback() {
|
|
28
|
+
actionFormObserver.observe(this, { childList: true, subtree: true });
|
|
29
|
+
bindHSActionForm(this, this.querySelector('form') as HTMLFormElement);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
window.customElements.define('hs-action', HSAction);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Bind the form inside an hs-action element to the action URL and submit handler
|
|
36
|
+
*/
|
|
37
|
+
function bindHSActionForm(hsActionElement: HSAction, form: HTMLFormElement) {
|
|
38
|
+
if (!hsActionElement || !form) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
form.setAttribute('action', hsActionElement.getAttribute('url') || '');
|
|
43
|
+
const submitHandler = (e: Event) => {
|
|
44
|
+
e.preventDefault();
|
|
45
|
+
formSubmitToRoute(e, form as HTMLFormElement, {
|
|
46
|
+
afterResponse: () => bindHSActionForm(hsActionElement, form),
|
|
47
|
+
});
|
|
48
|
+
form.removeEventListener('submit', submitHandler);
|
|
49
|
+
};
|
|
50
|
+
form.addEventListener('submit', submitHandler);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Submit form data to route and replace contents with response
|
|
55
|
+
*/
|
|
56
|
+
type TFormSubmitOptons = { afterResponse: () => any };
|
|
57
|
+
function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOptons) {
|
|
58
|
+
const formData = new FormData(form);
|
|
59
|
+
const formUrl = form.getAttribute('action') || '';
|
|
60
|
+
const method = form.getAttribute('method')?.toUpperCase() || 'POST';
|
|
61
|
+
const headers = {
|
|
62
|
+
Accept: 'text/html',
|
|
63
|
+
'X-Request-Type': 'partial',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const hsActionTag = form.closest('hs-action');
|
|
67
|
+
const submitBtn = form.querySelector('button[type=submit],input[type=submit]');
|
|
68
|
+
if (submitBtn) {
|
|
69
|
+
submitBtn.setAttribute('disabled', 'disabled');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
fetch(formUrl, { body: formData, method, headers })
|
|
73
|
+
.then((res: Response) => {
|
|
74
|
+
// Look for special header that indicates a redirect.
|
|
75
|
+
// fetch() automatically follows 3xx redirects, so we need to handle this manually to redirect the user to the full page
|
|
76
|
+
if (res.headers.has('X-Redirect-Location')) {
|
|
77
|
+
const newUrl = res.headers.get('X-Redirect-Location');
|
|
78
|
+
if (newUrl) {
|
|
79
|
+
window.location.assign(newUrl);
|
|
80
|
+
}
|
|
81
|
+
return '';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return res.text();
|
|
85
|
+
})
|
|
86
|
+
.then((content: string) => {
|
|
87
|
+
// No content = DO NOTHING (redirect or something else happened)
|
|
88
|
+
if (!content) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const target = content.includes('<html') ? window.document.body : hsActionTag || form;
|
|
93
|
+
|
|
94
|
+
Idiomorph.morph(target, content);
|
|
95
|
+
opts.afterResponse && opts.afterResponse();
|
|
96
|
+
lazyLoadScripts();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intersection observer for lazy loading <script> tags
|
|
3
|
+
*/
|
|
4
|
+
const lazyLoadScriptObserver = new IntersectionObserver(
|
|
5
|
+
(entries, observer) => {
|
|
6
|
+
entries
|
|
7
|
+
.filter((entry) => entry.isIntersecting)
|
|
8
|
+
.forEach((entry) => {
|
|
9
|
+
observer.unobserve(entry.target);
|
|
10
|
+
// @ts-ignore
|
|
11
|
+
if (entry.target.children[0]?.content) {
|
|
12
|
+
// @ts-ignore
|
|
13
|
+
entry.target.replaceWith(entry.target.children[0].content);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
},
|
|
17
|
+
{ rootMargin: '0px 0px -200px 0px' }
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Lazy load <script> tags in the current document
|
|
22
|
+
*/
|
|
23
|
+
export function lazyLoadScripts() {
|
|
24
|
+
document
|
|
25
|
+
.querySelectorAll('div[data-loading=lazy]')
|
|
26
|
+
.forEach((el) => lazyLoadScriptObserver.observe(el));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
window.addEventListener('load', () => {
|
|
30
|
+
lazyLoadScripts();
|
|
31
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Idiomorph } from './idiomorph';
|
|
2
|
+
import { lazyLoadScripts } from './hyperspan-scripts.client';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Used for streaming content from the server to the client.
|
|
6
|
+
*/
|
|
7
|
+
function htmlAsyncContentObserver() {
|
|
8
|
+
if (typeof MutationObserver != 'undefined') {
|
|
9
|
+
// Hyperspan - Async content loader
|
|
10
|
+
// Puts streamed content in its place immediately after it is added to the DOM
|
|
11
|
+
const asyncContentObserver = new MutationObserver((list) => {
|
|
12
|
+
const asyncContent = list
|
|
13
|
+
.map((mutation) =>
|
|
14
|
+
Array.from(mutation.addedNodes).find((node: any) => {
|
|
15
|
+
if (!node || !node?.id || typeof node.id !== 'string') {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return node.id?.startsWith('async_loading_') && node.id?.endsWith('_content');
|
|
19
|
+
})
|
|
20
|
+
)
|
|
21
|
+
.filter((node: any) => node);
|
|
22
|
+
|
|
23
|
+
asyncContent.forEach((templateEl: any) => {
|
|
24
|
+
try {
|
|
25
|
+
// Also observe for content inside the template content (shadow DOM is separate)
|
|
26
|
+
asyncContentObserver.observe(templateEl.content, { childList: true, subtree: true });
|
|
27
|
+
|
|
28
|
+
const slotId = templateEl.id.replace('_content', '');
|
|
29
|
+
const slotEl = document.getElementById(slotId);
|
|
30
|
+
|
|
31
|
+
if (slotEl) {
|
|
32
|
+
// Content AND slot are present - let's insert the content into the slot
|
|
33
|
+
// Ensure the content is fully done streaming in before inserting it into the slot
|
|
34
|
+
waitForContent(templateEl.content, (el2) => {
|
|
35
|
+
return Array.from(el2.childNodes).find(
|
|
36
|
+
(node) => node.nodeType === Node.COMMENT_NODE && node.nodeValue === 'end'
|
|
37
|
+
);
|
|
38
|
+
})
|
|
39
|
+
.then((endComment) => {
|
|
40
|
+
templateEl.content.removeChild(endComment);
|
|
41
|
+
const content = templateEl.content.cloneNode(true);
|
|
42
|
+
Idiomorph.morph(slotEl, content);
|
|
43
|
+
templateEl.parentNode.removeChild(templateEl);
|
|
44
|
+
lazyLoadScripts();
|
|
45
|
+
})
|
|
46
|
+
.catch(console.error);
|
|
47
|
+
} else {
|
|
48
|
+
// Slot is NOT present - wait for it to be added to the DOM so we can insert the content into it
|
|
49
|
+
waitForContent(document.body, () => {
|
|
50
|
+
return document.getElementById(slotId);
|
|
51
|
+
}).then((slotEl) => {
|
|
52
|
+
Idiomorph.morph(slotEl, templateEl.content.cloneNode(true));
|
|
53
|
+
lazyLoadScripts();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
} catch (e) {
|
|
57
|
+
console.error(e);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
asyncContentObserver.observe(document.body, { childList: true, subtree: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
htmlAsyncContentObserver();
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Wait until ALL of the content inside an element is present from streaming in.
|
|
68
|
+
* Large chunks of content can sometimes take more than a single tick to write to DOM.
|
|
69
|
+
*/
|
|
70
|
+
async function waitForContent(
|
|
71
|
+
el: HTMLElement,
|
|
72
|
+
waitFn: (
|
|
73
|
+
node: HTMLElement
|
|
74
|
+
) => HTMLElement | HTMLTemplateElement | Node | ChildNode | null | undefined,
|
|
75
|
+
options: { timeoutMs?: number; intervalMs?: number } = { timeoutMs: 10000, intervalMs: 20 }
|
|
76
|
+
): Promise<HTMLElement | HTMLTemplateElement | Node | ChildNode | null | undefined> {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
let timeout: NodeJS.Timeout;
|
|
79
|
+
const interval = setInterval(() => {
|
|
80
|
+
const content = waitFn(el);
|
|
81
|
+
if (content) {
|
|
82
|
+
if (timeout) {
|
|
83
|
+
clearTimeout(timeout);
|
|
84
|
+
}
|
|
85
|
+
clearInterval(interval);
|
|
86
|
+
resolve(content);
|
|
87
|
+
}
|
|
88
|
+
}, options.intervalMs || 20);
|
|
89
|
+
timeout = setTimeout(() => {
|
|
90
|
+
clearInterval(interval);
|
|
91
|
+
reject(new Error(`[Hyperspan] Timeout waiting for end of streaming content ${el.id}`));
|
|
92
|
+
}, options.timeoutMs || 10000);
|
|
93
|
+
});
|
|
94
|
+
}
|
package/src/client/js.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { html } from '@hyperspan/html';
|
|
2
|
-
import type { Hyperspan as HS } from '../types';
|
|
3
2
|
|
|
4
3
|
export const JS_PUBLIC_PATH = '/_hs/js';
|
|
5
4
|
export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
|
|
@@ -59,23 +58,4 @@ export function functionToString(fn: any) {
|
|
|
59
58
|
}
|
|
60
59
|
|
|
61
60
|
return str;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Island defaults
|
|
66
|
-
*/
|
|
67
|
-
export const ISLAND_DEFAULTS: () => HS.ClientIslandOptions = () => ({
|
|
68
|
-
ssr: true,
|
|
69
|
-
loading: undefined,
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
export function renderIsland(Component: any, props: any, options = ISLAND_DEFAULTS()) {
|
|
73
|
-
// Render island with its own logic
|
|
74
|
-
if (Component.__HS_ISLAND?.render) {
|
|
75
|
-
return html.raw(Component.__HS_ISLAND.render(props, options));
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
throw new Error(
|
|
79
|
-
`Module ${Component.name} was not loaded with an island plugin! Did you forget to install an island plugin and add it to the 'islandPlugins' option in your hyperspan.config.ts file?`
|
|
80
|
-
);
|
|
81
61
|
}
|
package/src/index.ts
ADDED
package/src/plugins.ts
CHANGED
|
@@ -46,6 +46,7 @@ export function clientJSPlugin(): HS.Plugin {
|
|
|
46
46
|
const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
|
|
47
47
|
JS_IMPORT_MAP.set(esmName, `${JS_PUBLIC_PATH}/${esmName}.js`);
|
|
48
48
|
|
|
49
|
+
// Get the contents of the file to extract the exports
|
|
49
50
|
const contents = await result.outputs[0].text();
|
|
50
51
|
const exportLine = EXPORT_REGEX.exec(contents);
|
|
51
52
|
|
|
@@ -67,9 +68,6 @@ export function clientJSPlugin(): HS.Plugin {
|
|
|
67
68
|
const moduleCode = `// hyperspan:processed
|
|
68
69
|
import { functionToString } from '@hyperspan/framework/client/js';
|
|
69
70
|
|
|
70
|
-
// Original file contents
|
|
71
|
-
${contents}
|
|
72
|
-
|
|
73
71
|
// hyperspan:client-js-plugin
|
|
74
72
|
export const __CLIENT_JS = {
|
|
75
73
|
id: "${jsId}",
|
package/src/server.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { HSHtml, html, isHSHtml, renderStream, renderAsync, render } from '@hype
|
|
|
2
2
|
import { executeMiddleware } from './middleware';
|
|
3
3
|
import type { Hyperspan as HS } from './types';
|
|
4
4
|
import { clientJSPlugin } from './plugins';
|
|
5
|
-
import { CSS_ROUTE_MAP } from './client/css';
|
|
6
5
|
export type { HS as Hyperspan };
|
|
7
6
|
|
|
8
7
|
export const IS_PROD = process.env.NODE_ENV === 'production';
|
|
@@ -50,7 +49,10 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
50
49
|
method,
|
|
51
50
|
headers,
|
|
52
51
|
query,
|
|
53
|
-
|
|
52
|
+
async text() { return req.text() },
|
|
53
|
+
async json<T = unknown>() { return await req.json() as T },
|
|
54
|
+
async formData<T = unknown>() { return await req.formData() as T },
|
|
55
|
+
async urlencoded() { return new URLSearchParams(await req.text()) },
|
|
54
56
|
},
|
|
55
57
|
res: {
|
|
56
58
|
headers: new Headers(),
|
|
@@ -135,6 +137,10 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
135
137
|
_middleware['OPTIONS'] = handlerOptions?.middleware || [];
|
|
136
138
|
return api;
|
|
137
139
|
},
|
|
140
|
+
errorHandler(handler: HS.RouteHandler) {
|
|
141
|
+
_handlers['_ERROR'] = handler;
|
|
142
|
+
return api;
|
|
143
|
+
},
|
|
138
144
|
/**
|
|
139
145
|
* Add middleware specific to this route
|
|
140
146
|
*/
|
|
@@ -196,7 +202,16 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
196
202
|
return routeContent;
|
|
197
203
|
};
|
|
198
204
|
|
|
199
|
-
|
|
205
|
+
// Run the route handler and any middleware
|
|
206
|
+
// If an error occurs, run the error handler if it exists
|
|
207
|
+
try {
|
|
208
|
+
return await executeMiddleware(context, [...globalMiddleware, ...methodMiddleware, methodHandler]);
|
|
209
|
+
} catch (e) {
|
|
210
|
+
if (_handlers['_ERROR']) {
|
|
211
|
+
return await (_handlers['_ERROR'](context) as Promise<Response>);
|
|
212
|
+
}
|
|
213
|
+
throw e;
|
|
214
|
+
}
|
|
200
215
|
},
|
|
201
216
|
};
|
|
202
217
|
|
|
@@ -329,11 +344,6 @@ export function getRunnableRoute(route: unknown, routeConfig?: HS.RouteConfig):
|
|
|
329
344
|
|
|
330
345
|
const kind = typeof route;
|
|
331
346
|
|
|
332
|
-
// Plain function - wrap in createRoute()
|
|
333
|
-
if (kind === 'function') {
|
|
334
|
-
return createRoute(routeConfig).get(route as HS.RouteHandler);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
347
|
// Module - get default and use it
|
|
338
348
|
// @ts-ignore
|
|
339
349
|
if (kind === 'object' && 'default' in route) {
|
|
@@ -355,7 +365,7 @@ export function isRunnableRoute(route: unknown): boolean {
|
|
|
355
365
|
}
|
|
356
366
|
|
|
357
367
|
const obj = route as { _kind: string; fetch: (request: Request) => Promise<Response> };
|
|
358
|
-
return
|
|
368
|
+
return typeof obj?._kind === 'string' && 'fetch' in obj;
|
|
359
369
|
}
|
|
360
370
|
|
|
361
371
|
/**
|
|
@@ -365,7 +375,7 @@ export function isValidRoutePath(path: string): boolean {
|
|
|
365
375
|
const isHiddenRoute = path.includes('/__');
|
|
366
376
|
const isTestFile = path.includes('.test') || path.includes('.spec');
|
|
367
377
|
|
|
368
|
-
return !isHiddenRoute && !isTestFile;
|
|
378
|
+
return !isHiddenRoute && !isTestFile && Boolean(path);
|
|
369
379
|
}
|
|
370
380
|
|
|
371
381
|
/**
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { HSHtml } from '@hyperspan/html';
|
|
2
|
+
import * as z from 'zod/v4';
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Hyperspan Types
|
|
3
6
|
*/
|
|
@@ -39,7 +42,10 @@ export namespace Hyperspan {
|
|
|
39
42
|
method: string; // Always uppercase
|
|
40
43
|
headers: Headers; // Case-insensitive
|
|
41
44
|
query: URLSearchParams;
|
|
42
|
-
|
|
45
|
+
text: () => Promise<string>;
|
|
46
|
+
json<T = unknown>(): Promise<T>;
|
|
47
|
+
formData<T = unknown>(): Promise<T>;
|
|
48
|
+
urlencoded(): Promise<URLSearchParams>;
|
|
43
49
|
};
|
|
44
50
|
res: {
|
|
45
51
|
headers: Headers; // Headers to merge with final outgoing response
|
|
@@ -94,7 +100,6 @@ export namespace Hyperspan {
|
|
|
94
100
|
|
|
95
101
|
export interface Route {
|
|
96
102
|
_kind: 'hsRoute';
|
|
97
|
-
_name: string | undefined;
|
|
98
103
|
_config: Hyperspan.RouteConfig;
|
|
99
104
|
_path(): string;
|
|
100
105
|
_methods(): string[];
|
|
@@ -104,7 +109,29 @@ export namespace Hyperspan {
|
|
|
104
109
|
patch: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
|
|
105
110
|
delete: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
|
|
106
111
|
options: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
|
|
112
|
+
errorHandler: (handler: Hyperspan.RouteHandler) => Hyperspan.Route;
|
|
107
113
|
middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Hyperspan.Route;
|
|
108
114
|
fetch: (request: Request) => Promise<Response>;
|
|
109
115
|
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Action = Form + route handler
|
|
119
|
+
*/
|
|
120
|
+
export type ActionResponse = HSHtml | void | null | Promise<HSHtml | void | null> | Response | Promise<Response>;
|
|
121
|
+
export type ActionProps<T extends z.ZodTypeAny> = { data?: Partial<z.infer<T>>; error?: z.ZodError | Error };
|
|
122
|
+
export type ActionFormHandler<T extends z.ZodTypeAny> = (
|
|
123
|
+
c: Context, props: ActionProps<T>
|
|
124
|
+
) => ActionResponse;
|
|
125
|
+
export interface Action<T extends z.ZodTypeAny> {
|
|
126
|
+
_kind: 'hsAction';
|
|
127
|
+
_config: Hyperspan.RouteConfig;
|
|
128
|
+
_path(): string;
|
|
129
|
+
_form: null | ActionFormHandler<T>;
|
|
130
|
+
form(form: ActionFormHandler<T>): Action<T>;
|
|
131
|
+
render: (c: Context, props?: ActionProps<T>) => ActionResponse;
|
|
132
|
+
post: (handler: ActionFormHandler<T>) => Action<T>;
|
|
133
|
+
errorHandler: (handler: ActionFormHandler<T>) => Action<T>;
|
|
134
|
+
middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Action<T>;
|
|
135
|
+
fetch: (request: Request) => Promise<Response>;
|
|
136
|
+
}
|
|
110
137
|
}
|
package/src/utils.ts
CHANGED
|
@@ -6,4 +6,73 @@ export function assetHash(content: string): string {
|
|
|
6
6
|
|
|
7
7
|
export function randomHash(): string {
|
|
8
8
|
return createHash('md5').update(randomBytes(32).toString('hex')).digest('hex');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Return JSON data structure for a given FormData or URLSearchParams object
|
|
13
|
+
* Accounts for array fields (e.g. name="options[]" or <select multiple>)
|
|
14
|
+
*
|
|
15
|
+
* @link https://stackoverflow.com/a/75406413
|
|
16
|
+
*/
|
|
17
|
+
export function formDataToJSON(formData: FormData | URLSearchParams): Record<string, string | string[]> {
|
|
18
|
+
let object = {};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parses FormData key xxx`[x][x][x]` fields into array
|
|
22
|
+
*/
|
|
23
|
+
const parseKey = (key: string) => {
|
|
24
|
+
const subKeyIdx = key.indexOf('[');
|
|
25
|
+
|
|
26
|
+
if (subKeyIdx !== -1) {
|
|
27
|
+
const keys = [key.substring(0, subKeyIdx)];
|
|
28
|
+
key = key.substring(subKeyIdx);
|
|
29
|
+
|
|
30
|
+
for (const match of key.matchAll(/\[(?<key>.*?)]/gm)) {
|
|
31
|
+
if (match.groups) {
|
|
32
|
+
keys.push(match.groups.key);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return keys;
|
|
36
|
+
} else {
|
|
37
|
+
return [key];
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Recursively iterates over keys and assigns key/values to object
|
|
43
|
+
*/
|
|
44
|
+
const assign = (keys: string[], value: FormDataEntryValue, object: any): void => {
|
|
45
|
+
const key = keys.shift();
|
|
46
|
+
|
|
47
|
+
// When last key in the iterations
|
|
48
|
+
if (key === '' || key === undefined) {
|
|
49
|
+
return object.push(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (Reflect.has(object, key)) {
|
|
53
|
+
// If key has been found, but final pass - convert the value to array
|
|
54
|
+
if (keys.length === 0) {
|
|
55
|
+
if (!Array.isArray(object[key])) {
|
|
56
|
+
object[key] = [object[key], value];
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Recurse again with found object
|
|
61
|
+
return assign(keys, value, object[key]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Create empty object for key, if next key is '' do array instead, otherwise set value
|
|
65
|
+
if (keys.length >= 1) {
|
|
66
|
+
object[key] = keys[0] === '' ? [] : {};
|
|
67
|
+
return assign(keys, value, object[key]);
|
|
68
|
+
} else {
|
|
69
|
+
object[key] = value;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
for (const pair of formData.entries()) {
|
|
74
|
+
assign(parseKey(pair[0]), pair[1], object);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return object;
|
|
9
78
|
}
|
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
import { html } from '@hyperspan/html';
|
|
2
|
-
import { Idiomorph } from './idiomorph';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Used for streaming content from the server to the client.
|
|
6
|
-
*/
|
|
7
|
-
function htmlAsyncContentObserver() {
|
|
8
|
-
if (typeof MutationObserver != 'undefined') {
|
|
9
|
-
// Hyperspan - Async content loader
|
|
10
|
-
// Puts streamed content in its place immediately after it is added to the DOM
|
|
11
|
-
const asyncContentObserver = new MutationObserver((list) => {
|
|
12
|
-
const asyncContent = list
|
|
13
|
-
.map((mutation) =>
|
|
14
|
-
Array.from(mutation.addedNodes).find((node: any) => {
|
|
15
|
-
if (!node || !node?.id || typeof node.id !== 'string') {
|
|
16
|
-
return false;
|
|
17
|
-
}
|
|
18
|
-
return node.id?.startsWith('async_loading_') && node.id?.endsWith('_content');
|
|
19
|
-
})
|
|
20
|
-
)
|
|
21
|
-
.filter((node: any) => node);
|
|
22
|
-
|
|
23
|
-
asyncContent.forEach((templateEl: any) => {
|
|
24
|
-
try {
|
|
25
|
-
// Also observe for content inside the template content (shadow DOM is separate)
|
|
26
|
-
asyncContentObserver.observe(templateEl.content, { childList: true, subtree: true });
|
|
27
|
-
|
|
28
|
-
const slotId = templateEl.id.replace('_content', '');
|
|
29
|
-
const slotEl = document.getElementById(slotId);
|
|
30
|
-
|
|
31
|
-
if (slotEl) {
|
|
32
|
-
// Content AND slot are present - let's insert the content into the slot
|
|
33
|
-
// Ensure the content is fully done streaming in before inserting it into the slot
|
|
34
|
-
waitForContent(templateEl.content, (el2) => {
|
|
35
|
-
return Array.from(el2.childNodes).find(
|
|
36
|
-
(node) => node.nodeType === Node.COMMENT_NODE && node.nodeValue === 'end'
|
|
37
|
-
);
|
|
38
|
-
})
|
|
39
|
-
.then((endComment) => {
|
|
40
|
-
templateEl.content.removeChild(endComment);
|
|
41
|
-
const content = templateEl.content.cloneNode(true);
|
|
42
|
-
Idiomorph.morph(slotEl, content);
|
|
43
|
-
templateEl.parentNode.removeChild(templateEl);
|
|
44
|
-
lazyLoadScripts();
|
|
45
|
-
})
|
|
46
|
-
.catch(console.error);
|
|
47
|
-
} else {
|
|
48
|
-
// Slot is NOT present - wait for it to be added to the DOM so we can insert the content into it
|
|
49
|
-
waitForContent(document.body, () => {
|
|
50
|
-
return document.getElementById(slotId);
|
|
51
|
-
}).then((slotEl) => {
|
|
52
|
-
Idiomorph.morph(slotEl, templateEl.content.cloneNode(true));
|
|
53
|
-
lazyLoadScripts();
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
} catch (e) {
|
|
57
|
-
console.error(e);
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
asyncContentObserver.observe(document.body, { childList: true, subtree: true });
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
htmlAsyncContentObserver();
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Wait until ALL of the content inside an element is present from streaming in.
|
|
68
|
-
* Large chunks of content can sometimes take more than a single tick to write to DOM.
|
|
69
|
-
*/
|
|
70
|
-
async function waitForContent(
|
|
71
|
-
el: HTMLElement,
|
|
72
|
-
waitFn: (
|
|
73
|
-
node: HTMLElement
|
|
74
|
-
) => HTMLElement | HTMLTemplateElement | Node | ChildNode | null | undefined,
|
|
75
|
-
options: { timeoutMs?: number; intervalMs?: number } = { timeoutMs: 10000, intervalMs: 20 }
|
|
76
|
-
): Promise<HTMLElement | HTMLTemplateElement | Node | ChildNode | null | undefined> {
|
|
77
|
-
return new Promise((resolve, reject) => {
|
|
78
|
-
let timeout: NodeJS.Timeout;
|
|
79
|
-
const interval = setInterval(() => {
|
|
80
|
-
const content = waitFn(el);
|
|
81
|
-
if (content) {
|
|
82
|
-
if (timeout) {
|
|
83
|
-
clearTimeout(timeout);
|
|
84
|
-
}
|
|
85
|
-
clearInterval(interval);
|
|
86
|
-
resolve(content);
|
|
87
|
-
}
|
|
88
|
-
}, options.intervalMs || 20);
|
|
89
|
-
timeout = setTimeout(() => {
|
|
90
|
-
clearInterval(interval);
|
|
91
|
-
reject(new Error(`[Hyperspan] Timeout waiting for end of streaming content ${el.id}`));
|
|
92
|
-
}, options.timeoutMs || 10000);
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Server action component to handle the client-side form submission and HTML replacement
|
|
98
|
-
*/
|
|
99
|
-
class HSAction extends HTMLElement {
|
|
100
|
-
constructor() {
|
|
101
|
-
super();
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
connectedCallback() {
|
|
105
|
-
actionFormObserver.observe(this, { childList: true, subtree: true });
|
|
106
|
-
bindHSActionForm(this, this.querySelector('form') as HTMLFormElement);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
window.customElements.define('hs-action', HSAction);
|
|
110
|
-
const actionFormObserver = new MutationObserver((list) => {
|
|
111
|
-
list.forEach((mutation) => {
|
|
112
|
-
mutation.addedNodes.forEach((node) => {
|
|
113
|
-
if (node && ('closest' in node || node instanceof HTMLFormElement)) {
|
|
114
|
-
bindHSActionForm(
|
|
115
|
-
(node as HTMLElement).closest('hs-action') as HSAction,
|
|
116
|
-
node instanceof HTMLFormElement
|
|
117
|
-
? node
|
|
118
|
-
: ((node as HTMLElement | HTMLFormElement).querySelector('form') as HTMLFormElement)
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Bind the form inside an hs-action element to the action URL and submit handler
|
|
127
|
-
*/
|
|
128
|
-
function bindHSActionForm(hsActionElement: HSAction, form: HTMLFormElement) {
|
|
129
|
-
if (!hsActionElement || !form) {
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
form.setAttribute('action', hsActionElement.getAttribute('url') || '');
|
|
134
|
-
const submitHandler = (e: Event) => {
|
|
135
|
-
e.preventDefault();
|
|
136
|
-
formSubmitToRoute(e, form as HTMLFormElement, {
|
|
137
|
-
afterResponse: () => bindHSActionForm(hsActionElement, form),
|
|
138
|
-
});
|
|
139
|
-
form.removeEventListener('submit', submitHandler);
|
|
140
|
-
};
|
|
141
|
-
form.addEventListener('submit', submitHandler);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Submit form data to route and replace contents with response
|
|
146
|
-
*/
|
|
147
|
-
type TFormSubmitOptons = { afterResponse: () => any };
|
|
148
|
-
function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOptons) {
|
|
149
|
-
const formData = new FormData(form);
|
|
150
|
-
const formUrl = form.getAttribute('action') || '';
|
|
151
|
-
const method = form.getAttribute('method')?.toUpperCase() || 'POST';
|
|
152
|
-
const headers = {
|
|
153
|
-
Accept: 'text/html',
|
|
154
|
-
'X-Request-Type': 'partial',
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
const hsActionTag = form.closest('hs-action');
|
|
158
|
-
const submitBtn = form.querySelector('button[type=submit],input[type=submit]');
|
|
159
|
-
if (submitBtn) {
|
|
160
|
-
submitBtn.setAttribute('disabled', 'disabled');
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
fetch(formUrl, { body: formData, method, headers })
|
|
164
|
-
.then((res: Response) => {
|
|
165
|
-
// Look for special header that indicates a redirect.
|
|
166
|
-
// fetch() automatically follows 3xx redirects, so we need to handle this manually to redirect the user to the full page
|
|
167
|
-
if (res.headers.has('X-Redirect-Location')) {
|
|
168
|
-
const newUrl = res.headers.get('X-Redirect-Location');
|
|
169
|
-
if (newUrl) {
|
|
170
|
-
window.location.assign(newUrl);
|
|
171
|
-
}
|
|
172
|
-
return '';
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return res.text();
|
|
176
|
-
})
|
|
177
|
-
.then((content: string) => {
|
|
178
|
-
// No content = DO NOTHING (redirect or something else happened)
|
|
179
|
-
if (!content) {
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const target = content.includes('<html') ? window.document.body : hsActionTag || form;
|
|
184
|
-
|
|
185
|
-
Idiomorph.morph(target, content);
|
|
186
|
-
opts.afterResponse && opts.afterResponse();
|
|
187
|
-
lazyLoadScripts();
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Intersection observer for lazy loading <script> tags
|
|
193
|
-
*/
|
|
194
|
-
const lazyLoadScriptObserver = new IntersectionObserver(
|
|
195
|
-
(entries, observer) => {
|
|
196
|
-
entries
|
|
197
|
-
.filter((entry) => entry.isIntersecting)
|
|
198
|
-
.forEach((entry) => {
|
|
199
|
-
observer.unobserve(entry.target);
|
|
200
|
-
// @ts-ignore
|
|
201
|
-
if (entry.target.children[0]?.content) {
|
|
202
|
-
// @ts-ignore
|
|
203
|
-
entry.target.replaceWith(entry.target.children[0].content);
|
|
204
|
-
}
|
|
205
|
-
});
|
|
206
|
-
},
|
|
207
|
-
{ rootMargin: '0px 0px -200px 0px' }
|
|
208
|
-
);
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Lazy load <script> tags in the current document
|
|
212
|
-
*/
|
|
213
|
-
function lazyLoadScripts() {
|
|
214
|
-
document
|
|
215
|
-
.querySelectorAll('div[data-loading=lazy]')
|
|
216
|
-
.forEach((el) => lazyLoadScriptObserver.observe(el));
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
window.addEventListener('load', () => {
|
|
220
|
-
lazyLoadScripts();
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
// @ts-ignore
|
|
224
|
-
window.html = html;
|
|
File without changes
|