@webstir-io/webstir 0.1.0 → 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
|
@@ -0,0 +1,574 @@
|
|
|
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
|
+
|
|
17
|
+
export {};
|
|
18
|
+
|
|
19
|
+
/**
|
|
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.
|
|
23
|
+
*
|
|
24
|
+
* Opt out per-link with:
|
|
25
|
+
* - data-no-client-nav
|
|
26
|
+
* - data-client-nav="off"
|
|
27
|
+
*/
|
|
28
|
+
export function enableClientNav(): void {
|
|
29
|
+
document.addEventListener('click', async (event) => {
|
|
30
|
+
const target = event.target;
|
|
31
|
+
if (!(target instanceof Element)) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const link = target.closest('a');
|
|
39
|
+
if (!link || !(link instanceof HTMLAnchorElement)) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (hasClientNavOptOut(link)) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const isExternal = link.origin !== window.location.origin;
|
|
48
|
+
const opensInNewTab = link.getAttribute('target') === '_blank';
|
|
49
|
+
const isDownload = link.hasAttribute('download');
|
|
50
|
+
if (isExternal || opensInNewTab || isDownload) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const isSameDocumentAnchor = link.hash
|
|
55
|
+
&& link.pathname === window.location.pathname
|
|
56
|
+
&& link.search === window.location.search;
|
|
57
|
+
if (isSameDocumentAnchor) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
await renderUrl(link.href, { pushHistory: true });
|
|
63
|
+
});
|
|
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
|
+
|
|
87
|
+
window.addEventListener('popstate', async () => {
|
|
88
|
+
await renderUrl(window.location.href, { pushHistory: false });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let activeRequestId = 0;
|
|
93
|
+
let activeController: AbortController | null = null;
|
|
94
|
+
const DYNAMIC_ATTR = 'data-webstir-dynamic';
|
|
95
|
+
const DYNAMIC_VALUE = 'client-nav';
|
|
96
|
+
const BYPASS_ATTR = 'data-webstir-client-nav-bypass';
|
|
97
|
+
const BASE_PATH = resolveBasePath();
|
|
98
|
+
const DOM_RUNTIME = {
|
|
99
|
+
dynamicAttr: DYNAMIC_ATTR,
|
|
100
|
+
dynamicValue: DYNAMIC_VALUE,
|
|
101
|
+
withBasePath,
|
|
102
|
+
stripBasePath
|
|
103
|
+
} as const;
|
|
104
|
+
|
|
105
|
+
function resolveBasePath(): string {
|
|
106
|
+
const raw = document.documentElement?.getAttribute('data-webstir-base') ?? '';
|
|
107
|
+
return normalizeBasePath(raw);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeBasePath(value: string): string {
|
|
111
|
+
const trimmed = value.trim();
|
|
112
|
+
if (!trimmed || trimmed === '/') {
|
|
113
|
+
return '';
|
|
114
|
+
}
|
|
115
|
+
if (!trimmed.startsWith('/')) {
|
|
116
|
+
return `/${trimmed}`;
|
|
117
|
+
}
|
|
118
|
+
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function withBasePath(value: string): string {
|
|
122
|
+
if (!BASE_PATH) {
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
if (!value.startsWith('/') || value.startsWith('//')) {
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
if (value === BASE_PATH || value.startsWith(`${BASE_PATH}/`) || value.startsWith(`${BASE_PATH}?`) || value.startsWith(`${BASE_PATH}#`)) {
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
return `${BASE_PATH}${value}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function stripBasePath(value: string): string {
|
|
135
|
+
if (!BASE_PATH || !value.startsWith('/')) {
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
if (value === BASE_PATH) {
|
|
139
|
+
return '/';
|
|
140
|
+
}
|
|
141
|
+
if (value.startsWith(`${BASE_PATH}/`) || value.startsWith(`${BASE_PATH}?`) || value.startsWith(`${BASE_PATH}#`)) {
|
|
142
|
+
return value.slice(BASE_PATH.length);
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function renderUrl(url: string, { pushHistory }: { pushHistory: boolean }): Promise<void> {
|
|
148
|
+
const { controller, requestId } = beginRequest();
|
|
149
|
+
|
|
150
|
+
let response: Response;
|
|
151
|
+
try {
|
|
152
|
+
response = await fetch(url, {
|
|
153
|
+
headers: { 'X-Webstir-Client-Nav': '1' },
|
|
154
|
+
signal: controller.signal
|
|
155
|
+
});
|
|
156
|
+
} catch {
|
|
157
|
+
if (controller.signal.aborted) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
window.location.href = url;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const resolution = resolveDocumentNavigationResponse({
|
|
166
|
+
ok: response.ok,
|
|
167
|
+
contentType: response.headers.get('content-type')
|
|
168
|
+
});
|
|
169
|
+
if (resolution.kind === 'navigate') {
|
|
170
|
+
window.location.href = url;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
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> {
|
|
254
|
+
const html = await response.text();
|
|
255
|
+
if (requestId !== activeRequestId) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
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> {
|
|
266
|
+
const parser = new DOMParser();
|
|
267
|
+
const doc = parser.parseFromString(html, 'text/html');
|
|
268
|
+
|
|
269
|
+
await syncHead(doc, options.url, DOM_RUNTIME);
|
|
270
|
+
|
|
271
|
+
const newMain = doc.querySelector('main');
|
|
272
|
+
const currentMain = document.querySelector('main');
|
|
273
|
+
if (newMain && currentMain) {
|
|
274
|
+
currentMain.replaceWith(newMain);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const newTitle = doc.querySelector('title');
|
|
278
|
+
if (newTitle && newTitle.textContent) {
|
|
279
|
+
document.title = newTitle.textContent;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (options.pushHistory) {
|
|
283
|
+
window.history.pushState({}, '', options.url);
|
|
284
|
+
}
|
|
285
|
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
286
|
+
focusAutofocus(document);
|
|
287
|
+
|
|
288
|
+
executeScripts(document.querySelector('main'), DOM_RUNTIME);
|
|
289
|
+
window.dispatchEvent(new CustomEvent('webstir:client-nav', { detail: { url: options.url } }));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function getSubmitter(event: SubmitEvent): HTMLButtonElement | HTMLInputElement | null {
|
|
293
|
+
const candidate = event.submitter;
|
|
294
|
+
if (candidate instanceof HTMLButtonElement || candidate instanceof HTMLInputElement) {
|
|
295
|
+
return candidate;
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
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;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const target = resolveFormTarget(form, submitter);
|
|
309
|
+
if (target && target !== '_self') {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const method = resolveFormMethod(form, submitter);
|
|
314
|
+
if (normalizeFormMethod(method) !== 'POST') {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const enctype = resolveFormEnctype(form, submitter);
|
|
319
|
+
const action = resolveFormAction(form, submitter);
|
|
320
|
+
if (new URL(action).origin !== window.location.origin) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
const formData = createFormData(form, submitter);
|
|
324
|
+
|
|
325
|
+
return buildEnhancedFormRequest({
|
|
326
|
+
action,
|
|
327
|
+
method,
|
|
328
|
+
enctype,
|
|
329
|
+
formData
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function hasClientNavOptOut(element: Element | null): boolean {
|
|
334
|
+
if (!element) {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const setting = element.getAttribute('data-client-nav');
|
|
339
|
+
return element.hasAttribute('data-no-client-nav')
|
|
340
|
+
|| setting === 'off'
|
|
341
|
+
|| setting === 'false';
|
|
342
|
+
}
|
|
343
|
+
|
|
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
|
+
}
|
|
349
|
+
|
|
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
|
+
}
|
|
362
|
+
|
|
363
|
+
function createFormData(
|
|
364
|
+
form: HTMLFormElement,
|
|
365
|
+
submitter: HTMLButtonElement | HTMLInputElement | null
|
|
366
|
+
): FormData {
|
|
367
|
+
try {
|
|
368
|
+
if (submitter) {
|
|
369
|
+
return new FormData(form, submitter);
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
// Fall through to the broader FormData constructor.
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const formData = new FormData(form);
|
|
376
|
+
if (submitter?.name && !formData.has(submitter.name)) {
|
|
377
|
+
formData.append(submitter.name, submitter.value);
|
|
378
|
+
}
|
|
379
|
+
return formData;
|
|
380
|
+
}
|
|
381
|
+
|
|
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) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const html = await response.text();
|
|
397
|
+
if (requestId !== activeRequestId) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
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
|
|
408
|
+
}
|
|
409
|
+
}));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function resolveFragmentTarget(target: string, selector?: string): Element | null {
|
|
413
|
+
if (selector) {
|
|
414
|
+
return document.querySelector(selector);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const byId = document.getElementById(target);
|
|
418
|
+
if (byId) {
|
|
419
|
+
return byId;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return document.querySelector(`[data-webstir-fragment-target="${cssEscape(target)}"]`);
|
|
423
|
+
}
|
|
424
|
+
|
|
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);
|
|
456
|
+
}
|
|
457
|
+
executeInsertedScripts(roots);
|
|
458
|
+
return { focusRoots: roots };
|
|
459
|
+
}
|
|
460
|
+
|
|
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);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
executeInsertedScripts(insertedRoots);
|
|
470
|
+
return { focusRoots: insertedRoots };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function elementMatchesSelector(element: Element, selector: string | undefined): boolean {
|
|
474
|
+
if (!selector) {
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
return element.matches(selector);
|
|
480
|
+
} catch {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
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;
|
|
489
|
+
}
|
|
490
|
+
if (node instanceof Text && !node.textContent?.trim()) {
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
|
|
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
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
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;
|
|
525
|
+
}
|
|
526
|
+
executeScripts(root, DOM_RUNTIME);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function executeTopLevelScriptRoot(script: HTMLScriptElement): void {
|
|
531
|
+
const wrapper = document.createElement('div');
|
|
532
|
+
wrapper.append(script.cloneNode(true));
|
|
533
|
+
executeScripts(wrapper, DOM_RUNTIME);
|
|
534
|
+
|
|
535
|
+
const replacement = wrapper.querySelector('script');
|
|
536
|
+
if (replacement) {
|
|
537
|
+
script.replaceWith(replacement);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
script.remove();
|
|
542
|
+
}
|
|
543
|
+
|
|
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;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const descendant = root.querySelector('[autofocus]');
|
|
552
|
+
if (descendant instanceof HTMLElement) {
|
|
553
|
+
descendant.focus();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
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;
|
|
567
|
+
}
|
|
568
|
+
window.setTimeout(() => {
|
|
569
|
+
form.removeAttribute(BYPASS_ATTR);
|
|
570
|
+
}, 0);
|
|
571
|
+
form.submit();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
enableClientNav();
|