@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
@@ -0,0 +1,344 @@
1
+ export interface NavigationDomRuntime {
2
+ readonly dynamicAttr: string;
3
+ readonly dynamicValue: string;
4
+ withBasePath(value: string): string;
5
+ stripBasePath(value: string): string;
6
+ }
7
+
8
+ export type DocumentNavigationResponseResolution =
9
+ | { readonly kind: 'document' }
10
+ | { readonly kind: 'navigate'; readonly reason: 'http-status' | 'non-html' };
11
+
12
+ export function resolveDocumentNavigationResponse(options: {
13
+ readonly ok: boolean;
14
+ readonly contentType: string | null;
15
+ }): DocumentNavigationResponseResolution {
16
+ if (!options.ok) {
17
+ return { kind: 'navigate', reason: 'http-status' };
18
+ }
19
+
20
+ if (!isHtmlDocumentContentType(options.contentType)) {
21
+ return { kind: 'navigate', reason: 'non-html' };
22
+ }
23
+
24
+ return { kind: 'document' };
25
+ }
26
+
27
+ export async function syncHead(
28
+ doc: Document,
29
+ url: string,
30
+ runtime: NavigationDomRuntime
31
+ ): Promise<void> {
32
+ const head = document.head;
33
+ const newHead = doc.head;
34
+ if (!head || !newHead) {
35
+ return;
36
+ }
37
+
38
+ const preservedClientNav = head.querySelector('script[data-webstir="client-nav"]');
39
+ const preservedAppCss = Array.from(head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'))
40
+ .find((link) => isAppStylesheetHref(link.getAttribute('href'), runtime.stripBasePath)) ?? null;
41
+
42
+ for (const element of Array.from(head.querySelectorAll(`script[${runtime.dynamicAttr}="${runtime.dynamicValue}"]`))) {
43
+ element.remove();
44
+ }
45
+
46
+ for (const script of Array.from(head.querySelectorAll('script[src]'))) {
47
+ const src = script.getAttribute('src') ?? '';
48
+ const normalizedSrc = runtime.stripBasePath(src);
49
+ if (script === preservedClientNav) {
50
+ continue;
51
+ }
52
+ if (normalizedSrc === '/hmr.js' || normalizedSrc === '/refresh.js') {
53
+ continue;
54
+ }
55
+ if (normalizedSrc.startsWith('/pages/')) {
56
+ script.remove();
57
+ }
58
+ }
59
+
60
+ const desiredStyles = new Map<string, string>();
61
+ for (const link of Array.from(newHead.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'))) {
62
+ const href = link.getAttribute('href');
63
+ if (!href) {
64
+ continue;
65
+ }
66
+ const resolved = resolveUrl(href, url, runtime);
67
+ if (!resolved) {
68
+ continue;
69
+ }
70
+ const key = runtime.stripBasePath(stripQueryAndHash(resolved));
71
+ const finalHref = key === '/app/app.css' && preservedAppCss
72
+ ? (preservedAppCss.getAttribute('href') ?? resolved)
73
+ : runtime.withBasePath(resolved);
74
+ desiredStyles.set(key, finalHref);
75
+ }
76
+
77
+ if (preservedAppCss) {
78
+ const appHref = preservedAppCss.getAttribute('href') ?? runtime.withBasePath('/app/app.css');
79
+ desiredStyles.set('/app/app.css', appHref);
80
+ }
81
+
82
+ const existingStyles = new Map<string, HTMLLinkElement>();
83
+ const staleStyles: HTMLLinkElement[] = [];
84
+ for (const link of Array.from(head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'))) {
85
+ const key = normalizeStylesheetKey(link.getAttribute('href'), window.location.href, runtime);
86
+ if (!key) {
87
+ link.remove();
88
+ continue;
89
+ }
90
+ if (desiredStyles.has(key)) {
91
+ if (!existingStyles.has(key)) {
92
+ existingStyles.set(key, link);
93
+ }
94
+ continue;
95
+ }
96
+ staleStyles.push(link);
97
+ }
98
+
99
+ const pendingStyles: HTMLLinkElement[] = [];
100
+ for (const [key, href] of desiredStyles.entries()) {
101
+ if (existingStyles.has(key)) {
102
+ continue;
103
+ }
104
+ const next = document.createElement('link');
105
+ next.rel = 'stylesheet';
106
+ next.href = href;
107
+ head.appendChild(next);
108
+ existingStyles.set(key, next);
109
+ pendingStyles.push(next);
110
+ }
111
+
112
+ const stylesReady = pendingStyles.length > 0
113
+ ? waitForStylesheets(pendingStyles)
114
+ : Promise.resolve();
115
+ if (staleStyles.length > 0) {
116
+ void stylesReady.then(() => {
117
+ requestAnimationFrame(() => {
118
+ for (const link of staleStyles) {
119
+ link.remove();
120
+ }
121
+ });
122
+ });
123
+ }
124
+
125
+ syncCriticalStyles(head, newHead);
126
+
127
+ for (const script of Array.from(newHead.querySelectorAll('script[src]'))) {
128
+ const src = script.getAttribute('src');
129
+ if (!src) {
130
+ continue;
131
+ }
132
+ if (src === '/clientNav.js' || src.endsWith('/clientNav.js')) {
133
+ continue;
134
+ }
135
+ if (src === '/hmr.js' || src === '/refresh.js') {
136
+ continue;
137
+ }
138
+
139
+ const resolved = resolveUrl(src, url, runtime);
140
+ if (!resolved) {
141
+ continue;
142
+ }
143
+
144
+ const next = document.createElement('script');
145
+ const type = script.getAttribute('type');
146
+ if (type) {
147
+ next.type = type;
148
+ }
149
+ next.src = resolved;
150
+ next.setAttribute(runtime.dynamicAttr, runtime.dynamicValue);
151
+ head.appendChild(next);
152
+ }
153
+
154
+ if (preservedClientNav && !head.contains(preservedClientNav)) {
155
+ head.appendChild(preservedClientNav);
156
+ }
157
+
158
+ await stylesReady;
159
+ }
160
+
161
+ export function executeScripts(container: Element | null, runtime: NavigationDomRuntime): void {
162
+ if (!container) {
163
+ return;
164
+ }
165
+
166
+ const scripts = Array.from(container.querySelectorAll('script'));
167
+ for (const script of scripts) {
168
+ const src = script.getAttribute('src');
169
+ const type = script.getAttribute('type');
170
+
171
+ const normalizedSrc = src ? runtime.stripBasePath(src) : '';
172
+ if (normalizedSrc && (normalizedSrc === '/clientNav.js' || normalizedSrc.endsWith('/clientNav.js'))) {
173
+ script.remove();
174
+ continue;
175
+ }
176
+ if (normalizedSrc === '/hmr.js' || normalizedSrc === '/refresh.js') {
177
+ script.remove();
178
+ continue;
179
+ }
180
+
181
+ const next = document.createElement('script');
182
+ if (type) {
183
+ next.type = type;
184
+ }
185
+
186
+ if (src) {
187
+ const resolved = resolveUrl(src, window.location.href, runtime);
188
+ if (resolved) {
189
+ next.src = resolved;
190
+ }
191
+ } else if (script.textContent) {
192
+ next.textContent = script.textContent;
193
+ }
194
+
195
+ script.replaceWith(next);
196
+ }
197
+ }
198
+
199
+ export function focusAutofocus(root: ParentNode): void {
200
+ const focusTarget = root.querySelector('[autofocus]');
201
+ if (focusTarget instanceof HTMLElement) {
202
+ focusTarget.focus();
203
+ }
204
+ }
205
+
206
+ export function cssEscape(value: string): string {
207
+ if (typeof CSS !== 'undefined' && typeof (CSS as { escape?: (input: string) => string }).escape === 'function') {
208
+ return (CSS as { escape: (input: string) => string }).escape(value);
209
+ }
210
+ return value.replace(/[\"\\\\]/g, '\\\\$&');
211
+ }
212
+
213
+ function resolveUrl(value: string, baseUrl: string, runtime: NavigationDomRuntime): string | null {
214
+ try {
215
+ const trimmed = String(value ?? '').trim();
216
+ const [path, suffix] = splitPathSuffix(trimmed);
217
+ if (path && !path.startsWith('/') && !path.startsWith('http:') && !path.startsWith('https:')) {
218
+ if (path === 'index.js' || path === 'index.css') {
219
+ const pageName = getPageNameFromUrl(baseUrl, runtime.stripBasePath);
220
+ return runtime.withBasePath(`/pages/${pageName}/${path}${suffix}`);
221
+ }
222
+ }
223
+
224
+ const resolved = new URL(value, baseUrl);
225
+ return runtime.withBasePath(resolved.pathname + resolved.search + resolved.hash);
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ function normalizeStylesheetKey(href: string | null, baseUrl: string, runtime: NavigationDomRuntime): string | null {
232
+ const resolved = resolveUrl(href ?? '', baseUrl, runtime);
233
+ if (!resolved) {
234
+ return null;
235
+ }
236
+ return runtime.stripBasePath(stripQueryAndHash(resolved));
237
+ }
238
+
239
+ function stripQueryAndHash(value: string): string {
240
+ return value.split(/[?#]/)[0] ?? value;
241
+ }
242
+
243
+ function waitForStylesheets(links: HTMLLinkElement[], timeoutMs = 2000): Promise<void> {
244
+ if (links.length === 0) {
245
+ return Promise.resolve();
246
+ }
247
+
248
+ return new Promise((resolve) => {
249
+ let remaining = links.length;
250
+ let done = false;
251
+ const finish = () => {
252
+ if (done) {
253
+ return;
254
+ }
255
+ done = true;
256
+ resolve();
257
+ };
258
+
259
+ const timer = window.setTimeout(finish, timeoutMs);
260
+ const handle = () => {
261
+ if (done) {
262
+ return;
263
+ }
264
+ remaining -= 1;
265
+ if (remaining <= 0) {
266
+ window.clearTimeout(timer);
267
+ finish();
268
+ }
269
+ };
270
+
271
+ for (const link of links) {
272
+ if (link.sheet) {
273
+ handle();
274
+ continue;
275
+ }
276
+ link.addEventListener('load', handle, { once: true });
277
+ link.addEventListener('error', handle, { once: true });
278
+ }
279
+ });
280
+ }
281
+
282
+ function syncCriticalStyles(head: HTMLHeadElement, newHead: HTMLHeadElement): void {
283
+ for (const style of Array.from(head.querySelectorAll<HTMLStyleElement>('style[data-critical]'))) {
284
+ style.remove();
285
+ }
286
+
287
+ for (const style of Array.from(newHead.querySelectorAll<HTMLStyleElement>('style[data-critical]'))) {
288
+ const next = document.createElement('style');
289
+ for (const attribute of Array.from(style.attributes)) {
290
+ next.setAttribute(attribute.name, attribute.value);
291
+ }
292
+ if (style.textContent) {
293
+ next.textContent = style.textContent;
294
+ }
295
+ head.appendChild(next);
296
+ }
297
+ }
298
+
299
+ function splitPathSuffix(value: string): [string, string] {
300
+ const [path, suffix = ''] = value.split(/(?=[?#])/);
301
+ return [path ?? '', suffix ?? ''];
302
+ }
303
+
304
+ function isAppStylesheetHref(href: string | null, stripBasePath: (value: string) => string): boolean {
305
+ if (!href) {
306
+ return false;
307
+ }
308
+
309
+ try {
310
+ const normalized = stripBasePath(new URL(href, window.location.origin).pathname);
311
+ return normalized === '/app/app.css';
312
+ } catch {
313
+ const trimmed = href.trim();
314
+ if (!trimmed) {
315
+ return false;
316
+ }
317
+ const [path] = trimmed.split(/[?#]/);
318
+ return stripBasePath(path) === '/app/app.css';
319
+ }
320
+ }
321
+
322
+ function isHtmlDocumentContentType(value: string | null): boolean {
323
+ if (!value) {
324
+ return false;
325
+ }
326
+
327
+ const normalized = value.toLowerCase();
328
+ return normalized.includes('text/html') || normalized.includes('application/xhtml+xml');
329
+ }
330
+
331
+ function getPageNameFromUrl(url: string, stripBasePath: (value: string) => string): string {
332
+ try {
333
+ const pathname = stripBasePath(new URL(url, window.location.href).pathname);
334
+ const trimmed = pathname.replace(/^\/+|\/+$/g, '');
335
+ if (!trimmed) {
336
+ return 'home';
337
+ }
338
+
339
+ const firstSegment = trimmed.split('/')[0];
340
+ return firstSegment || 'home';
341
+ } catch {
342
+ return 'home';
343
+ }
344
+ }
@@ -0,0 +1,275 @@
1
+ export type FragmentUpdateMode = 'replace' | 'append' | 'prepend';
2
+
3
+ export interface FragmentResponseMetadata {
4
+ readonly target: string;
5
+ readonly selector?: string;
6
+ readonly mode: FragmentUpdateMode;
7
+ }
8
+
9
+ export type FragmentResponseMetadataIssue = 'target' | 'selector' | 'mode';
10
+
11
+ export type FragmentResponseMetadataResolution =
12
+ | { readonly kind: 'none' }
13
+ | { readonly kind: 'invalid'; readonly issues: readonly FragmentResponseMetadataIssue[] }
14
+ | { readonly kind: 'fragment'; readonly fragment: FragmentResponseMetadata };
15
+
16
+ export interface FragmentRootCandidate {
17
+ readonly id?: string | null;
18
+ readonly fragmentTarget?: string | null;
19
+ readonly matchesSelector?: boolean;
20
+ }
21
+
22
+ export type FragmentInsertionBehavior =
23
+ | 'replace-target'
24
+ | 'replace-children'
25
+ | 'append-payload'
26
+ | 'append-matching-root-children'
27
+ | 'prepend-payload'
28
+ | 'prepend-matching-root-children';
29
+
30
+ export interface EnhancedFormRequest {
31
+ readonly url: string;
32
+ readonly init: RequestInit;
33
+ }
34
+
35
+ export type EnhancedFormResponseResolution =
36
+ | { readonly kind: 'fragment'; readonly fragment: FragmentResponseMetadata }
37
+ | { readonly kind: 'document' }
38
+ | {
39
+ readonly kind: 'navigate';
40
+ readonly location: string;
41
+ readonly reason: 'redirect' | 'missing-target' | 'invalid-fragment' | 'non-html';
42
+ };
43
+
44
+ const CLIENT_NAV_HEADER = 'X-Webstir-Client-Nav';
45
+ const DEFAULT_FORM_ENCODING = 'application/x-www-form-urlencoded';
46
+
47
+ export function normalizeFormMethod(value: string | null | undefined): string {
48
+ const normalized = String(value ?? 'GET').trim().toUpperCase();
49
+ return normalized || 'GET';
50
+ }
51
+
52
+ export function normalizeFormEnctype(value: string | null | undefined): string {
53
+ const normalized = String(value ?? DEFAULT_FORM_ENCODING).trim().toLowerCase();
54
+ return normalized || DEFAULT_FORM_ENCODING;
55
+ }
56
+
57
+ export function buildEnhancedFormRequest(options: {
58
+ readonly action: string;
59
+ readonly method: string;
60
+ readonly enctype?: string | null;
61
+ readonly formData: FormData;
62
+ }): EnhancedFormRequest | null {
63
+ const method = normalizeFormMethod(options.method);
64
+ if (method !== 'POST') {
65
+ return null;
66
+ }
67
+
68
+ const enctype = normalizeFormEnctype(options.enctype);
69
+ const headers = new Headers({ [CLIENT_NAV_HEADER]: '1' });
70
+
71
+ if (enctype === DEFAULT_FORM_ENCODING) {
72
+ const body = toUrlEncodedBody(options.formData);
73
+ if (body === null) {
74
+ return null;
75
+ }
76
+ headers.set('content-type', DEFAULT_FORM_ENCODING);
77
+ return {
78
+ url: options.action,
79
+ init: {
80
+ method,
81
+ headers,
82
+ body
83
+ }
84
+ };
85
+ }
86
+
87
+ if (enctype === 'multipart/form-data') {
88
+ return {
89
+ url: options.action,
90
+ init: {
91
+ method,
92
+ headers,
93
+ body: options.formData
94
+ }
95
+ };
96
+ }
97
+
98
+ return null;
99
+ }
100
+
101
+ export function resolveFragmentResponseMetadata(headers: Headers): FragmentResponseMetadataResolution {
102
+ const rawTarget = headers.get('x-webstir-fragment-target');
103
+ const rawSelector = headers.get('x-webstir-fragment-selector');
104
+ const rawMode = headers.get('x-webstir-fragment-mode');
105
+
106
+ if (rawTarget === null && rawSelector === null && rawMode === null) {
107
+ return { kind: 'none' };
108
+ }
109
+
110
+ const issues: FragmentResponseMetadataIssue[] = [];
111
+ const target = rawTarget?.trim() ?? '';
112
+ if (!target) {
113
+ issues.push('target');
114
+ }
115
+
116
+ let selector: string | undefined;
117
+ if (rawSelector !== null) {
118
+ const normalizedSelector = rawSelector.trim();
119
+ if (!normalizedSelector) {
120
+ issues.push('selector');
121
+ } else {
122
+ selector = normalizedSelector;
123
+ }
124
+ }
125
+
126
+ let mode: FragmentUpdateMode = 'replace';
127
+ if (rawMode !== null) {
128
+ const normalizedMode = rawMode.trim().toLowerCase();
129
+ if (normalizedMode === 'replace' || normalizedMode === 'append' || normalizedMode === 'prepend') {
130
+ mode = normalizedMode;
131
+ } else {
132
+ issues.push('mode');
133
+ }
134
+ }
135
+
136
+ if (issues.length > 0) {
137
+ return {
138
+ kind: 'invalid',
139
+ issues
140
+ };
141
+ }
142
+
143
+ return {
144
+ kind: 'fragment',
145
+ fragment: {
146
+ target,
147
+ selector,
148
+ mode
149
+ }
150
+ };
151
+ }
152
+
153
+ export function readFragmentResponseMetadata(headers: Headers): FragmentResponseMetadata | null {
154
+ const resolution = resolveFragmentResponseMetadata(headers);
155
+ return resolution.kind === 'fragment' ? resolution.fragment : null;
156
+ }
157
+
158
+ export function resolveEnhancedFormResponse(options: {
159
+ readonly metadata: FragmentResponseMetadataResolution;
160
+ readonly hasFragmentTarget: boolean;
161
+ readonly contentType: string | null;
162
+ readonly redirected: boolean;
163
+ readonly responseUrl?: string | null;
164
+ readonly requestUrl: string;
165
+ }): EnhancedFormResponseResolution {
166
+ if (options.metadata.kind === 'fragment') {
167
+ if (options.hasFragmentTarget) {
168
+ return {
169
+ kind: 'fragment',
170
+ fragment: options.metadata.fragment
171
+ };
172
+ }
173
+
174
+ if (isHtmlDocumentContentType(options.contentType)) {
175
+ return { kind: 'document' };
176
+ }
177
+
178
+ return {
179
+ kind: 'navigate',
180
+ location: options.responseUrl || options.requestUrl,
181
+ reason: 'missing-target'
182
+ };
183
+ }
184
+
185
+ if (options.metadata.kind === 'invalid') {
186
+ if (isHtmlDocumentContentType(options.contentType)) {
187
+ return { kind: 'document' };
188
+ }
189
+
190
+ return {
191
+ kind: 'navigate',
192
+ location: options.responseUrl || options.requestUrl,
193
+ reason: 'invalid-fragment'
194
+ };
195
+ }
196
+
197
+ if (isHtmlDocumentContentType(options.contentType)) {
198
+ return { kind: 'document' };
199
+ }
200
+
201
+ if (options.redirected && options.responseUrl) {
202
+ return {
203
+ kind: 'navigate',
204
+ location: options.responseUrl,
205
+ reason: 'redirect'
206
+ };
207
+ }
208
+
209
+ return {
210
+ kind: 'navigate',
211
+ location: options.responseUrl || options.requestUrl,
212
+ reason: 'non-html'
213
+ };
214
+ }
215
+
216
+ export function isHtmlDocumentContentType(value: string | null): boolean {
217
+ if (!value) {
218
+ return false;
219
+ }
220
+
221
+ const normalized = value.toLowerCase();
222
+ return normalized.includes('text/html') || normalized.includes('application/xhtml+xml');
223
+ }
224
+
225
+ export function shouldReplaceFragmentTarget(options: {
226
+ readonly mode: FragmentUpdateMode;
227
+ readonly target: string;
228
+ readonly roots: readonly FragmentRootCandidate[];
229
+ }): boolean {
230
+ return resolveFragmentInsertionBehavior(options) === 'replace-target';
231
+ }
232
+
233
+ export function resolveFragmentInsertionBehavior(options: {
234
+ readonly mode: FragmentUpdateMode;
235
+ readonly target: string;
236
+ readonly roots: readonly FragmentRootCandidate[];
237
+ readonly hasMeaningfulSiblingContent?: boolean;
238
+ }): FragmentInsertionBehavior {
239
+ const hasMatchingRoot = options.roots.length === 1
240
+ && options.hasMeaningfulSiblingContent !== true
241
+ && rootMatchesFragmentTarget(options.roots[0], options.target);
242
+
243
+ if (options.mode === 'replace') {
244
+ return hasMatchingRoot ? 'replace-target' : 'replace-children';
245
+ }
246
+
247
+ if (options.mode === 'append') {
248
+ return hasMatchingRoot ? 'append-matching-root-children' : 'append-payload';
249
+ }
250
+
251
+ return hasMatchingRoot ? 'prepend-matching-root-children' : 'prepend-payload';
252
+ }
253
+
254
+ function toUrlEncodedBody(formData: FormData): URLSearchParams | null {
255
+ const params = new URLSearchParams();
256
+ let hasBinaryValue = false;
257
+ formData.forEach((value, key) => {
258
+ if (typeof value !== 'string') {
259
+ hasBinaryValue = true;
260
+ return;
261
+ }
262
+ params.append(key, value);
263
+ });
264
+ return hasBinaryValue ? null : params;
265
+ }
266
+
267
+ function matchesFragmentTarget(value: string | null | undefined, target: string): boolean {
268
+ return typeof value === 'string' && value.trim() === target;
269
+ }
270
+
271
+ function rootMatchesFragmentTarget(root: FragmentRootCandidate, target: string): boolean {
272
+ return root.matchesSelector === true
273
+ || matchesFragmentTarget(root.id, target)
274
+ || matchesFragmentTarget(root.fragmentTarget, target);
275
+ }
@@ -6,6 +6,7 @@
6
6
  gap: 2rem;
7
7
  align-items: center;
8
8
  text-align: center;
9
+ padding: 4rem 1.5rem;
9
10
  }
10
11
 
11
12
  .hero h1 {
@@ -19,3 +20,10 @@
19
20
  margin: 0 auto;
20
21
  max-width: 42ch;
21
22
  }
23
+
24
+ .eyebrow {
25
+ font-size: 0.875rem;
26
+ font-weight: 700;
27
+ letter-spacing: 0.16em;
28
+ text-transform: uppercase;
29
+ }
@@ -5,6 +5,11 @@
5
5
  </head>
6
6
  <body>
7
7
  <main>
8
- Home
8
+ <section class="hero">
9
+ <p class="eyebrow">Webstir full demo</p>
10
+ <h1>Home</h1>
11
+ <p>Open the canonical progressive-enhancement route to compare full-page redirects with fragment updates driven by the same HTML form.</p>
12
+ <p><a href="/api/demo/progressive-enhancement">Open the form flow demo</a></p>
13
+ </section>
9
14
  </main>
10
15
  </body>
@@ -14,8 +14,18 @@ test('home page has expected parts', () => {
14
14
  const html = readFileSync(htmlPath, 'utf8');
15
15
 
16
16
  assert.isTrue(html.includes('<title>Home</title>'), 'Missing <title>Home</title>');
17
- assert.isTrue(html.includes('<link rel="stylesheet" href="index.css"'), 'Missing CSS link to index.css');
18
- assert.isTrue(html.includes('<script type="module" src="index.js"'), 'Missing module script to index.js');
17
+ assert.isTrue(
18
+ html.includes('<link rel="stylesheet" href="index.css"') || html.includes('<link rel="stylesheet" href="/pages/home/index.css"'),
19
+ 'Missing CSS link to index.css'
20
+ );
21
+ assert.isTrue(
22
+ html.includes('<script type="module" src="index.js"') || html.includes('<script type="module" src="/pages/home/index.js"'),
23
+ 'Missing module script to index.js'
24
+ );
19
25
  assert.isTrue(html.includes('<main'), 'Missing <main> container');
20
26
  assert.isTrue(html.includes('Home'), 'Missing Home content');
27
+ assert.isTrue(
28
+ html.includes('href="/api/demo/progressive-enhancement"'),
29
+ 'Missing link to the progressive enhancement demo route'
30
+ );
21
31
  });
@@ -14,8 +14,16 @@ test('home page has expected parts', () => {
14
14
  const html = readFileSync(htmlPath, 'utf8');
15
15
 
16
16
  assert.isTrue(html.includes('<title>Home</title>'), 'Missing <title>Home</title>');
17
- assert.isTrue(html.includes('<link rel="stylesheet" href="index.css"'), 'Missing CSS link to index.css');
18
- assert.isTrue(html.includes('<script type="module" src="index.js"'), 'Missing module script to index.js');
17
+ assert.isTrue(
18
+ html.includes('<link rel="stylesheet" href="index.css"') ||
19
+ html.includes('<link rel="stylesheet" href="/pages/home/index.css"'),
20
+ 'Missing CSS link to index.css',
21
+ );
22
+ assert.isTrue(
23
+ html.includes('<script type="module" src="index.js"') ||
24
+ html.includes('<script type="module" src="/pages/home/index.js"'),
25
+ 'Missing module script to index.js',
26
+ );
19
27
  assert.isTrue(html.includes('<main'), 'Missing <main> container');
20
28
  assert.isTrue(html.includes('Home'), 'Missing Home content');
21
29
  });