@hyperfixi/server-bridge 2.0.0

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/LICENSE +20 -0
  2. package/dist/cli/config.d.ts +5 -0
  3. package/dist/cli/diff-command.d.ts +9 -0
  4. package/dist/cli/generate-command.d.ts +12 -0
  5. package/dist/cli/index.d.ts +2 -0
  6. package/dist/cli/scan-command.d.ts +9 -0
  7. package/dist/cli/watch-command.d.ts +30 -0
  8. package/dist/cli.cjs +1853 -0
  9. package/dist/cli.cjs.map +1 -0
  10. package/dist/conventions/convention-engine.d.ts +29 -0
  11. package/dist/conventions/url-patterns.d.ts +37 -0
  12. package/dist/generate.d.ts +21 -0
  13. package/dist/generators/django-generator.d.ts +10 -0
  14. package/dist/generators/express-generator.d.ts +9 -0
  15. package/dist/generators/fastapi-generator.d.ts +10 -0
  16. package/dist/generators/hono-generator.d.ts +9 -0
  17. package/dist/generators/index.d.ts +6 -0
  18. package/dist/generators/manifest.d.ts +43 -0
  19. package/dist/generators/openapi-generator.d.ts +14 -0
  20. package/dist/generators/template-helpers.d.ts +24 -0
  21. package/dist/generators/types.d.ts +11 -0
  22. package/dist/index.cjs +1426 -0
  23. package/dist/index.cjs.map +1 -0
  24. package/dist/index.d.ts +19 -0
  25. package/dist/index.js +1376 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/scanner/conflict-detector.d.ts +10 -0
  28. package/dist/scanner/form-scanner.d.ts +12 -0
  29. package/dist/scanner/htmx-extractor.d.ts +9 -0
  30. package/dist/scanner/hyperscript-extractor.d.ts +9 -0
  31. package/dist/scanner/route-scanner.d.ts +21 -0
  32. package/dist/types.d.ts +123 -0
  33. package/package.json +64 -0
  34. package/src/__tests__/cli.test.ts +30 -0
  35. package/src/__tests__/conflict-detector.test.ts +126 -0
  36. package/src/__tests__/convention-engine.test.ts +58 -0
  37. package/src/__tests__/diff-command.test.ts +69 -0
  38. package/src/__tests__/django-generator.test.ts +129 -0
  39. package/src/__tests__/express-generator.test.ts +105 -0
  40. package/src/__tests__/fastapi-generator.test.ts +125 -0
  41. package/src/__tests__/form-scanner.test.ts +91 -0
  42. package/src/__tests__/generate.test.ts +89 -0
  43. package/src/__tests__/hono-generator.test.ts +90 -0
  44. package/src/__tests__/htmx-extractor.test.ts +84 -0
  45. package/src/__tests__/hyperscript-extractor.test.ts +89 -0
  46. package/src/__tests__/integration.test.ts +102 -0
  47. package/src/__tests__/manifest.test.ts +133 -0
  48. package/src/__tests__/openapi-generator.test.ts +116 -0
  49. package/src/__tests__/query-params.test.ts +101 -0
  50. package/src/__tests__/route-scanner.test.ts +47 -0
  51. package/src/__tests__/url-patterns.test.ts +74 -0
  52. package/src/__tests__/watch-command.test.ts +141 -0
  53. package/src/cli/config.ts +35 -0
  54. package/src/cli/diff-command.ts +96 -0
  55. package/src/cli/generate-command.ts +108 -0
  56. package/src/cli/index.ts +143 -0
  57. package/src/cli/scan-command.ts +72 -0
  58. package/src/cli/watch-command.ts +242 -0
  59. package/src/conventions/convention-engine.ts +68 -0
  60. package/src/conventions/url-patterns.ts +123 -0
  61. package/src/generate.ts +44 -0
  62. package/src/generators/django-generator.ts +210 -0
  63. package/src/generators/express-generator.ts +137 -0
  64. package/src/generators/fastapi-generator.ts +248 -0
  65. package/src/generators/hono-generator.ts +137 -0
  66. package/src/generators/index.ts +25 -0
  67. package/src/generators/manifest.ts +164 -0
  68. package/src/generators/openapi-generator.ts +259 -0
  69. package/src/generators/template-helpers.ts +77 -0
  70. package/src/generators/types.ts +12 -0
  71. package/src/index.ts +42 -0
  72. package/src/scanner/conflict-detector.ts +92 -0
  73. package/src/scanner/form-scanner.ts +126 -0
  74. package/src/scanner/htmx-extractor.ts +174 -0
  75. package/src/scanner/hyperscript-extractor.ts +184 -0
  76. package/src/scanner/route-scanner.ts +130 -0
  77. package/src/types.ts +151 -0
