@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.
Files changed (77) hide show
  1. package/README.md +13 -0
  2. package/assets/deployment/docker/.dockerignore +7 -0
  3. package/assets/deployment/docker/Dockerfile +17 -0
  4. package/assets/deployment/docker/README.md +44 -0
  5. package/assets/deployment/docker/example.env +3 -0
  6. package/assets/features/client_nav/client_nav.ts +369 -264
  7. package/assets/features/client_nav/document_navigation.ts +344 -0
  8. package/assets/features/client_nav/form_enhancement.ts +275 -0
  9. package/assets/templates/api/src/backend/index.ts +71 -10
  10. package/assets/templates/api/src/backend/tsconfig.json +6 -1
  11. package/assets/templates/full/src/backend/index.ts +71 -10
  12. package/assets/templates/full/src/backend/module.ts +515 -0
  13. package/assets/templates/full/src/backend/tests/progressive-enhancement.test.ts +180 -0
  14. package/assets/templates/full/src/backend/tsconfig.json +6 -1
  15. package/assets/templates/full/src/frontend/app/scripts/features/client-nav.ts +574 -0
  16. package/assets/templates/full/src/frontend/app/scripts/features/document-navigation.ts +344 -0
  17. package/assets/templates/full/src/frontend/app/scripts/features/form-enhancement.ts +275 -0
  18. package/assets/templates/full/src/frontend/pages/home/index.css +8 -0
  19. package/assets/templates/full/src/frontend/pages/home/index.html +6 -1
  20. package/assets/templates/full/src/frontend/pages/home/tests/home.test.ts +12 -2
  21. package/assets/templates/spa/src/frontend/pages/home/tests/home.test.ts +10 -2
  22. package/package.json +31 -13
  23. package/scripts/check-feature-projections.mjs +87 -0
  24. package/scripts/check-full-demo-sync.mjs +89 -0
  25. package/scripts/check-package-install.mjs +537 -0
  26. package/scripts/check-standalone-install.mjs +221 -0
  27. package/scripts/pack-standalone.mjs +52 -28
  28. package/scripts/publish.sh +9 -0
  29. package/scripts/run-tests.mjs +99 -0
  30. package/scripts/sync-assets.mjs +175 -17
  31. package/src/add-backend-compat.ts +628 -0
  32. package/src/add-backend.ts +155 -27
  33. package/src/add.ts +111 -4
  34. package/src/agent.ts +393 -0
  35. package/src/api-watch.ts +7 -4
  36. package/src/backend-inspect.ts +70 -2
  37. package/src/backend-runtime.ts +22 -14
  38. package/src/build.ts +1 -3
  39. package/src/bun-generated-frontend-watch.ts +209 -0
  40. package/src/bun-globals.d.ts +23 -0
  41. package/src/bun-spa-document.ts +310 -0
  42. package/src/bun-spa-routes.ts +159 -0
  43. package/src/bun-spa-watch.ts +29 -0
  44. package/src/bun-ssg-watch.ts +304 -0
  45. package/src/cli.ts +381 -50
  46. package/src/compile-tests.ts +37 -29
  47. package/src/dev-server.ts +214 -143
  48. package/src/doctor.ts +164 -0
  49. package/src/enable-assets.ts +18 -1
  50. package/src/enable.ts +133 -41
  51. package/src/execute.ts +28 -4
  52. package/src/external-workspace.ts +178 -0
  53. package/src/format.ts +296 -17
  54. package/src/frontend-inspect.ts +32 -0
  55. package/src/frontend-watch.ts +27 -102
  56. package/src/full-watch.ts +13 -18
  57. package/src/index.ts +7 -0
  58. package/src/init-assets.ts +41 -11
  59. package/src/init.ts +85 -71
  60. package/src/inspect.ts +112 -0
  61. package/src/mcp/run-cli-json.ts +46 -0
  62. package/src/mcp/server.ts +307 -0
  63. package/src/operations.ts +176 -0
  64. package/src/providers.ts +20 -18
  65. package/src/refresh.ts +29 -3
  66. package/src/repair.ts +110 -43
  67. package/src/runtime-filter.ts +41 -0
  68. package/src/runtime.ts +1 -1
  69. package/src/smoke.ts +48 -16
  70. package/src/test.ts +54 -16
  71. package/src/testing-runtime.ts +273 -0
  72. package/src/types.ts +1 -4
  73. package/src/watch-events.ts +46 -17
  74. package/src/watch.ts +5 -1
  75. package/src/workspace-watcher.ts +10 -6
  76. package/src/workspace.ts +4 -2
  77. 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 PJAX-style navigation: swaps the <main> content, updates title/URL,
