@webstir-io/webstir 0.1.1 → 0.1.2
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/README.md +13 -0
- package/assets/deployment/docker/.dockerignore +7 -0
- package/assets/deployment/docker/Dockerfile +17 -0
- package/assets/deployment/docker/README.md +44 -0
- package/assets/deployment/docker/example.env +3 -0
- package/assets/features/client_nav/client_nav.ts +369 -264
- package/assets/features/client_nav/document_navigation.ts +344 -0
- package/assets/features/client_nav/form_enhancement.ts +275 -0
- package/assets/templates/api/src/backend/index.ts +71 -10
- package/assets/templates/api/src/backend/tsconfig.json +6 -1
- package/assets/templates/full/src/backend/index.ts +71 -10
- package/assets/templates/full/src/backend/module.ts +515 -0
- package/assets/templates/full/src/backend/tests/progressive-enhancement.test.ts +180 -0
- package/assets/templates/full/src/backend/tsconfig.json +6 -1
- package/assets/templates/full/src/frontend/app/scripts/features/client-nav.ts +574 -0
- package/assets/templates/full/src/frontend/app/scripts/features/document-navigation.ts +344 -0
- package/assets/templates/full/src/frontend/app/scripts/features/form-enhancement.ts +275 -0
- package/assets/templates/full/src/frontend/pages/home/index.css +8 -0
- package/assets/templates/full/src/frontend/pages/home/index.html +6 -1
- package/assets/templates/full/src/frontend/pages/home/tests/home.test.ts +12 -2
- package/assets/templates/spa/src/frontend/pages/home/tests/home.test.ts +10 -2
- package/package.json +31 -13
- package/scripts/check-feature-projections.mjs +87 -0
- package/scripts/check-full-demo-sync.mjs +89 -0
- package/scripts/check-package-install.mjs +537 -0
- package/scripts/check-standalone-install.mjs +221 -0
- package/scripts/pack-standalone.mjs +52 -28
- package/scripts/publish.sh +9 -0
- package/scripts/run-tests.mjs +99 -0
- package/scripts/sync-assets.mjs +175 -17
- package/src/add-backend-compat.ts +628 -0
- package/src/add-backend.ts +155 -27
- package/src/add.ts +111 -4
- package/src/agent.ts +393 -0
- package/src/api-watch.ts +7 -4
- package/src/backend-inspect.ts +70 -2
- package/src/backend-runtime.ts +22 -14
- package/src/build.ts +1 -3
- package/src/bun-generated-frontend-watch.ts +209 -0
- package/src/bun-globals.d.ts +23 -0
- package/src/bun-spa-document.ts +310 -0
- package/src/bun-spa-routes.ts +159 -0
- package/src/bun-spa-watch.ts +29 -0
- package/src/bun-ssg-watch.ts +304 -0
- package/src/cli.ts +381 -50
- package/src/compile-tests.ts +37 -29
- package/src/dev-server.ts +214 -143
- package/src/doctor.ts +164 -0
- package/src/enable-assets.ts +18 -1
- package/src/enable.ts +133 -41
- package/src/execute.ts +28 -4
- package/src/external-workspace.ts +178 -0
- package/src/format.ts +296 -17
- package/src/frontend-inspect.ts +32 -0
- package/src/frontend-watch.ts +27 -102
- package/src/full-watch.ts +13 -18
- package/src/index.ts +7 -0
- package/src/init-assets.ts +41 -11
- package/src/init.ts +85 -71
- package/src/inspect.ts +112 -0
- package/src/mcp/run-cli-json.ts +46 -0
- package/src/mcp/server.ts +307 -0
- package/src/operations.ts +176 -0
- package/src/providers.ts +20 -18
- package/src/refresh.ts +29 -3
- package/src/repair.ts +110 -43
- package/src/runtime-filter.ts +41 -0
- package/src/runtime.ts +1 -1
- package/src/smoke.ts +48 -16
- package/src/test.ts +54 -16
- package/src/testing-runtime.ts +273 -0
- package/src/types.ts +1 -4
- package/src/watch-events.ts +46 -17
- package/src/watch.ts +5 -1
- package/src/workspace-watcher.ts +10 -6
- package/src/workspace.ts +4 -2
- package/src/watch-daemon-client.ts +0 -171
|
@@ -1,8 +1,25 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildEnhancedFormRequest,
|
|
3
|
+
normalizeFormEnctype,
|
|
4
|
+
normalizeFormMethod,
|
|
5
|
+
resolveEnhancedFormResponse,
|
|
6
|
+
resolveFragmentResponseMetadata,
|
|
7
|
+
resolveFragmentInsertionBehavior
|
|
8
|
+
} from './form-enhancement.js';
|
|
9
|
+
import {
|
|
10
|
+
cssEscape,
|
|
11
|
+
executeScripts,
|
|
12
|
+
focusAutofocus,
|
|
13
|
+
resolveDocumentNavigationResponse,
|
|
14
|
+
syncHead
|
|
15
|
+
} from './document-navigation.js';
|
|
16
|
+
|
|
1
17
|
export {};
|
|
2
18
|
|
|
3
19
|
/**
|
|
4
|
-
* Minimal
|
|
5
|
-
*
|
|
20
|
+
* Minimal document navigation enhancement: swaps the <main> content, updates
|
|
21
|
+
* title/URL, restores scroll/focus, and can consume fragment responses from
|
|
22
|
+
* enhanced POST forms.
|
|
6
23
|
*
|
|
7
24
|
* Opt out per-link with:
|
|
8
25
|
* - data-no-client-nav
|
|
@@ -23,11 +40,7 @@ export function enableClientNav(): void {
|
|
|
23
40
|
return;
|
|
24
41
|
}
|
|
25
42
|
|
|
26
|
-
|
|
27
|
-
const optOut = link.hasAttribute('data-no-client-nav')
|
|
28
|
-
|| setting === 'off'
|
|
29
|
-
|| setting === 'false';
|
|
30
|
-
if (optOut) {
|
|
43
|
+
if (hasClientNavOptOut(link)) {
|
|
31
44
|
return;
|
|
32
45
|
}
|
|
33
46
|
|
|
@@ -49,6 +62,28 @@ export function enableClientNav(): void {
|
|
|
49
62
|
await renderUrl(link.href, { pushHistory: true });
|
|
50
63
|
});
|
|
51
64
|
|
|
65
|
+
document.addEventListener('submit', async (event) => {
|
|
66
|
+
const target = event.target;
|
|
67
|
+
if (!(target instanceof HTMLFormElement)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (target.hasAttribute(BYPASS_ATTR)) {
|
|
72
|
+
target.removeAttribute(BYPASS_ATTR);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const submitEvent = event as SubmitEvent;
|
|
77
|
+
const submitter = getSubmitter(submitEvent);
|
|
78
|
+
const submission = createEnhancedFormSubmission(target, submitter);
|
|
79
|
+
if (!submission) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
await submitForm(target, submitter, submission);
|
|
85
|
+
});
|
|
86
|
+
|
|
52
87
|
window.addEventListener('popstate', async () => {
|
|
53
88
|
await renderUrl(window.location.href, { pushHistory: false });
|
|
54
89
|
});
|
|
@@ -58,7 +93,14 @@ let activeRequestId = 0;
|
|
|
58
93
|
let activeController: AbortController | null = null;
|
|
59
94
|
const DYNAMIC_ATTR = 'data-webstir-dynamic';
|
|
60
95
|
const DYNAMIC_VALUE = 'client-nav';
|
|
96
|
+
const BYPASS_ATTR = 'data-webstir-client-nav-bypass';
|
|
61
97
|
const BASE_PATH = resolveBasePath();
|
|
98
|
+
const DOM_RUNTIME = {
|
|
99
|
+
dynamicAttr: DYNAMIC_ATTR,
|
|
100
|
+
dynamicValue: DYNAMIC_VALUE,
|
|
101
|
+
withBasePath,
|
|
102
|
+
stripBasePath
|
|
103
|
+
} as const;
|
|
62
104
|
|
|
63
105
|
function resolveBasePath(): string {
|
|
64
106
|
const raw = document.documentElement?.getAttribute('data-webstir-base') ?? '';
|
|
@@ -103,15 +145,7 @@ function stripBasePath(value: string): string {
|
|
|
103
145
|
}
|
|
104
146
|
|
|
105
147
|
async function renderUrl(url: string, { pushHistory }: { pushHistory: boolean }): Promise<void> {
|
|
106
|
-
|
|
107
|
-
const requestId = activeRequestId;
|
|
108
|
-
|
|
109
|
-
if (activeController) {
|
|
110
|
-
activeController.abort();
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const controller = new AbortController();
|
|
114
|
-
activeController = controller;
|
|
148
|
+
const { controller, requestId } = beginRequest();
|
|
115
149
|
|
|
116
150
|
let response: Response;
|
|
117
151
|
try {
|
|
@@ -128,20 +162,111 @@ async function renderUrl(url: string, { pushHistory }: { pushHistory: boolean })
|
|
|
128
162
|
return;
|
|
129
163
|
}
|
|
130
164
|
|
|
131
|
-
|
|
165
|
+
const resolution = resolveDocumentNavigationResponse({
|
|
166
|
+
ok: response.ok,
|
|
167
|
+
contentType: response.headers.get('content-type')
|
|
168
|
+
});
|
|
169
|
+
if (resolution.kind === 'navigate') {
|
|
132
170
|
window.location.href = url;
|
|
133
171
|
return;
|
|
134
172
|
}
|
|
135
173
|
|
|
174
|
+
await renderDocumentResponse(response, requestId, {
|
|
175
|
+
pushHistory,
|
|
176
|
+
url
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function submitForm(
|
|
181
|
+
form: HTMLFormElement,
|
|
182
|
+
submitter: HTMLButtonElement | HTMLInputElement | null,
|
|
183
|
+
submission: { readonly url: string; readonly init: RequestInit }
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
const { controller, requestId } = beginRequest();
|
|
186
|
+
|
|
187
|
+
let response: Response;
|
|
188
|
+
try {
|
|
189
|
+
response = await fetch(submission.url, {
|
|
190
|
+
...submission.init,
|
|
191
|
+
signal: controller.signal
|
|
192
|
+
});
|
|
193
|
+
} catch {
|
|
194
|
+
if (controller.signal.aborted) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
submitFormNatively(form, submitter);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (requestId !== activeRequestId) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const metadata = resolveFragmentResponseMetadata(response.headers);
|
|
207
|
+
const fragmentTarget = metadata.kind === 'fragment'
|
|
208
|
+
? resolveFragmentTarget(metadata.fragment.target, metadata.fragment.selector)
|
|
209
|
+
: null;
|
|
210
|
+
const resolution = resolveEnhancedFormResponse({
|
|
211
|
+
metadata,
|
|
212
|
+
hasFragmentTarget: fragmentTarget !== null,
|
|
213
|
+
contentType: response.headers.get('content-type'),
|
|
214
|
+
redirected: response.redirected,
|
|
215
|
+
responseUrl: response.url,
|
|
216
|
+
requestUrl: submission.url
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (resolution.kind === 'fragment') {
|
|
220
|
+
await handleFragmentResponse(response, requestId, resolution.fragment, fragmentTarget);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (resolution.kind === 'document') {
|
|
225
|
+
await renderDocumentResponse(response, requestId, {
|
|
226
|
+
pushHistory: true,
|
|
227
|
+
url: response.url || submission.url
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
window.location.href = resolution.location;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function beginRequest(): { readonly controller: AbortController; readonly requestId: number } {
|
|
236
|
+
activeRequestId += 1;
|
|
237
|
+
const requestId = activeRequestId;
|
|
238
|
+
|
|
239
|
+
if (activeController) {
|
|
240
|
+
activeController.abort();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const controller = new AbortController();
|
|
244
|
+
activeController = controller;
|
|
245
|
+
|
|
246
|
+
return { controller, requestId };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function renderDocumentResponse(
|
|
250
|
+
response: Response,
|
|
251
|
+
requestId: number,
|
|
252
|
+
options: { readonly pushHistory: boolean; readonly url: string }
|
|
253
|
+
): Promise<void> {
|
|
136
254
|
const html = await response.text();
|
|
137
255
|
if (requestId !== activeRequestId) {
|
|
138
256
|
return;
|
|
139
257
|
}
|
|
140
258
|
|
|
259
|
+
await renderDocumentHtml(html, options);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function renderDocumentHtml(
|
|
263
|
+
html: string,
|
|
264
|
+
options: { readonly pushHistory: boolean; readonly url: string }
|
|
265
|
+
): Promise<void> {
|
|
141
266
|
const parser = new DOMParser();
|
|
142
267
|
const doc = parser.parseFromString(html, 'text/html');
|
|
143
268
|
|
|
144
|
-
await syncHead(doc, url);
|
|
269
|
+
await syncHead(doc, options.url, DOM_RUNTIME);
|
|
145
270
|
|
|
146
271
|
const newMain = doc.querySelector('main');
|
|
147
272
|
const currentMain = document.querySelector('main');
|
|
@@ -154,316 +279,296 @@ async function renderUrl(url: string, { pushHistory }: { pushHistory: boolean })
|
|
|
154
279
|
document.title = newTitle.textContent;
|
|
155
280
|
}
|
|
156
281
|
|
|
157
|
-
if (pushHistory) {
|
|
158
|
-
window.history.pushState({}, '', url);
|
|
282
|
+
if (options.pushHistory) {
|
|
283
|
+
window.history.pushState({}, '', options.url);
|
|
159
284
|
}
|
|
160
285
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
161
|
-
|
|
162
|
-
if (focusTarget instanceof HTMLElement) {
|
|
163
|
-
focusTarget.focus();
|
|
164
|
-
}
|
|
286
|
+
focusAutofocus(document);
|
|
165
287
|
|
|
166
|
-
executeScripts(document.querySelector('main'));
|
|
167
|
-
window.dispatchEvent(new CustomEvent('webstir:client-nav', { detail: { url } }));
|
|
288
|
+
executeScripts(document.querySelector('main'), DOM_RUNTIME);
|
|
289
|
+
window.dispatchEvent(new CustomEvent('webstir:client-nav', { detail: { url: options.url } }));
|
|
168
290
|
}
|
|
169
291
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const newHead = doc.head;
|
|
175
|
-
if (!head || !newHead) {
|
|
176
|
-
return;
|
|
292
|
+
function getSubmitter(event: SubmitEvent): HTMLButtonElement | HTMLInputElement | null {
|
|
293
|
+
const candidate = event.submitter;
|
|
294
|
+
if (candidate instanceof HTMLButtonElement || candidate instanceof HTMLInputElement) {
|
|
295
|
+
return candidate;
|
|
177
296
|
}
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
178
299
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
300
|
+
function createEnhancedFormSubmission(
|
|
301
|
+
form: HTMLFormElement,
|
|
302
|
+
submitter: HTMLButtonElement | HTMLInputElement | null
|
|
303
|
+
): { readonly url: string; readonly init: RequestInit } | null {
|
|
304
|
+
if (hasClientNavOptOut(form) || hasClientNavOptOut(submitter)) {
|
|
305
|
+
return null;
|
|
185
306
|
}
|
|
186
307
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (script === preservedClientNav) {
|
|
191
|
-
continue;
|
|
192
|
-
}
|
|
193
|
-
if (normalizedSrc === '/hmr.js' || normalizedSrc === '/refresh.js') {
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
196
|
-
if (normalizedSrc.startsWith('/pages/')) {
|
|
197
|
-
script.remove();
|
|
198
|
-
}
|
|
308
|
+
const target = resolveFormTarget(form, submitter);
|
|
309
|
+
if (target && target !== '_self') {
|
|
310
|
+
return null;
|
|
199
311
|
}
|
|
200
312
|
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (!href) {
|
|
205
|
-
continue;
|
|
206
|
-
}
|
|
207
|
-
const resolved = resolveUrl(href, url);
|
|
208
|
-
if (!resolved) {
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
const key = stripBasePath(stripQueryAndHash(resolved));
|
|
212
|
-
const finalHref = key === '/app/app.css' && preservedAppCss
|
|
213
|
-
? (preservedAppCss.getAttribute('href') ?? resolved)
|
|
214
|
-
: withBasePath(resolved);
|
|
215
|
-
desiredStyles.set(key, finalHref);
|
|
313
|
+
const method = resolveFormMethod(form, submitter);
|
|
314
|
+
if (normalizeFormMethod(method) !== 'POST') {
|
|
315
|
+
return null;
|
|
216
316
|
}
|
|
217
317
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
318
|
+
const enctype = resolveFormEnctype(form, submitter);
|
|
319
|
+
const action = resolveFormAction(form, submitter);
|
|
320
|
+
if (new URL(action).origin !== window.location.origin) {
|
|
321
|
+
return null;
|
|
221
322
|
}
|
|
323
|
+
const formData = createFormData(form, submitter);
|
|
222
324
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
if (desiredStyles.has(key)) {
|
|
232
|
-
if (!existingStyles.has(key)) {
|
|
233
|
-
existingStyles.set(key, link);
|
|
234
|
-
}
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
237
|
-
staleStyles.push(link);
|
|
238
|
-
}
|
|
325
|
+
return buildEnhancedFormRequest({
|
|
326
|
+
action,
|
|
327
|
+
method,
|
|
328
|
+
enctype,
|
|
329
|
+
formData
|
|
330
|
+
});
|
|
331
|
+
}
|
|
239
332
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
continue;
|
|
244
|
-
}
|
|
245
|
-
const next = document.createElement('link');
|
|
246
|
-
next.rel = 'stylesheet';
|
|
247
|
-
next.href = href;
|
|
248
|
-
head.appendChild(next);
|
|
249
|
-
existingStyles.set(key, next);
|
|
250
|
-
pendingStyles.push(next);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const stylesReady = pendingStyles.length > 0
|
|
254
|
-
? waitForStylesheets(pendingStyles)
|
|
255
|
-
: Promise.resolve();
|
|
256
|
-
if (staleStyles.length > 0) {
|
|
257
|
-
void stylesReady.then(() => {
|
|
258
|
-
requestAnimationFrame(() => {
|
|
259
|
-
for (const link of staleStyles) {
|
|
260
|
-
link.remove();
|
|
261
|
-
}
|
|
262
|
-
});
|
|
263
|
-
});
|
|
333
|
+
function hasClientNavOptOut(element: Element | null): boolean {
|
|
334
|
+
if (!element) {
|
|
335
|
+
return false;
|
|
264
336
|
}
|
|
265
337
|
|
|
266
|
-
|
|
338
|
+
const setting = element.getAttribute('data-client-nav');
|
|
339
|
+
return element.hasAttribute('data-no-client-nav')
|
|
340
|
+
|| setting === 'off'
|
|
341
|
+
|| setting === 'false';
|
|
342
|
+
}
|
|
267
343
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if (src === '/clientNav.js' || src.endsWith('/clientNav.js')) {
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
|
-
if (src === '/hmr.js' || src === '/refresh.js') {
|
|
277
|
-
continue;
|
|
278
|
-
}
|
|
344
|
+
function resolveFormAction(form: HTMLFormElement, submitter: HTMLButtonElement | HTMLInputElement | null): string {
|
|
345
|
+
const override = submitter?.getAttribute('formaction')?.trim();
|
|
346
|
+
const action = override || form.getAttribute('action')?.trim() || window.location.href;
|
|
347
|
+
return new URL(action, window.location.href).href;
|
|
348
|
+
}
|
|
279
349
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
350
|
+
function resolveFormMethod(form: HTMLFormElement, submitter: HTMLButtonElement | HTMLInputElement | null): string {
|
|
351
|
+
return submitter?.getAttribute('formmethod') || form.getAttribute('method') || form.method || 'GET';
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function resolveFormEnctype(form: HTMLFormElement, submitter: HTMLButtonElement | HTMLInputElement | null): string {
|
|
355
|
+
const override = submitter?.getAttribute('formenctype');
|
|
356
|
+
return normalizeFormEnctype(override || form.getAttribute('enctype') || form.enctype);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function resolveFormTarget(form: HTMLFormElement, submitter: HTMLButtonElement | HTMLInputElement | null): string {
|
|
360
|
+
return submitter?.getAttribute('formtarget') || form.getAttribute('target') || form.target || '';
|
|
361
|
+
}
|
|
284
362
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
363
|
+
function createFormData(
|
|
364
|
+
form: HTMLFormElement,
|
|
365
|
+
submitter: HTMLButtonElement | HTMLInputElement | null
|
|
366
|
+
): FormData {
|
|
367
|
+
try {
|
|
368
|
+
if (submitter) {
|
|
369
|
+
return new FormData(form, submitter);
|
|
289
370
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
head.appendChild(next);
|
|
371
|
+
} catch {
|
|
372
|
+
// Fall through to the broader FormData constructor.
|
|
293
373
|
}
|
|
294
374
|
|
|
295
|
-
|
|
296
|
-
|
|
375
|
+
const formData = new FormData(form);
|
|
376
|
+
if (submitter?.name && !formData.has(submitter.name)) {
|
|
377
|
+
formData.append(submitter.name, submitter.value);
|
|
297
378
|
}
|
|
298
|
-
|
|
299
|
-
await stylesReady;
|
|
379
|
+
return formData;
|
|
300
380
|
}
|
|
301
381
|
|
|
302
|
-
function
|
|
303
|
-
|
|
382
|
+
async function handleFragmentResponse(
|
|
383
|
+
response: Response,
|
|
384
|
+
requestId: number,
|
|
385
|
+
fragment: {
|
|
386
|
+
readonly target: string;
|
|
387
|
+
readonly selector?: string;
|
|
388
|
+
readonly mode: 'replace' | 'append' | 'prepend';
|
|
389
|
+
},
|
|
390
|
+
target: Element | null
|
|
391
|
+
): Promise<void> {
|
|
392
|
+
if (!target) {
|
|
304
393
|
return;
|
|
305
394
|
}
|
|
306
395
|
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const normalizedSrc = src ? stripBasePath(src) : '';
|
|
313
|
-
if (normalizedSrc && (normalizedSrc === '/clientNav.js' || normalizedSrc.endsWith('/clientNav.js'))) {
|
|
314
|
-
script.remove();
|
|
315
|
-
continue;
|
|
316
|
-
}
|
|
317
|
-
if (normalizedSrc === '/hmr.js' || normalizedSrc === '/refresh.js') {
|
|
318
|
-
script.remove();
|
|
319
|
-
continue;
|
|
320
|
-
}
|
|
396
|
+
const html = await response.text();
|
|
397
|
+
if (requestId !== activeRequestId) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
321
400
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
401
|
+
const appliedFragment = applyFragmentHtml(target, html, fragment);
|
|
402
|
+
focusInsertedAutofocus(appliedFragment.focusRoots);
|
|
403
|
+
window.dispatchEvent(new CustomEvent('webstir:fragment-update', {
|
|
404
|
+
detail: {
|
|
405
|
+
target: fragment.target,
|
|
406
|
+
selector: fragment.selector,
|
|
407
|
+
mode: fragment.mode
|
|
325
408
|
}
|
|
409
|
+
}));
|
|
410
|
+
}
|
|
326
411
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
332
|
-
} else if (script.textContent) {
|
|
333
|
-
next.textContent = script.textContent;
|
|
334
|
-
}
|
|
412
|
+
function resolveFragmentTarget(target: string, selector?: string): Element | null {
|
|
413
|
+
if (selector) {
|
|
414
|
+
return document.querySelector(selector);
|
|
415
|
+
}
|
|
335
416
|
|
|
336
|
-
|
|
417
|
+
const byId = document.getElementById(target);
|
|
418
|
+
if (byId) {
|
|
419
|
+
return byId;
|
|
337
420
|
}
|
|
421
|
+
|
|
422
|
+
return document.querySelector(`[data-webstir-fragment-target="${cssEscape(target)}"]`);
|
|
338
423
|
}
|
|
339
424
|
|
|
340
|
-
function
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
425
|
+
function applyFragmentHtml(target: Element, html: string, fragment: {
|
|
426
|
+
readonly target: string;
|
|
427
|
+
readonly selector?: string;
|
|
428
|
+
readonly mode: 'replace' | 'append' | 'prepend';
|
|
429
|
+
}): { readonly focusRoots: readonly Element[] } {
|
|
430
|
+
const template = document.createElement('template');
|
|
431
|
+
template.innerHTML = html;
|
|
432
|
+
const insertedRoots = Array.from(template.content.children);
|
|
433
|
+
const insertionBehavior = resolveFragmentInsertionBehavior({
|
|
434
|
+
mode: fragment.mode,
|
|
435
|
+
target: fragment.target,
|
|
436
|
+
hasMeaningfulSiblingContent: hasMeaningfulSiblingContent(template.content, insertedRoots[0] ?? null),
|
|
437
|
+
roots: insertedRoots.map((root) => ({
|
|
438
|
+
id: root.id,
|
|
439
|
+
fragmentTarget: root.getAttribute('data-webstir-fragment-target'),
|
|
440
|
+
matchesSelector: elementMatchesSelector(root, fragment.selector)
|
|
441
|
+
}))
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
if (insertionBehavior === 'replace-target') {
|
|
445
|
+
target.replaceWith(template.content);
|
|
446
|
+
executeInsertedScripts(insertedRoots);
|
|
447
|
+
return { focusRoots: insertedRoots };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (insertionBehavior === 'append-matching-root-children' || insertionBehavior === 'prepend-matching-root-children') {
|
|
451
|
+
const { content, roots } = extractMatchingRootChildren(template.content);
|
|
452
|
+
if (insertionBehavior === 'append-matching-root-children') {
|
|
453
|
+
target.append(content);
|
|
454
|
+
} else {
|
|
455
|
+
target.prepend(content);
|
|
349
456
|
}
|
|
457
|
+
executeInsertedScripts(roots);
|
|
458
|
+
return { focusRoots: roots };
|
|
459
|
+
}
|
|
350
460
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
}
|
|
354
|
-
|
|
461
|
+
if (insertionBehavior === 'append-payload') {
|
|
462
|
+
target.append(template.content);
|
|
463
|
+
} else if (insertionBehavior === 'prepend-payload') {
|
|
464
|
+
target.prepend(template.content);
|
|
465
|
+
} else {
|
|
466
|
+
target.replaceChildren(template.content);
|
|
355
467
|
}
|
|
468
|
+
|
|
469
|
+
executeInsertedScripts(insertedRoots);
|
|
470
|
+
return { focusRoots: insertedRoots };
|
|
356
471
|
}
|
|
357
472
|
|
|
358
|
-
function
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
return null;
|
|
473
|
+
function elementMatchesSelector(element: Element, selector: string | undefined): boolean {
|
|
474
|
+
if (!selector) {
|
|
475
|
+
return false;
|
|
362
476
|
}
|
|
363
|
-
return stripBasePath(stripQueryAndHash(resolved));
|
|
364
|
-
}
|
|
365
477
|
|
|
366
|
-
|
|
367
|
-
|
|
478
|
+
try {
|
|
479
|
+
return element.matches(selector);
|
|
480
|
+
} catch {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
368
483
|
}
|
|
369
484
|
|
|
370
|
-
function
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
return new Promise((resolve) => {
|
|
376
|
-
let remaining = links.length;
|
|
377
|
-
let done = false;
|
|
378
|
-
const finish = () => {
|
|
379
|
-
if (done) {
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
done = true;
|
|
383
|
-
resolve();
|
|
384
|
-
};
|
|
385
|
-
|
|
386
|
-
const timer = window.setTimeout(finish, timeoutMs);
|
|
387
|
-
const handle = () => {
|
|
388
|
-
if (done) {
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
|
-
remaining -= 1;
|
|
392
|
-
if (remaining <= 0) {
|
|
393
|
-
window.clearTimeout(timer);
|
|
394
|
-
finish();
|
|
395
|
-
}
|
|
396
|
-
};
|
|
397
|
-
|
|
398
|
-
for (const link of links) {
|
|
399
|
-
if (link.sheet) {
|
|
400
|
-
handle();
|
|
401
|
-
continue;
|
|
402
|
-
}
|
|
403
|
-
link.addEventListener('load', handle, { once: true });
|
|
404
|
-
link.addEventListener('error', handle, { once: true });
|
|
485
|
+
function hasMeaningfulSiblingContent(content: DocumentFragment, root: Element | null): boolean {
|
|
486
|
+
for (const node of Array.from(content.childNodes)) {
|
|
487
|
+
if (node === root || node instanceof Comment) {
|
|
488
|
+
continue;
|
|
405
489
|
}
|
|
406
|
-
|
|
490
|
+
if (node instanceof Text && !node.textContent?.trim()) {
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
return false;
|
|
407
496
|
}
|
|
408
497
|
|
|
409
|
-
function
|
|
410
|
-
|
|
411
|
-
|
|
498
|
+
function extractMatchingRootChildren(content: DocumentFragment): {
|
|
499
|
+
readonly content: DocumentFragment;
|
|
500
|
+
readonly roots: readonly Element[];
|
|
501
|
+
} {
|
|
502
|
+
const fragment = document.createDocumentFragment();
|
|
503
|
+
const roots: Element[] = [];
|
|
504
|
+
const root = content.firstElementChild;
|
|
505
|
+
if (!root) {
|
|
506
|
+
return { content: fragment, roots };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
while (root.firstChild) {
|
|
510
|
+
const node = root.firstChild;
|
|
511
|
+
fragment.append(node);
|
|
512
|
+
if (node instanceof Element) {
|
|
513
|
+
roots.push(node);
|
|
514
|
+
}
|
|
412
515
|
}
|
|
413
516
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
if (
|
|
420
|
-
|
|
517
|
+
return { content: fragment, roots };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function executeInsertedScripts(roots: readonly Element[]): void {
|
|
521
|
+
for (const root of roots) {
|
|
522
|
+
if (root.tagName.toLowerCase() === 'script') {
|
|
523
|
+
executeTopLevelScriptRoot(root as HTMLScriptElement);
|
|
524
|
+
continue;
|
|
421
525
|
}
|
|
422
|
-
|
|
526
|
+
executeScripts(root, DOM_RUNTIME);
|
|
423
527
|
}
|
|
424
528
|
}
|
|
425
529
|
|
|
426
|
-
function
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
530
|
+
function executeTopLevelScriptRoot(script: HTMLScriptElement): void {
|
|
531
|
+
const wrapper = document.createElement('div');
|
|
532
|
+
wrapper.append(script.cloneNode(true));
|
|
533
|
+
executeScripts(wrapper, DOM_RUNTIME);
|
|
430
534
|
|
|
431
|
-
|
|
432
|
-
if (
|
|
433
|
-
|
|
535
|
+
const replacement = wrapper.querySelector('script');
|
|
536
|
+
if (replacement) {
|
|
537
|
+
script.replaceWith(replacement);
|
|
538
|
+
return;
|
|
434
539
|
}
|
|
435
540
|
|
|
436
|
-
|
|
437
|
-
const normalized = stripBasePath(new URL(href, window.location.origin).pathname);
|
|
438
|
-
return normalized === '/app/app.css';
|
|
439
|
-
} catch {
|
|
440
|
-
const trimmed = href.trim();
|
|
441
|
-
if (!trimmed) {
|
|
442
|
-
return false;
|
|
443
|
-
}
|
|
444
|
-
const [path] = trimmed.split(/[?#]/);
|
|
445
|
-
return stripBasePath(path) === '/app/app.css';
|
|
446
|
-
}
|
|
541
|
+
script.remove();
|
|
447
542
|
}
|
|
448
543
|
|
|
449
|
-
function
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
return 'home';
|
|
544
|
+
function focusInsertedAutofocus(roots: readonly Element[]): void {
|
|
545
|
+
for (const root of roots) {
|
|
546
|
+
if (root instanceof HTMLElement && root.hasAttribute('autofocus')) {
|
|
547
|
+
root.focus();
|
|
548
|
+
return;
|
|
455
549
|
}
|
|
456
550
|
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
551
|
+
const descendant = root.querySelector('[autofocus]');
|
|
552
|
+
if (descendant instanceof HTMLElement) {
|
|
553
|
+
descendant.focus();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
461
556
|
}
|
|
462
557
|
}
|
|
463
558
|
|
|
464
|
-
function
|
|
465
|
-
|
|
466
|
-
|
|
559
|
+
function submitFormNatively(
|
|
560
|
+
form: HTMLFormElement,
|
|
561
|
+
submitter: HTMLButtonElement | HTMLInputElement | null
|
|
562
|
+
): void {
|
|
563
|
+
form.setAttribute(BYPASS_ATTR, 'true');
|
|
564
|
+
if (submitter && typeof form.requestSubmit === 'function') {
|
|
565
|
+
form.requestSubmit(submitter);
|
|
566
|
+
return;
|
|
467
567
|
}
|
|
468
|
-
|
|
568
|
+
window.setTimeout(() => {
|
|
569
|
+
form.removeAttribute(BYPASS_ATTR);
|
|
570
|
+
}, 0);
|
|
571
|
+
form.submit();
|
|
469
572
|
}
|
|
573
|
+
|
|
574
|
+
enableClientNav();
|