@@ -0,0 +1,259 @@
1
+ import type { RouteDescriptor, GeneratorOptions, GenerateResult, GeneratedFile } from '../types.js';
2
+ import type { RouteGenerator } from './types.js';
3
+
4
+ export class OpenAPIGenerator implements RouteGenerator {
5
+ readonly framework = 'openapi';
6
+
7
+ generate(routes: RouteDescriptor[], options: GeneratorOptions): GenerateResult {
8
+ const files: GeneratedFile[] = [];
9
+ const warnings: string[] = [];
10
+
11
+ if (routes.length === 0) {
12
+ warnings.push('No routes to generate');
13
+ return { files, preserved: [], warnings };
14
+ }
15
+
16
+ const spec = this.buildSpec(routes);
17
+ const yaml = this.toYaml(spec);
18
+
19
+ files.push({
20
+ path: 'openapi.yaml',
21
+ content: yaml,
22
+ isNew: true,
23
+ });
24
+
25
+ return { files, preserved: [], warnings };
26
+ }
27
+
28
+ private buildSpec(routes: RouteDescriptor[]): OpenAPISpec {
29
+ const paths: Record<string, Record<string, OpenAPIOperation>> = {};
30
+
31
+ for (const route of routes) {
32
+ // Convert :param to {param} for OpenAPI
33
+ const oaPath = route.path.replace(/:(\w+)/g, '{$1}');
34
+ const method = route.method.toLowerCase();
35
+
36
+ if (!paths[oaPath]) {
37
+ paths[oaPath] = {};
38
+ }
39
+
40
+ paths[oaPath][method] = this.buildOperation(route);
41
+ }
42
+
43
+ return {
44
+ openapi: '3.1.0',
45
+ info: {
46
+ title: 'ServerBridge Generated API',
47
+ version: '0.1.0',
48
+ description: 'Auto-extracted from hyperscript/htmx attributes',
49
+ },
50
+ paths,
51
+ };
52
+ }
53
+
54
+ private buildOperation(route: RouteDescriptor): OpenAPIOperation {
55
+ const op: OpenAPIOperation = {
56
+ operationId: route.handlerName,
57
+ summary: `Source: ${route.source.file}${route.source.line ? ':' + route.source.line : ''} (${route.source.kind})`,
58
+ responses: {
59
+ '200': this.buildResponse(route),
60
+ },
61
+ };
62
+
63
+ // Path parameters
64
+ if (route.pathParams.length > 0) {
65
+ op.parameters = route.pathParams.map(name => ({
66
+ name,
67
+ in: 'path' as const,
68
+ required: true,
69
+ schema: { type: inferParamType(name) },
70
+ }));
71
+ }
72
+
73
+ // Query parameters
74
+ if (route.queryParams && route.queryParams.length > 0) {
75
+ const queryParamSpecs = route.queryParams.map(param => ({
76
+ name: param.name,
77
+ in: 'query' as const,
78
+ required: param.required,
79
+ schema: { type: 'string' as const },
80
+ }));
81
+ if (op.parameters) {
82
+ op.parameters.push(...queryParamSpecs);
83
+ } else {
84
+ op.parameters = queryParamSpecs;
85
+ }
86
+ }
87
+
88
+ // Request body
89
+ if (
90
+ route.requestBody &&
91
+ route.requestBody.length > 0 &&
92
+ ['POST', 'PUT', 'PATCH'].includes(route.method)
93
+ ) {
94
+ const required = route.requestBody.filter(f => f.required).map(f => f.name);
95
+ const properties: Record<string, { type: string; format?: string }> = {};
96
+
97
+ for (const field of route.requestBody) {
98
+ properties[field.name] = fieldToSchema(field.type);
99
+ }
100
+
101
+ op.requestBody = {
102
+ required: required.length > 0,
103
+ content: {
104
+ 'application/json': {
105
+ schema: {
106
+ type: 'object',
107
+ ...(required.length > 0 ? { required } : {}),
108
+ properties,
109
+ },
110
+ },
111
+ },
112
+ };
113
+ }
114
+
115
+ return op;
116
+ }
117
+
118
+ private buildResponse(route: RouteDescriptor): OpenAPIResponse {
119
+ const contentType =
120
+ route.responseFormat === 'json'
121
+ ? 'application/json'
122
+ : route.responseFormat === 'html'
123
+ ? 'text/html'
124
+ : 'text/plain';
125
+
126
+ return {
127
+ description: 'Success',
128
+ content: {
129
+ [contentType]: {
130
+ schema: route.responseFormat === 'json' ? { type: 'object' } : { type: 'string' },
131
+ },
132
+ },
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Minimal YAML serializer — avoids adding a yaml dependency.
138
+ * Handles the specific nested structure of OpenAPI specs.
139
+ */
140
+ toYaml(obj: unknown, indent: number = 0): string {
141
+ const pad = ' '.repeat(indent);
142
+
143
+ if (obj === null || obj === undefined) {
144
+ return 'null';
145
+ }
146
+
147
+ if (typeof obj === 'string') {
148
+ // Quote strings that could be ambiguous
149
+ if (
150
+ obj === '' ||
151
+ obj.includes(':') ||
152
+ obj.includes('#') ||
153
+ obj.includes("'") ||
154
+ /^\d/.test(obj) ||
155
+ obj === 'true' ||
156
+ obj === 'false'
157
+ ) {
158
+ return `'${obj.replace(/'/g, "''")}'`;
159
+ }
160
+ return obj;
161
+ }
162
+
163
+ if (typeof obj === 'number' || typeof obj === 'boolean') {
164
+ return String(obj);
165
+ }
166
+
167
+ if (Array.isArray(obj)) {
168
+ if (obj.length === 0) return '[]';
169
+
170
+ // For simple string/number arrays, use inline format
171
+ if (obj.every(item => typeof item === 'string' || typeof item === 'number')) {
172
+ return `[${obj.map(item => (typeof item === 'string' ? `'${item}'` : String(item))).join(', ')}]`;
173
+ }
174
+
175
+ return obj
176
+ .map(item => {
177
+ if (typeof item === 'object' && item !== null) {
178
+ const inner = this.toYaml(item, indent + 1).trimStart();
179
+ return `${pad}- ${inner}`;
180
+ }
181
+ return `${pad}- ${this.toYaml(item)}`;
182
+ })
183
+ .join('\n');
184
+ }
185
+
186
+ if (typeof obj === 'object') {
187
+ const entries = Object.entries(obj as Record<string, unknown>);
188
+ if (entries.length === 0) return '{}';
189
+
190
+ return entries
191
+ .map(([key, value]) => {
192
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
193
+ const inner = this.toYaml(value, indent + 1);
194
+ return `${pad}${key}:\n${inner}`;
195
+ }
196
+ if (Array.isArray(value) && value.length > 0 && value.some(v => typeof v === 'object')) {
197
+ const inner = this.toYaml(value, indent + 1);
198
+ return `${pad}${key}:\n${inner}`;
199
+ }
200
+ return `${pad}${key}: ${this.toYaml(value, indent + 1)}`;
201
+ })
202
+ .join('\n');
203
+ }
204
+
205
+ return String(obj);
206
+ }
207
+ }
208
+
209
+ // --- Helper types (not exported) ---
210
+
211
+ interface OpenAPISpec {
212
+ openapi: string;
213
+ info: { title: string; version: string; description: string };
214
+ paths: Record<string, Record<string, OpenAPIOperation>>;
215
+ }
216
+
217
+ interface OpenAPIOperation {
218
+ operationId: string;
219
+ summary: string;
220
+ parameters?: OpenAPIParameter[];
221
+ requestBody?: OpenAPIRequestBody;
222
+ responses: Record<string, OpenAPIResponse>;
223
+ }
224
+
225
+ interface OpenAPIParameter {
226
+ name: string;
227
+ in: 'path' | 'query';
228
+ required: boolean;
229
+ schema: { type: string };
230
+ }
231
+
232
+ interface OpenAPIRequestBody {
233
+ required: boolean;
234
+ content: Record<string, { schema: Record<string, unknown> }>;
235
+ }
236
+
237
+ interface OpenAPIResponse {
238
+ description: string;
239
+ content: Record<string, { schema: { type: string } }>;
240
+ }
241
+
242
+ function inferParamType(name: string): string {
243
+ if (name === 'id' || name.endsWith('Id')) return 'string';
244
+ if (name === 'page' || name === 'limit' || name === 'offset') return 'integer';
245
+ return 'string';
246
+ }
247
+
248
+ function fieldToSchema(type: string): { type: string; format?: string } {
249
+ switch (type) {
250
+ case 'number':
251
+ return { type: 'number' };
252
+ case 'boolean':
253
+ return { type: 'boolean' };
254
+ case 'file':
255
+ return { type: 'string', format: 'binary' };
256
+ default:
257
+ return { type: 'string' };
258
+ }
259
+ }
@@ -0,0 +1,77 @@
1
+ import type { RouteDescriptor } from '../types.js';
2
+
3
+ /** Marker comment indicating a serverbridge-generated route */
4
+ export const ROUTE_MARKER = '// @serverbridge-route:';
5
+ /** Marker for start of user-editable code */
6
+ export const USER_START = '// @serverbridge-user-start';
7
+ /** Marker for end of user-editable code */
8
+ export const USER_END = '// @serverbridge-user-end';
9
+
10
+ /**
11
+ * Generate a route comment header.
12
+ */
13
+ export function routeMarker(route: RouteDescriptor): string {
14
+ return `${ROUTE_MARKER} ${route.method} ${route.path}`;
15
+ }
16
+
17
+ /**
18
+ * Generate a TODO comment block for an unimplemented route.
19
+ */
20
+ export function todoBlock(route: RouteDescriptor, indent: string = ' '): string {
21
+ const lines: string[] = [];
22
+ lines.push(`${indent}// TODO: Implement ${route.method} ${route.path}`);
23
+
24
+ if (route.pathParams.length > 0) {
25
+ lines.push(`${indent}// Path params: ${route.pathParams.join(', ')}`);
26
+ }
27
+
28
+ if (route.queryParams && route.queryParams.length > 0) {
29
+ lines.push(`${indent}// Query params: ${route.queryParams.map(p => p.name).join(', ')}`);
30
+ }
31
+
32
+ if (route.requestBody && route.requestBody.length > 0) {
33
+ const fields = route.requestBody
34
+ .map(f => `${f.name}: ${f.type}${f.required ? '' : '?'}`)
35
+ .join(', ');
36
+ lines.push(`${indent}// Expected body: { ${fields} }`);
37
+ }
38
+
39
+ if (route.notes.length > 0) {
40
+ for (const note of route.notes) {
41
+ lines.push(`${indent}// Note: ${note}`);
42
+ }
43
+ }
44
+
45
+ return lines.join('\n');
46
+ }
47
+
48
+ /**
49
+ * Group routes by common path prefix for organizing into files.
50
+ * E.g., /api/users and /api/users/:id group under "api-users".
51
+ */
52
+ export function groupRoutesByPrefix(routes: RouteDescriptor[]): Map<string, RouteDescriptor[]> {
53
+ const groups = new Map<string, RouteDescriptor[]>();
54
+
55
+ for (const route of routes) {
56
+ // Take first two segments as the group key
57
+ const segments = route.path.replace(/^\//, '').split('/');
58
+ const groupKey =
59
+ segments
60
+ .slice(0, Math.min(2, segments.length))
61
+ .filter(s => !s.startsWith(':'))
62
+ .join('-') || 'root';
63
+
64
+ const group = groups.get(groupKey) ?? [];
65
+ group.push(route);
66
+ groups.set(groupKey, group);
67
+ }
68
+
69
+ return groups;
70
+ }
71
+
72
+ /**
73
+ * Generate a safe filename from a group key.
74
+ */
75
+ export function groupToFilename(groupKey: string, typescript: boolean): string {
76
+ return `${groupKey}.${typescript ? 'ts' : 'js'}`;
77
+ }
@@ -0,0 +1,12 @@
1
+ import type { RouteDescriptor, GeneratorOptions, GenerateResult } from '../types.js';
2
+
3
+ /**
4
+ * A route generator renders RouteDescriptor[] into framework-specific route files.
5
+ * Follows the same pattern as TestRenderer in compilation-service.
6
+ */
7
+ export interface RouteGenerator {
8
+ /** Target framework name */
9
+ readonly framework: string;
10
+ /** Generate route files */
11
+ generate(routes: RouteDescriptor[], options: GeneratorOptions): GenerateResult;
12
+ }
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ // Public API
2
+ export type {
3
+ HttpMethod,
4
+ RouteDescriptor,
5
+ RouteSource,
6
+ RequestBodyField,
7
+ QueryParam,
8
+ ScanResult,
9
+ ScanError,
10
+ ConflictWarning,
11
+ ConflictDetail,
12
+ GeneratorOptions,
13
+ GeneratedFile,
14
+ GenerateResult,
15
+ ServerBridgeConfig,
16
+ } from './types.js';
17
+
18
+ export {
19
+ extractPathParams,
20
+ extractQueryParams,
21
+ normalizeUrl,
22
+ urlToHandlerName,
23
+ } from './conventions/url-patterns.js';
24
+ export { inferConventions } from './conventions/convention-engine.js';
25
+ export type { ConventionContext, ConventionResult } from './conventions/convention-engine.js';
26
+ export { extractHyperscriptRoutes } from './scanner/hyperscript-extractor.js';
27
+ export { extractHtmxRoutes } from './scanner/htmx-extractor.js';
28
+ export { extractFormFields, extractFormBodies } from './scanner/form-scanner.js';
29
+ export { scanRoutes, scanDirectory } from './scanner/route-scanner.js';
30
+ export { ExpressGenerator } from './generators/express-generator.js';
31
+ export { HonoGenerator } from './generators/hono-generator.js';
32
+ export { OpenAPIGenerator } from './generators/openapi-generator.js';
33
+ export { DjangoGenerator } from './generators/django-generator.js';
34
+ export { FastAPIGenerator } from './generators/fastapi-generator.js';
35
+ export type { RouteGenerator } from './generators/types.js';
36
+ export { selectGenerator } from './generators/index.js';
37
+ export { createManifest, loadManifest, saveManifest, diffRoutes } from './generators/manifest.js';
38
+ export { detectConflicts, formatConflicts } from './scanner/conflict-detector.js';
39
+
40
+ // Convenience generate function for programmatic use (e.g., Vite plugin)
41
+ export { generate } from './generate.js';
42
+ export type { ProgrammaticGenerateOptions, GenerateFullResult } from './generate.js';
@@ -0,0 +1,92 @@
1
+ import type { RouteDescriptor, ConflictWarning, ConflictDetail } from '../types.js';
2
+
3
+ /**
4
+ * Detect conflicts between multiple declarations of the same route.
5
+ * Must be called on the pre-deduplicated route list (before first-wins dedup).
6
+ */
7
+ export function detectConflicts(routes: RouteDescriptor[]): ConflictWarning[] {
8
+ const warnings: ConflictWarning[] = [];
9
+
10
+ // Group routes by method:path
11
+ const groups = new Map<string, RouteDescriptor[]>();
12
+ for (const route of routes) {
13
+ const key = `${route.method}:${route.path}`;
14
+ const existing = groups.get(key) ?? [];
15
+ existing.push(route);
16
+ groups.set(key, existing);
17
+ }
18
+
19
+ for (const [key, groupRoutes] of groups) {
20
+ if (groupRoutes.length < 2) continue;
21
+
22
+ const conflicts: ConflictDetail[] = [];
23
+
24
+ // Check responseFormat
25
+ const formats = [...new Set(groupRoutes.map(r => r.responseFormat))];
26
+ if (formats.length > 1) {
27
+ conflicts.push({
28
+ field: 'responseFormat',
29
+ values: formats,
30
+ message: `Conflicting response formats: ${formats.join(' vs ')}`,
31
+ });
32
+ }
33
+
34
+ // Check requestBody field names (for POST/PUT/PATCH)
35
+ if (['POST', 'PUT', 'PATCH'].includes(groupRoutes[0].method)) {
36
+ const bodySignatures = groupRoutes.map(r =>
37
+ JSON.stringify((r.requestBody ?? []).map(f => f.name).sort())
38
+ );
39
+ const uniqueSignatures = [...new Set(bodySignatures)];
40
+ if (uniqueSignatures.length > 1) {
41
+ conflicts.push({
42
+ field: 'requestBody',
43
+ values: uniqueSignatures,
44
+ message: 'Request body fields differ across declarations',
45
+ });
46
+ }
47
+ }
48
+
49
+ // Check queryParams
50
+ const querySignatures = groupRoutes.map(r =>
51
+ JSON.stringify((r.queryParams ?? []).map(p => p.name).sort())
52
+ );
53
+ const uniqueQuerySigs = [...new Set(querySignatures)];
54
+ if (uniqueQuerySigs.length > 1) {
55
+ conflicts.push({
56
+ field: 'queryParams',
57
+ values: uniqueQuerySigs,
58
+ message: 'Query parameters differ across declarations',
59
+ });
60
+ }
61
+
62
+ if (conflicts.length > 0) {
63
+ warnings.push({
64
+ routeKey: key,
65
+ conflicts,
66
+ sources: groupRoutes.map(r => r.source),
67
+ });
68
+ }
69
+ }
70
+
71
+ return warnings;
72
+ }
73
+
74
+ /**
75
+ * Format conflict warnings for human-readable CLI output.
76
+ */
77
+ export function formatConflicts(warnings: ConflictWarning[]): string {
78
+ const lines: string[] = [];
79
+
80
+ for (const warning of warnings) {
81
+ lines.push(` ${warning.routeKey}:`);
82
+ for (const conflict of warning.conflicts) {
83
+ lines.push(` - ${conflict.message}`);
84
+ }
85
+ lines.push(` Sources:`);
86
+ for (const source of warning.sources) {
87
+ lines.push(` ${source.file}:${source.line ?? '?'} (${source.kind})`);
88
+ }
89
+ }
90
+
91
+ return lines.join('\n');
92
+ }
@@ -0,0 +1,126 @@
1
+ import type { RequestBodyField } from '../types.js';
2
+
3
+ /**
4
+ * Regex to match <form> blocks and capture inner content.
5
+ * Uses non-greedy matching to handle multiple forms.
6
+ */
7
+ const FORM_BLOCK = /<form\b[^>]*>([\s\S]*?)<\/form>/gi;
8
+
9
+ /**
10
+ * Regex to match input-like elements with a name attribute.
11
+ * Captures: tag name, full attributes string.
12
+ */
13
+ const NAMED_FIELD = /<(input|select|textarea)\b([^>]*)\bname\s*=\s*["']([^"']+)["']([^>]*)>/gi;
14
+
15
+ /** Regex for type attribute */
16
+ const TYPE_ATTR = /\btype\s*=\s*["']([^"']+)["']/i;
17
+
18
+ /** Regex for required attribute */
19
+ const REQUIRED_ATTR = /\brequired\b/i;
20
+
21
+ /** Map HTML input types to our field types */
22
+ const TYPE_MAP: Record<string, RequestBodyField['type']> = {
23
+ text: 'string',
24
+ email: 'string',
25
+ password: 'string',
26
+ tel: 'string',
27
+ url: 'string',
28
+ search: 'string',
29
+ hidden: 'string',
30
+ color: 'string',
31
+ date: 'string',
32
+ 'datetime-local': 'string',
33
+ month: 'string',
34
+ week: 'string',
35
+ time: 'string',
36
+ number: 'number',
37
+ range: 'number',
38
+ checkbox: 'boolean',
39
+ file: 'file',
40
+ };
41
+
42
+ /**
43
+ * Extract form fields from a <form> block's inner HTML.
44
+ */
45
+ export function extractFormFields(formInnerHtml: string): RequestBodyField[] {
46
+ const fields: RequestBodyField[] = [];
47
+ const seen = new Set<string>();
48
+
49
+ NAMED_FIELD.lastIndex = 0;
50
+ let match;
51
+ while ((match = NAMED_FIELD.exec(formInnerHtml)) !== null) {
52
+ const tag = match[1].toLowerCase();
53
+ const attrsBefore = match[2];
54
+ const name = match[3];
55
+ const attrsAfter = match[4];
56
+ const allAttrs = attrsBefore + ' ' + attrsAfter;
57
+
58
+ if (seen.has(name)) continue;
59
+ seen.add(name);
60
+
61
+ let type: RequestBodyField['type'] = 'string';
62
+ if (tag === 'select' || tag === 'textarea') {
63
+ type = 'string';
64
+ } else {
65
+ const typeMatch = allAttrs.match(TYPE_ATTR);
66
+ const inputType = typeMatch ? typeMatch[1].toLowerCase() : 'text';
67
+ type = TYPE_MAP[inputType] ?? 'unknown';
68
+ }
69
+
70
+ const required = REQUIRED_ATTR.test(allAttrs);
71
+
72
+ fields.push({ name, type, required });
73
+ }
74
+
75
+ return fields;
76
+ }
77
+
78
+ /**
79
+ * Find all <form> blocks in HTML content that contain route-implying attributes
80
+ * (hx-post, hx-put, hx-patch, fx-action) and extract their fields.
81
+ *
82
+ * Returns a map from URL path to field list.
83
+ */
84
+ export function extractFormBodies(content: string): Map<string, RequestBodyField[]> {
85
+ const result = new Map<string, RequestBodyField[]>();
86
+
87
+ FORM_BLOCK.lastIndex = 0;
88
+ let match;
89
+ while ((match = FORM_BLOCK.exec(content)) !== null) {
90
+ const formTag = content.slice(
91
+ match.index,
92
+ match.index + content.slice(match.index).indexOf('>') + 1
93
+ );
94
+ const formBody = match[1];
95
+
96
+ // Check for route-implying attributes in the form tag
97
+ const url = extractFormUrl(formTag);
98
+ if (!url) continue;
99
+
100
+ const fields = extractFormFields(formBody);
101
+ if (fields.length > 0) {
102
+ result.set(url, fields);
103
+ }
104
+ }
105
+
106
+ return result;
107
+ }
108
+
109
+ /**
110
+ * Extract URL from a form tag's route-implying attributes.
111
+ */
112
+ function extractFormUrl(formTag: string): string | undefined {
113
+ // hx-post, hx-put, hx-patch
114
+ const hxMatch = formTag.match(/\bhx-(?:post|put|patch)\s*=\s*["']([^"']+)["']/i);
115
+ if (hxMatch) return hxMatch[1];
116
+
117
+ // fx-action
118
+ const fxMatch = formTag.match(/\bfx-action\s*=\s*["']([^"']+)["']/i);
119
+ if (fxMatch) return fxMatch[1];
120
+
121
+ // hyperscript with send form to
122
+ const hsMatch = formTag.match(/\bsend\s+(?:the\s+)?form\s+to\s+['"]?([^\s'"]+)['"]?/i);
123
+ if (hsMatch) return hsMatch[1];
124
+
125
+ return undefined;
126
+ }