@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.
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
@@ -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();