@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,108 @@
1
+ import { resolve, join } from 'node:path';
2
+ import { mkdir, writeFile, readFile } from 'node:fs/promises';
3
+ import { scanDirectory } from '../scanner/route-scanner.js';
4
+ import { formatConflicts } from '../scanner/conflict-detector.js';
5
+ import { selectGenerator } from '../generators/index.js';
6
+ import {
7
+ loadManifest,
8
+ saveManifest,
9
+ createManifest,
10
+ diffRoutes,
11
+ extractUserCode,
12
+ } from '../generators/manifest.js';
13
+ import { loadConfig } from './config.js';
14
+ interface GenerateOptions {
15
+ dir: string;
16
+ framework?: 'express' | 'hono' | 'openapi' | 'django' | 'fastapi';
17
+ output?: string;
18
+ typescript?: boolean;
19
+ overwrite?: boolean;
20
+ include?: string[];
21
+ exclude?: string[];
22
+ ignore?: string[];
23
+ }
24
+
25
+ export async function runGenerate(options: GenerateOptions): Promise<void> {
26
+ const cwd = resolve(options.dir);
27
+ const config = await loadConfig(cwd, {
28
+ framework: options.framework,
29
+ output: options.output,
30
+ typescript: options.typescript,
31
+ include: options.include,
32
+ exclude: options.exclude,
33
+ ignore: options.ignore,
34
+ });
35
+
36
+ // Scan for routes
37
+ const result = await scanDirectory(cwd, {
38
+ include: config.include,
39
+ exclude: config.exclude,
40
+ ignore: config.ignore,
41
+ });
42
+
43
+ if (result.errors.length > 0) {
44
+ for (const err of result.errors) {
45
+ console.error(`Warning: ${err.file}: ${err.message}`);
46
+ }
47
+ }
48
+
49
+ if (result.conflicts.length > 0) {
50
+ console.error(
51
+ `\nWarning: ${result.conflicts.length} route conflict(s) detected:\n${formatConflicts(result.conflicts)}\nFirst occurrence used for generation.\n`
52
+ );
53
+ }
54
+
55
+ if (result.routes.length === 0) {
56
+ console.log('No routes found. Nothing to generate.');
57
+ return;
58
+ }
59
+
60
+ // Select generator
61
+ const generator = selectGenerator(config.framework ?? 'express');
62
+
63
+ const outputDir = resolve(cwd, config.output ?? './server/routes');
64
+
65
+ // Load existing manifest for coexistence
66
+ const oldManifest = options.overwrite ? null : await loadManifest(outputDir);
67
+ const diff = diffRoutes(oldManifest, result.routes);
68
+
69
+ // Generate
70
+ const generated = generator.generate(result.routes, {
71
+ outputDir,
72
+ typescript: config.typescript,
73
+ overwrite: options.overwrite,
74
+ });
75
+
76
+ // Write files — create subdirectories as needed
77
+ await mkdir(outputDir, { recursive: true });
78
+
79
+ for (const file of generated.files) {
80
+ const filePath = join(outputDir, file.path);
81
+ const fileDir = join(filePath, '..');
82
+ await mkdir(fileDir, { recursive: true });
83
+ await writeFile(filePath, file.content, 'utf-8');
84
+ }
85
+
86
+ // Save manifest
87
+ const manifest = createManifest(result.routes);
88
+ await saveManifest(outputDir, manifest);
89
+
90
+ // Print summary
91
+ console.log(`Generated ${generated.files.length} file(s) in ${outputDir}/`);
92
+ console.log(` Framework: ${generator.framework}`);
93
+ console.log(` Routes: ${result.routes.length}`);
94
+
95
+ if (diff.added.length > 0) {
96
+ console.log(` New: ${diff.added.length}`);
97
+ }
98
+ if (diff.changed.length > 0) {
99
+ console.log(` Changed: ${diff.changed.length}`);
100
+ }
101
+ if (diff.removed.length > 0) {
102
+ console.log(` Removed: ${diff.removed.length} (routes no longer in source)`);
103
+ }
104
+
105
+ for (const warning of generated.warnings) {
106
+ console.log(` Warning: ${warning}`);
107
+ }
108
+ }
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runScan } from './scan-command.js';
4
+ import { runGenerate } from './generate-command.js';
5
+ import { runDiff } from './diff-command.js';
6
+ import { runWatch } from './watch-command.js';
7
+
8
+ const args = process.argv.slice(2);
9
+ const command = args[0];
10
+
11
+ function parseFlags(args: string[]): Record<string, string | boolean> {
12
+ const flags: Record<string, string | boolean> = {};
13
+ for (let i = 0; i < args.length; i++) {
14
+ const arg = args[i];
15
+ if (arg.startsWith('--')) {
16
+ const [key, ...valueParts] = arg.slice(2).split('=');
17
+ const value = valueParts.join('=');
18
+ if (value) {
19
+ flags[key] = value;
20
+ } else if (key.startsWith('no-')) {
21
+ flags[key.slice(3)] = false;
22
+ } else {
23
+ // Check if next arg is a value
24
+ const next = args[i + 1];
25
+ if (next && !next.startsWith('--')) {
26
+ flags[key] = next;
27
+ i++;
28
+ } else {
29
+ flags[key] = true;
30
+ }
31
+ }
32
+ }
33
+ }
34
+ return flags;
35
+ }
36
+
37
+ function getPositionalArgs(args: string[]): string[] {
38
+ return args.filter(a => !a.startsWith('--'));
39
+ }
40
+
41
+ function parseFramework(flags: Record<string, string | boolean>) {
42
+ return (
43
+ ['hono', 'openapi', 'django', 'fastapi'].includes(flags.framework as string)
44
+ ? flags.framework
45
+ : 'express'
46
+ ) as 'express' | 'hono' | 'openapi' | 'django' | 'fastapi';
47
+ }
48
+
49
+ async function main(): Promise<void> {
50
+ if (!command || command === '--help' || command === '-h') {
51
+ printHelp();
52
+ return;
53
+ }
54
+
55
+ const flagArgs = args.slice(1);
56
+ const flags = parseFlags(flagArgs);
57
+ const positional = getPositionalArgs(flagArgs);
58
+ const dir = positional[0] || '.';
59
+
60
+ if (command === 'scan') {
61
+ await runScan({
62
+ dir,
63
+ format: flags.format === 'table' ? 'table' : 'json',
64
+ include: typeof flags.include === 'string' ? flags.include.split(',') : undefined,
65
+ exclude: typeof flags.exclude === 'string' ? flags.exclude.split(',') : undefined,
66
+ ignore: typeof flags.ignore === 'string' ? flags.ignore.split(',') : undefined,
67
+ });
68
+ } else if (command === 'generate') {
69
+ await runGenerate({
70
+ dir,
71
+ framework: parseFramework(flags),
72
+ output: typeof flags.output === 'string' ? flags.output : undefined,
73
+ typescript: flags.typescript !== false,
74
+ overwrite: flags.overwrite === true,
75
+ include: typeof flags.include === 'string' ? flags.include.split(',') : undefined,
76
+ exclude: typeof flags.exclude === 'string' ? flags.exclude.split(',') : undefined,
77
+ });
78
+ } else if (command === 'diff') {
79
+ await runDiff({
80
+ dir,
81
+ format: flags.format === 'detailed' ? 'detailed' : 'summary',
82
+ include: typeof flags.include === 'string' ? flags.include.split(',') : undefined,
83
+ exclude: typeof flags.exclude === 'string' ? flags.exclude.split(',') : undefined,
84
+ ignore: typeof flags.ignore === 'string' ? flags.ignore.split(',') : undefined,
85
+ });
86
+ } else if (command === 'watch') {
87
+ await runWatch({
88
+ dir,
89
+ framework: parseFramework(flags),
90
+ output: typeof flags.output === 'string' ? flags.output : undefined,
91
+ typescript: flags.typescript !== false,
92
+ include: typeof flags.include === 'string' ? flags.include.split(',') : undefined,
93
+ exclude: typeof flags.exclude === 'string' ? flags.exclude.split(',') : undefined,
94
+ ignore: typeof flags.ignore === 'string' ? flags.ignore.split(',') : undefined,
95
+ debounce: typeof flags.debounce === 'string' ? parseInt(flags.debounce, 10) : undefined,
96
+ });
97
+ } else {
98
+ console.error(`Unknown command: ${command}`);
99
+ console.error('Run "serverbridge --help" for usage.');
100
+ process.exit(1);
101
+ }
102
+ }
103
+
104
+ function printHelp(): void {
105
+ console.log(`
106
+ serverbridge - Extract routes from hyperscript/htmx HTML and generate server stubs
107
+
108
+ Usage:
109
+ serverbridge scan [dir] [options] Scan files and print extracted routes
110
+ serverbridge generate [dir] [options] Scan + generate server route stubs
111
+ serverbridge diff [dir] [options] Show changes since last generation
112
+ serverbridge watch [dir] [options] Watch files and auto-regenerate
113
+
114
+ Scan options:
115
+ --format=json|table Output format (default: json)
116
+ --include=PATTERNS Comma-separated glob patterns (default: **/*.{html,htm})
117
+ --exclude=PATTERNS Comma-separated exclusion patterns
118
+ --ignore=URLS Comma-separated URL patterns to ignore
119
+
120
+ Generate options:
121
+ --framework=express|hono|openapi|django|fastapi Target framework (default: express)
122
+ --output=DIR Output directory (default: ./server/routes)
123
+ --typescript Generate TypeScript (default: true)
124
+ --no-typescript Generate JavaScript
125
+ --overwrite Overwrite existing user code
126
+
127
+ Diff options:
128
+ --format=summary|detailed Output format (default: summary)
129
+
130
+ Watch options:
131
+ --framework=... Same as generate
132
+ --output=DIR Same as generate
133
+ --debounce=MS Debounce delay in ms (default: 300)
134
+
135
+ Config file:
136
+ Place .serverbridgerc.json in your project root to set defaults.
137
+ `);
138
+ }
139
+
140
+ main().catch(err => {
141
+ console.error(err.message || err);
142
+ process.exit(1);
143
+ });
@@ -0,0 +1,72 @@
1
+ import { resolve } from 'node:path';
2
+ import { scanDirectory } from '../scanner/route-scanner.js';
3
+ import { formatConflicts } from '../scanner/conflict-detector.js';
4
+ import { loadConfig } from './config.js';
5
+
6
+ interface ScanOptions {
7
+ dir: string;
8
+ format?: 'json' | 'table';
9
+ include?: string[];
10
+ exclude?: string[];
11
+ ignore?: string[];
12
+ }
13
+
14
+ export async function runScan(options: ScanOptions): Promise<void> {
15
+ const cwd = resolve(options.dir);
16
+ const config = await loadConfig(cwd, {
17
+ include: options.include,
18
+ exclude: options.exclude,
19
+ ignore: options.ignore,
20
+ });
21
+
22
+ const result = await scanDirectory(cwd, {
23
+ include: config.include,
24
+ exclude: config.exclude,
25
+ ignore: config.ignore,
26
+ });
27
+
28
+ if (result.errors.length > 0) {
29
+ for (const err of result.errors) {
30
+ console.error(`Error in ${err.file}: ${err.message}`);
31
+ }
32
+ }
33
+
34
+ if (result.conflicts.length > 0) {
35
+ console.error(
36
+ `\nWarning: ${result.conflicts.length} route conflict(s) detected:\n${formatConflicts(result.conflicts)}\n`
37
+ );
38
+ }
39
+
40
+ if (options.format === 'table') {
41
+ printTable(result.routes);
42
+ } else {
43
+ console.log(JSON.stringify(result, null, 2));
44
+ }
45
+ }
46
+
47
+ function printTable(
48
+ routes: {
49
+ path: string;
50
+ method: string;
51
+ responseFormat: string;
52
+ handlerName: string;
53
+ source: { file: string; kind: string };
54
+ }[]
55
+ ): void {
56
+ if (routes.length === 0) {
57
+ console.log('No routes found.');
58
+ return;
59
+ }
60
+
61
+ console.log(`Found ${routes.length} route(s):\n`);
62
+ console.log(' METHOD PATH FORMAT SOURCE');
63
+ console.log(' ------ ---------------------------- ------ ------');
64
+
65
+ for (const route of routes) {
66
+ const method = route.method.padEnd(6);
67
+ const path = route.path.padEnd(28);
68
+ const format = route.responseFormat.padEnd(6);
69
+ const source = route.source.kind;
70
+ console.log(` ${method} ${path} ${format} ${source}`);
71
+ }
72
+ }
@@ -0,0 +1,242 @@
1
+ import { watch } from 'node:fs';
2
+ import { readFile, mkdir, writeFile } from 'node:fs/promises';
3
+ import { resolve, relative, join } from 'node:path';
4
+ import { scanDirectory, scanRoutes, matchIgnorePattern } from '../scanner/route-scanner.js';
5
+ import { detectConflicts, formatConflicts } from '../scanner/conflict-detector.js';
6
+ import { selectGenerator } from '../generators/index.js';
7
+ import { createManifest, saveManifest } from '../generators/manifest.js';
8
+ import { loadConfig } from './config.js';
9
+ import type { RouteDescriptor } from '../types.js';
10
+
11
+ interface WatchOptions {
12
+ dir: string;
13
+ framework?: 'express' | 'hono' | 'openapi' | 'django' | 'fastapi';
14
+ output?: string;
15
+ typescript?: boolean;
16
+ include?: string[];
17
+ exclude?: string[];
18
+ ignore?: string[];
19
+ debounce?: number;
20
+ }
21
+
22
+ /** Convert a glob-like pattern to a regex for file matching. */
23
+ function patternToRegex(pattern: string): RegExp {
24
+ const escaped = pattern
25
+ .replace(/\./g, '\\.')
26
+ .replace(/\*\*\//g, '\0/')
27
+ .replace(/\*\*/g, '\0')
28
+ .replace(/\*/g, '[^/]*')
29
+ .replace(/\0\//g, '(.+/)?')
30
+ .replace(/\0/g, '.*');
31
+ return new RegExp('^' + escaped + '$');
32
+ }
33
+
34
+ function shouldScan(filename: string, includePatterns: string[]): boolean {
35
+ return includePatterns.some(p => patternToRegex(p).test(filename));
36
+ }
37
+
38
+ function shouldSkip(relPath: string, excludePatterns: string[]): boolean {
39
+ return excludePatterns.some(p => patternToRegex(p).test(relPath));
40
+ }
41
+
42
+ /**
43
+ * Creates a debounced runner that:
44
+ * - Debounces rapid invocations with setTimeout/clearTimeout
45
+ * - Guards against overlapping async executions
46
+ * - Queues a follow-up run if triggered during execution
47
+ */
48
+ export function createDebouncedRunner(fn: () => Promise<void>, ms: number) {
49
+ let timer: ReturnType<typeof setTimeout> | null = null;
50
+ let running = false;
51
+ let queued = false;
52
+
53
+ function trigger(): void {
54
+ if (timer) clearTimeout(timer);
55
+ timer = setTimeout(run, ms);
56
+ }
57
+
58
+ async function run(): Promise<void> {
59
+ timer = null;
60
+ if (running) {
61
+ queued = true;
62
+ return;
63
+ }
64
+ running = true;
65
+ try {
66
+ await fn();
67
+ } finally {
68
+ running = false;
69
+ if (queued) {
70
+ queued = false;
71
+ trigger();
72
+ }
73
+ }
74
+ }
75
+
76
+ return { trigger };
77
+ }
78
+
79
+ /**
80
+ * Compute a hash of all route method:path pairs for change detection.
81
+ */
82
+ export function computeRouteHash(routes: RouteDescriptor[]): string {
83
+ return routes
84
+ .map(r => `${r.method}:${r.path}`)
85
+ .sort()
86
+ .join('\n');
87
+ }
88
+
89
+ export async function runWatch(options: WatchOptions): Promise<void> {
90
+ const cwd = resolve(options.dir);
91
+ const config = await loadConfig(cwd, {
92
+ framework: options.framework,
93
+ output: options.output,
94
+ typescript: options.typescript,
95
+ include: options.include,
96
+ exclude: options.exclude,
97
+ ignore: options.ignore,
98
+ });
99
+
100
+ const includePatterns = config.include ?? ['**/*.html', '**/*.htm'];
101
+ const excludePatterns = config.exclude ?? ['node_modules/**', 'dist/**'];
102
+ const ignorePatterns = config.ignore ?? [];
103
+ const debounceMs = options.debounce ?? 300;
104
+ const outputDir = resolve(cwd, config.output ?? './server/routes');
105
+ const framework = config.framework ?? 'express';
106
+
107
+ console.log(`\nWatching for changes in ${cwd}/`);
108
+ console.log(` Output: ${outputDir}/`);
109
+ console.log(` Framework: ${framework}`);
110
+ console.log(` Press Ctrl+C to stop.\n`);
111
+
112
+ // Per-file route cache
113
+ const routesByFile = new Map<string, RouteDescriptor[]>();
114
+ let currentRouteHash = '';
115
+
116
+ // Initial full scan
117
+ const initialScan = await scanDirectory(cwd, {
118
+ include: config.include,
119
+ exclude: config.exclude,
120
+ ignore: config.ignore,
121
+ });
122
+ for (const route of initialScan.routes) {
123
+ const file = route.source.file;
124
+ const existing = routesByFile.get(file) ?? [];
125
+ existing.push(route);
126
+ routesByFile.set(file, existing);
127
+ }
128
+
129
+ // Generate from initial scan
130
+ await generateFromCache();
131
+
132
+ const changedFiles = new Set<string>();
133
+ const debounced = createDebouncedRunner(async () => {
134
+ const files = [...changedFiles];
135
+ changedFiles.clear();
136
+ await incrementalUpdate(files);
137
+ }, debounceMs);
138
+
139
+ const watcher = watch(cwd, { recursive: true }, (_eventType, filename) => {
140
+ if (!filename) return;
141
+
142
+ const relPath = relative(cwd, resolve(cwd, filename));
143
+
144
+ if (shouldSkip(relPath, excludePatterns)) return;
145
+ if (!shouldScan(filename, includePatterns)) return;
146
+
147
+ console.log(` Changed: ${relPath}`);
148
+ changedFiles.add(resolve(cwd, filename));
149
+ debounced.trigger();
150
+ });
151
+
152
+ process.on('SIGINT', () => {
153
+ console.log('\nStopping watch mode...');
154
+ watcher.close();
155
+ process.exit(0);
156
+ });
157
+
158
+ // Keep alive
159
+ await new Promise(() => {});
160
+
161
+ async function incrementalUpdate(files: string[]): Promise<void> {
162
+ try {
163
+ for (const file of files) {
164
+ try {
165
+ const content = await readFile(file, 'utf-8');
166
+ const routes = scanRoutes(content, file);
167
+ routesByFile.set(file, routes);
168
+ } catch {
169
+ // File may have been deleted
170
+ routesByFile.delete(file);
171
+ }
172
+ }
173
+ await generateFromCache();
174
+ } catch (err) {
175
+ console.error('Generation failed:', err instanceof Error ? err.message : err);
176
+ }
177
+ }
178
+
179
+ async function generateFromCache(): Promise<void> {
180
+ // Merge all cached routes
181
+ let allRoutes: RouteDescriptor[] = [];
182
+ for (const routes of routesByFile.values()) {
183
+ allRoutes.push(...routes);
184
+ }
185
+
186
+ // Apply ignore patterns
187
+ if (ignorePatterns.length > 0) {
188
+ allRoutes = allRoutes.filter(
189
+ route => !ignorePatterns.some(p => matchIgnorePattern(route.path, p))
190
+ );
191
+ }
192
+
193
+ // Deduplicate by method:path (first wins)
194
+ const seen = new Map<string, RouteDescriptor>();
195
+ for (const route of allRoutes) {
196
+ const key = `${route.method}:${route.path}`;
197
+ if (!seen.has(key)) seen.set(key, route);
198
+ }
199
+ const routes = [...seen.values()];
200
+
201
+ // Hash check — skip generation if unchanged
202
+ const newHash = computeRouteHash(routes);
203
+ if (newHash === currentRouteHash) {
204
+ console.log(' No route changes detected, skipping generation.');
205
+ return;
206
+ }
207
+ currentRouteHash = newHash;
208
+
209
+ // Detect conflicts
210
+ const conflicts = detectConflicts(allRoutes);
211
+ if (conflicts.length > 0) {
212
+ console.error(
213
+ `\n Warning: ${conflicts.length} route conflict(s):\n${formatConflicts(conflicts)}`
214
+ );
215
+ }
216
+
217
+ // Generate
218
+ const generator = selectGenerator(framework);
219
+ const generated = generator.generate(routes, {
220
+ outputDir,
221
+ typescript: config.typescript,
222
+ });
223
+
224
+ // Write files
225
+ await mkdir(outputDir, { recursive: true });
226
+ for (const file of generated.files) {
227
+ const filePath = join(outputDir, file.path);
228
+ const fileDir = join(filePath, '..');
229
+ await mkdir(fileDir, { recursive: true });
230
+ await writeFile(filePath, file.content, 'utf-8');
231
+ }
232
+
233
+ // Save manifest
234
+ const manifest = createManifest(routes);
235
+ await saveManifest(outputDir, manifest);
236
+
237
+ console.log(` Generated ${generated.files.length} file(s) for ${routes.length} route(s)`);
238
+ }
239
+ }
240
+
241
+ // Exported for testing
242
+ export { patternToRegex, shouldScan, shouldSkip };
@@ -0,0 +1,68 @@
1
+ import type { HttpMethod } from '../types.js';
2
+ import { urlToHandlerName } from './url-patterns.js';
3
+
4
+ export interface ConventionContext {
5
+ /** HTML element tag (e.g., 'form', 'button') */
6
+ elementTag?: string;
7
+ /** Explicit response type from hyperscript (e.g., 'json', 'html') */
8
+ fetchResponseType?: string;
9
+ /** Explicit method from source (e.g., from hx-post, fx-method) */
10
+ explicitMethod?: string;
11
+ }
12
+
13
+ export interface ConventionResult {
14
+ method: HttpMethod;
15
+ responseFormat: 'json' | 'text' | 'html';
16
+ handlerName: string;
17
+ notes: string[];
18
+ }
19
+
20
+ /**
21
+ * Infer HTTP method, response format, and handler name from URL and context.
22
+ *
23
+ * Priority:
24
+ * 1. Explicit method always wins (hx-post, fx-method, fetch via POST)
25
+ * 2. Form context implies POST
26
+ * 3. Default: GET
27
+ *
28
+ * Response format:
29
+ * 1. Explicit format from hyperscript (as json, as html)
30
+ * 2. URL ending in .html → html
31
+ * 3. Default: json
32
+ */
33
+ export function inferConventions(url: string, context: ConventionContext = {}): ConventionResult {
34
+ const notes: string[] = [];
35
+
36
+ // --- Method ---
37
+ let method: HttpMethod;
38
+ if (context.explicitMethod) {
39
+ method = context.explicitMethod.toUpperCase() as HttpMethod;
40
+ } else if (context.elementTag === 'form') {
41
+ method = 'POST';
42
+ notes.push('Inferred POST from <form> context');
43
+ } else {
44
+ method = 'GET';
45
+ }
46
+
47
+ // --- Response Format ---
48
+ let responseFormat: 'json' | 'text' | 'html';
49
+ if (context.fetchResponseType) {
50
+ const fmt = context.fetchResponseType.toLowerCase();
51
+ if (fmt === 'json' || fmt === 'html' || fmt === 'text') {
52
+ responseFormat = fmt;
53
+ } else {
54
+ responseFormat = 'json';
55
+ notes.push(`Unknown response type "${context.fetchResponseType}", defaulting to json`);
56
+ }
57
+ } else if (url.endsWith('.html') || url.includes('/pages/') || url.includes('/partials/')) {
58
+ responseFormat = 'html';
59
+ notes.push('Inferred html response from URL pattern');
60
+ } else {
61
+ responseFormat = 'json';
62
+ }
63
+
64
+ // --- Handler Name ---
65
+ const handlerName = urlToHandlerName(url, method);
66
+
67
+ return { method, responseFormat, handlerName, notes };
68
+ }