@hyperspan/framework 1.0.0-alpha.1 → 1.0.0-alpha.10

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/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
  */
@@ -26,6 +29,26 @@ export namespace Hyperspan {
26
29
  afterRoutesAdded?: (server: Hyperspan.Server) => void;
27
30
  };
28
31
 
32
+ export type CookieOptions = {
33
+ maxAge?: number;
34
+ domain?: string;
35
+ path?: string;
36
+ expires?: Date;
37
+ httpOnly?: boolean;
38
+ secure?: boolean;
39
+ sameSite?: 'lax' | 'strict' | true;
40
+ };
41
+ export type Cookies = {
42
+ _req: Request;
43
+ _responseHeaders: Headers | undefined;
44
+ _parsedCookies: Record<string, any>;
45
+ _encrypt: ((str: string) => string) | undefined;
46
+ _decrypt: ((str: string) => string) | undefined;
47
+ get: (name: string) => string | undefined;
48
+ set: (name: string, value: string, options?: CookieOptions) => void;
49
+ delete: (name: string) => void;
50
+ }
51
+
29
52
  export interface Context {
30
53
  vars: Record<string, any>;
31
54
  route: {
@@ -39,9 +62,14 @@ export namespace Hyperspan {
39
62
  method: string; // Always uppercase
40
63
  headers: Headers; // Case-insensitive
41
64
  query: URLSearchParams;
42
- body: any;
65
+ cookies: Hyperspan.Cookies;
66
+ text: () => Promise<string>;
67
+ json<T = unknown>(): Promise<T>;
68
+ formData(): Promise<FormData>;
69
+ urlencoded(): Promise<URLSearchParams>;
43
70
  };
44
71
  res: {
72
+ cookies: Hyperspan.Cookies;
45
73
  headers: Headers; // Headers to merge with final outgoing response
46
74
  html: (html: string, options?: { status?: number; headers?: Record<string, string> }) => Response
47
75
  json: (json: any, options?: { status?: number; headers?: Record<string, string> }) => Response;
@@ -49,6 +77,7 @@ export namespace Hyperspan {
49
77
  redirect: (url: string, options?: { status?: number; headers?: Record<string, string> }) => Response;
50
78
  error: (error: Error, options?: { status?: number; headers?: Record<string, string> }) => Response;
51
79
  notFound: (options?: { status?: number; headers?: Record<string, string> }) => Response;
80
+ merge: (response: Response) => Response;
52
81
  raw: Response;
53
82
  };
54
83
  };
@@ -94,7 +123,6 @@ export namespace Hyperspan {
94
123
 
95
124
  export interface Route {
96
125
  _kind: 'hsRoute';
97
- _name: string | undefined;
98
126
  _config: Hyperspan.RouteConfig;
99
127
  _path(): string;
100
128
  _methods(): string[];
@@ -104,7 +132,29 @@ export namespace Hyperspan {
104
132
  patch: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
105
133
  delete: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
106
134
  options: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
135
+ errorHandler: (handler: Hyperspan.RouteHandler) => Hyperspan.Route;
107
136
  middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Hyperspan.Route;
108
137
  fetch: (request: Request) => Promise<Response>;
109
138
  };
139
+
140
+ /**
141
+ * Action = Form + route handler
142
+ */
143
+ export type ActionResponse = HSHtml | void | null | Promise<HSHtml | void | null> | Response | Promise<Response>;
144
+ export type ActionProps<T extends z.ZodTypeAny> = { data?: Partial<z.infer<T>>; error?: z.ZodError | Error };
145
+ export type ActionFormHandler<T extends z.ZodTypeAny> = (
146
+ c: Context, props: ActionProps<T>
147
+ ) => ActionResponse;
148
+ export interface Action<T extends z.ZodTypeAny> {
149
+ _kind: 'hsAction';
150
+ _config: Hyperspan.RouteConfig;
151
+ _path(): string;
152
+ _form: null | ActionFormHandler<T>;
153
+ form(form: ActionFormHandler<T>): Action<T>;
154
+ render: (c: Context, props?: ActionProps<T>) => ActionResponse;
155
+ post: (handler: ActionFormHandler<T>) => Action<T>;
156
+ errorHandler: (handler: ActionFormHandler<T>) => Action<T>;
157
+ middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Action<T>;
158
+ fetch: (request: Request) => Promise<Response>;
159
+ }
110
160
  }
@@ -0,0 +1,196 @@
1
+ import { test, expect, describe } from 'bun:test';
2
+ import { formDataToJSON, parsePath } from './utils';
3
+
4
+ describe('formDataToJSON', () => {
5
+ test('formDataToJSON returns empty object for empty FormData', () => {
6
+ const formData = new FormData();
7
+ const result = formDataToJSON(formData);
8
+
9
+ expect(result).toEqual({});
10
+ });
11
+
12
+ test('formDataToJSON handles simple FormData object', () => {
13
+ const formData = new FormData();
14
+ formData.append('name', 'John Doe');
15
+ formData.append('email', 'john@example.com');
16
+ formData.append('age', '30');
17
+
18
+ const result = formDataToJSON(formData);
19
+
20
+ expect(result).toEqual({
21
+ name: 'John Doe',
22
+ email: 'john@example.com',
23
+ age: '30',
24
+ });
25
+ });
26
+
27
+ test('formDataToJSON handles complex FormData with nested fields', () => {
28
+ const formData = new FormData();
29
+ formData.append('user[firstName]', 'John');
30
+ formData.append('user[lastName]', 'Doe');
31
+ formData.append('user[email]', 'john@example.com');
32
+ formData.append('user[address][street]', '123 Main St');
33
+ formData.append('user[address][city]', 'New York');
34
+ formData.append('user[address][zip]', '10001');
35
+
36
+ const result = formDataToJSON(formData);
37
+
38
+ expect(result).toEqual({
39
+ user: {
40
+ firstName: 'John',
41
+ lastName: 'Doe',
42
+ email: 'john@example.com',
43
+ address: {
44
+ street: '123 Main St',
45
+ city: 'New York',
46
+ zip: '10001',
47
+ },
48
+ },
49
+ } as any);
50
+ });
51
+
52
+ test('formDataToJSON handles FormData with array of values', () => {
53
+ const formData = new FormData();
54
+ formData.append('tags', 'javascript');
55
+ formData.append('tags', 'typescript');
56
+ formData.append('tags', 'nodejs');
57
+ formData.append('colors[]', 'red');
58
+ formData.append('colors[]', 'green');
59
+ formData.append('colors[]', 'blue');
60
+
61
+ const result = formDataToJSON(formData);
62
+
63
+ expect(result).toEqual({
64
+ tags: ['javascript', 'typescript', 'nodejs'],
65
+ colors: ['red', 'green', 'blue'],
66
+ });
67
+ });
68
+ });
69
+
70
+ describe('parsePath', () => {
71
+ test('parsePath returns root path for empty string', () => {
72
+ const result = parsePath('');
73
+ expect(result.path).toBe('/');
74
+ expect(result.params).toEqual([]);
75
+ });
76
+
77
+ test('parsePath handles simple path', () => {
78
+ const result = parsePath('users');
79
+ expect(result.path).toBe('/users');
80
+ expect(result.params).toEqual([]);
81
+ });
82
+
83
+ test('parsePath removes leading slash', () => {
84
+ const result = parsePath('/users');
85
+ expect(result.path).toBe('/users');
86
+ expect(result.params).toEqual([]);
87
+ });
88
+
89
+ test('parsePath removes trailing slash', () => {
90
+ const result = parsePath('users/');
91
+ expect(result.path).toBe('/users');
92
+ expect(result.params).toEqual([]);
93
+ });
94
+
95
+ test('parsePath removes both leading and trailing slashes', () => {
96
+ const result = parsePath('/users/');
97
+ expect(result.path).toBe('/users');
98
+ expect(result.params).toEqual([]);
99
+ });
100
+
101
+ test('parsePath handles nested paths', () => {
102
+ const result = parsePath('users/posts');
103
+ expect(result.path).toBe('/users/posts');
104
+ expect(result.params).toEqual([]);
105
+ });
106
+
107
+ test('parsePath lowercases path segments', () => {
108
+ const result = parsePath('Users/Posts');
109
+ expect(result.path).toBe('/users/posts');
110
+ expect(result.params).toEqual([]);
111
+ });
112
+
113
+ test('parsePath removes .ts extension', () => {
114
+ const result = parsePath('users.ts');
115
+ expect(result.path).toBe('/users');
116
+ expect(result.params).toEqual([]);
117
+ });
118
+
119
+ test('parsePath removes .js extension', () => {
120
+ const result = parsePath('users.js');
121
+ expect(result.path).toBe('/users');
122
+ expect(result.params).toEqual([]);
123
+ });
124
+
125
+ test('parsePath removes index from path', () => {
126
+ const result = parsePath('index');
127
+ expect(result.path).toBe('/');
128
+ expect(result.params).toEqual([]);
129
+ });
130
+
131
+ test('parsePath removes index.ts from path', () => {
132
+ const result = parsePath('index.ts');
133
+ expect(result.path).toBe('/');
134
+ expect(result.params).toEqual([]);
135
+ });
136
+
137
+ test('parsePath handles dynamic param with brackets', () => {
138
+ const result = parsePath('users/[userId]');
139
+ expect(result.path).toBe('/users/:userId');
140
+ expect(result.params).toEqual(['userId']);
141
+ });
142
+
143
+ test('parsePath handles multiple dynamic params', () => {
144
+ const result = parsePath('users/[userId]/posts/[postId]');
145
+ expect(result.path).toBe('/users/:userId/posts/:postId');
146
+ expect(result.params).toEqual(['userId', 'postId']);
147
+ });
148
+
149
+ test('parsePath handles catch-all param with spread', () => {
150
+ const result = parsePath('users/[...slug]');
151
+ expect(result.path).toBe('/users/*');
152
+ expect(result.params).toEqual(['slug']);
153
+ });
154
+
155
+ test('parsePath handles catch-all param at root', () => {
156
+ const result = parsePath('[...slug]');
157
+ expect(result.path).toBe('/*');
158
+ expect(result.params).toEqual(['slug']);
159
+ });
160
+
161
+ test('parsePath preserves param names in path but converts format', () => {
162
+ const result = parsePath('users/[userId]');
163
+ expect(result.path).toBe('/users/:userId');
164
+ expect(result.params).toEqual(['userId']);
165
+ // Param segment should not be lowercased
166
+ expect(result.path).toContain(':userId');
167
+ });
168
+
169
+ test('parsePath handles complex nested path with params', () => {
170
+ const result = parsePath('/api/users/[userId]/posts/[postId]/comments');
171
+ expect(result.path).toBe('/api/users/:userId/posts/:postId/comments');
172
+ expect(result.params).toEqual(['userId', 'postId']);
173
+ });
174
+
175
+ test('parsePath handles path with dots in param name', () => {
176
+ const result = parsePath('users/[user.id]');
177
+ expect(result.path).toBe('/users/:user.id');
178
+ expect(result.params).toEqual(['user.id']);
179
+ });
180
+
181
+ test('parsePath handles mixed case with params', () => {
182
+ const result = parsePath('Users/[UserId]/Posts');
183
+ expect(result.path).toBe('/users/:UserId/posts');
184
+ expect(result.params).toEqual(['UserId']);
185
+ // Non-param segments should be lowercased, but param name preserved
186
+ expect(result.path).toContain('/users/');
187
+ expect(result.path).toContain('/posts');
188
+ });
189
+
190
+ test('parsePath handles file path format', () => {
191
+ const result = parsePath('/routes/users/[userId].ts');
192
+ expect(result.path).toBe('/routes/users/:userId');
193
+ expect(result.params).toEqual(['userId']);
194
+ });
195
+ });
196
+
package/src/utils.ts CHANGED
@@ -1,5 +1,139 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
 
3
3
  export function assetHash(content: string): string {
4
4
  return createHash('md5').update(content).digest('hex');
5
+ }
6
+
7
+ export function randomHash(): string {
8
+ return createHash('md5').update(randomBytes(32).toString('hex')).digest('hex');
9
+ }
10
+
11
+
12
+ /**
13
+ * Normalize URL path
14
+ * Removes trailing slash and lowercases path
15
+ */
16
+ const ROUTE_SEGMENT_REGEX = /(\[[a-zA-Z_\.]+\])/g;
17
+ export function parsePath(urlPath: string): { path: string, params: string[] } {
18
+ const params: string[] = [];
19
+ urlPath = urlPath.replace('index', '').replace('.ts', '').replace('.js', '');
20
+
21
+ if (urlPath.startsWith('/')) {
22
+ urlPath = urlPath.substring(1);
23
+ }
24
+
25
+ if (urlPath.endsWith('/')) {
26
+ urlPath = urlPath.substring(0, urlPath.length - 1);
27
+ }
28
+
29
+ if (!urlPath) {
30
+ return { path: '/', params: [] };
31
+ }
32
+
33
+ // Dynamic params
34
+ if (ROUTE_SEGMENT_REGEX.test(urlPath)) {
35
+ urlPath = urlPath.replace(ROUTE_SEGMENT_REGEX, (match: string) => {
36
+ const paramName = match.replace(/[^a-zA-Z_\.]+/g, '').replace('...', '');
37
+ params.push(paramName);
38
+
39
+ if (match.includes('...')) {
40
+ return '*';
41
+ } else {
42
+ return ':' + paramName;
43
+ }
44
+ });
45
+ }
46
+
47
+ // Only lowercase non-param segments (do not lowercase after ':')
48
+ return {
49
+ path: (
50
+ '/' +
51
+ urlPath
52
+ .split('/')
53
+ .map((segment) =>
54
+ segment.startsWith(':') || segment === '*' ? segment : segment.toLowerCase()
55
+ )
56
+ .join('/')
57
+ ),
58
+ params,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Is valid route path to add to server?
64
+ */
65
+ export function isValidRoutePath(path: string): boolean {
66
+ const isHiddenRoute = path.includes('/__');
67
+ const isTestFile = path.includes('.test') || path.includes('.spec');
68
+
69
+ return !isHiddenRoute && !isTestFile && Boolean(path);
70
+ }
71
+
72
+ /**
73
+ * Return JSON data structure for a given FormData or URLSearchParams object
74
+ * Accounts for array fields (e.g. name="options[]" or <select multiple>)
75
+ *
76
+ * @link https://stackoverflow.com/a/75406413
77
+ */
78
+ export function formDataToJSON(formData: FormData | URLSearchParams): Record<string, string | string[]> {
79
+ let object = {};
80
+
81
+ /**
82
+ * Parses FormData key xxx`[x][x][x]` fields into array
83
+ */
84
+ const parseKey = (key: string) => {
85
+ const subKeyIdx = key.indexOf('[');
86
+
87
+ if (subKeyIdx !== -1) {
88
+ const keys = [key.substring(0, subKeyIdx)];
89
+ key = key.substring(subKeyIdx);
90
+
91
+ for (const match of key.matchAll(/\[(?<key>.*?)]/gm)) {
92
+ if (match.groups) {
93
+ keys.push(match.groups.key);
94
+ }
95
+ }
96
+ return keys;
97
+ } else {
98
+ return [key];
99
+ }
100
+ };
101
+
102
+ /**
103
+ * Recursively iterates over keys and assigns key/values to object
104
+ */
105
+ const assign = (keys: string[], value: FormDataEntryValue, object: any): void => {
106
+ const key = keys.shift();
107
+
108
+ // When last key in the iterations
109
+ if (key === '' || key === undefined) {
110
+ return object.push(value);
111
+ }
112
+
113
+ if (Reflect.has(object, key)) {
114
+ // If key has been found, but final pass - convert the value to array
115
+ if (keys.length === 0) {
116
+ if (!Array.isArray(object[key])) {
117
+ object[key] = [object[key], value];
118
+ return;
119
+ }
120
+ }
121
+ // Recurse again with found object
122
+ return assign(keys, value, object[key]);
123
+ }
124
+
125
+ // Create empty object for key, if next key is '' do array instead, otherwise set value
126
+ if (keys.length >= 1) {
127
+ object[key] = keys[0] === '' ? [] : {};
128
+ return assign(keys, value, object[key]);
129
+ } else {
130
+ object[key] = value;
131
+ }
132
+ };
133
+
134
+ for (const pair of formData.entries()) {
135
+ assign(parseKey(pair[0]), pair[1], object);
136
+ }
137
+
138
+ return object;
5
139
  }
package/tsconfig.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "compileOnSave": true,
3
3
  "compilerOptions": {
4
- "rootDir": "src",
5
4
  "outDir": "dist",
6
5
  "target": "es2019",
7
6
  "lib": ["ESNext", "dom", "dom.iterable"],
@@ -25,5 +24,6 @@
25
24
  "@hyperspan/html": ["../html/src/html.ts"]
26
25
  }
27
26
  },
27
+ "references": [{ "path": "../html" }],
28
28
  "exclude": ["node_modules", "__tests__", "*.test.ts"]
29
29
  }
@@ -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