5
- * and restores scroll/focus.
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
- const setting = link.getAttribute('data-client-nav');
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
- activeRequestId += 1;
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
- if (!response.ok) {
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
- const focusTarget = document.querySelector('[autofocus]');
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
- enableClientNav();
171
-
172
- async function syncHead(doc: Document, url: string): Promise<void> {
173
- const head = document.head;
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
- const preservedClientNav = head.querySelector('script[data-webstir="client-nav"]');
180
- const preservedAppCss = Array.from(head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'))
181
- .find((link) => isAppStylesheetHref(link.getAttribute('href'))) ?? null;
182
-
183
- for (const element of Array.from(head.querySelectorAll(`script[${DYNAMIC_ATTR}="${DYNAMIC_VALUE}"]`))) {
184
- element.remove();
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
- for (const script of Array.from(head.querySelectorAll('script[src]'))) {
188
- const src = script.getAttribute('src') ?? '';
189
- const normalizedSrc = stripBasePath(src);
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 desiredStyles = new Map<string, string>();
202
- for (const link of Array.from(newHead.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'))) {
203
- const href = link.getAttribute('href');
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
- if (preservedAppCss) {
219
- const appHref = preservedAppCss.getAttribute('href') ?? withBasePath('/app/app.css');
220
- desiredStyles.set('/app/app.css', appHref);
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
- const existingStyles = new Map<string, HTMLLinkElement>();
224
- const staleStyles: HTMLLinkElement[] = [];
225
- for (const link of Array.from(head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'))) {
226
- const key = normalizeStylesheetKey(link.getAttribute('href'), window.location.href);
227
- if (!key) {
228
- link.remove();
229
- continue;
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
- const pendingStyles: HTMLLinkElement[] = [];
241
- for (const [key, href] of desiredStyles.entries()) {
242
- if (existingStyles.has(key)) {
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
- syncCriticalStyles(head, newHead);
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
- for (const script of Array.from(newHead.querySelectorAll('script[src]'))) {
269
- const src = script.getAttribute('src');
270
- if (!src) {
271
- continue;
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
- const resolved = resolveUrl(src, url);
281
- if (!resolved) {
282
- continue;
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
- const next = document.createElement('script');
286
- const type = script.getAttribute('type');
287
- if (type) {
288
- next.type = type;
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
- next.src = resolved;
291
- next.setAttribute(DYNAMIC_ATTR, DYNAMIC_VALUE);
292
- head.appendChild(next);
371
+ } catch {
372
+ // Fall through to the broader FormData constructor.
293
373
  }
294
374
 
295
- if (preservedClientNav && !head.contains(preservedClientNav)) {
296
- head.appendChild(preservedClientNav);
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 executeScripts(container: Element | null): void {
303
- if (!container) {
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 scripts = Array.from(container.querySelectorAll('script'));
308
- for (const script of scripts) {
309
- const src = script.getAttribute('src');
310
- const type = script.getAttribute('type');
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
- const next = document.createElement('script');
323
- if (type) {
324
- next.type = type;
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
- if (src) {
328
- const resolved = resolveUrl(src, window.location.href);
329
- if (resolved) {
330
- next.src = resolved;
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
- script.replaceWith(next);
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 resolveUrl(value: string, baseUrl: string): string | null {
341
- try {
342
- const trimmed = String(value ?? '').trim();
343
- const [path, suffix] = splitPathSuffix(trimmed);
344
- if (path && !path.startsWith('/') && !path.startsWith('http:') && !path.startsWith('https:')) {
345
- if (path === 'index.js' || path === 'index.css') {
346
- const pageName = getPageNameFromUrl(baseUrl);
347
- return withBasePath(`/pages/${pageName}/${path}${suffix}`);
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
- const resolved = new URL(value, baseUrl);
352
- return withBasePath(resolved.pathname + resolved.search + resolved.hash);
353
- } catch {
354
- return null;
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 normalizeStylesheetKey(href: string | null, baseUrl: string): string | null {
359
- const resolved = resolveUrl(href ?? '', baseUrl);
360
- if (!resolved) {
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
- function stripQueryAndHash(value: string): string {
367
- return value.split(/[?#]/)[0] ?? value;
478
+ try {
479
+ return element.matches(selector);
480
+ } catch {
481
+ return false;
482
+ }
368
483
  }
369
484
 
370
- function waitForStylesheets(links: HTMLLinkElement[], timeoutMs = 2000): Promise<void> {
371
- if (links.length === 0) {
372
- return Promise.resolve();
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 syncCriticalStyles(head: HTMLHeadElement, newHead: HTMLHeadElement): void {
410
- for (const style of Array.from(head.querySelectorAll<HTMLStyleElement>('style[data-critical]'))) {
411
- style.remove();
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
- for (const style of Array.from(newHead.querySelectorAll<HTMLStyleElement>('style[data-critical]'))) {
415
- const next = document.createElement('style');
416
- for (const attribute of Array.from(style.attributes)) {
417
- next.setAttribute(attribute.name, attribute.value);
418
- }
419
- if (style.textContent) {
420
- next.textContent = style.textContent;
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
- head.appendChild(next);
526
+ executeScripts(root, DOM_RUNTIME);
423
527
  }
424
528
  }
425
529
 
426
- function splitPathSuffix(value: string): [string, string] {
427
- const [path, suffix = ''] = value.split(/(?=[?#])/);
428
- return [path ?? '', suffix ?? ''];
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
- function isAppStylesheetHref(href: string | null): boolean {
432
- if (!href) {
433
- return false;
535
+ const replacement = wrapper.querySelector('script');
536
+ if (replacement) {
537
+ script.replaceWith(replacement);
538
+ return;
434
539
  }
435
540
 
436
- try {
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 getPageNameFromUrl(url: string): string {
450
- try {
451
- const pathname = stripBasePath(new URL(url, window.location.href).pathname);
452
- const trimmed = pathname.replace(/^\/+|\/+$/g, '');
453
- if (!trimmed) {
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 firstSegment = trimmed.split('/')[0];
458
- return firstSegment || 'home';
459
- } catch {
460
- return 'home';
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 cssEscape(value: string): string {
465
- if (typeof CSS !== 'undefined' && typeof (CSS as { escape?: (value: string) => string }).escape === 'function') {
466
- return (CSS as { escape: (value: string) => string }).escape(value);
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
- return value.replace(/[\"\\\\]/g, '\\\\$&');
568
+ window.setTimeout(() => {
569
+ form.removeAttribute(BYPASS_ATTR);
570
+ }, 0);
571
+ form.submit();
469
572
  }
573
+
574
+ enableClientNav();