@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.
- package/LICENSE +20 -0
- package/dist/cli/config.d.ts +5 -0
- package/dist/cli/diff-command.d.ts +9 -0
- package/dist/cli/generate-command.d.ts +12 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/scan-command.d.ts +9 -0
- package/dist/cli/watch-command.d.ts +30 -0
- package/dist/cli.cjs +1853 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/conventions/convention-engine.d.ts +29 -0
- package/dist/conventions/url-patterns.d.ts +37 -0
- package/dist/generate.d.ts +21 -0
- package/dist/generators/django-generator.d.ts +10 -0
- package/dist/generators/express-generator.d.ts +9 -0
- package/dist/generators/fastapi-generator.d.ts +10 -0
- package/dist/generators/hono-generator.d.ts +9 -0
- package/dist/generators/index.d.ts +6 -0
- package/dist/generators/manifest.d.ts +43 -0
- package/dist/generators/openapi-generator.d.ts +14 -0
- package/dist/generators/template-helpers.d.ts +24 -0
- package/dist/generators/types.d.ts +11 -0
- package/dist/index.cjs +1426 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +1376 -0
- package/dist/index.js.map +1 -0
- package/dist/scanner/conflict-detector.d.ts +10 -0
- package/dist/scanner/form-scanner.d.ts +12 -0
- package/dist/scanner/htmx-extractor.d.ts +9 -0
- package/dist/scanner/hyperscript-extractor.d.ts +9 -0
- package/dist/scanner/route-scanner.d.ts +21 -0
- package/dist/types.d.ts +123 -0
- package/package.json +64 -0
- package/src/__tests__/cli.test.ts +30 -0
- package/src/__tests__/conflict-detector.test.ts +126 -0
- package/src/__tests__/convention-engine.test.ts +58 -0
- package/src/__tests__/diff-command.test.ts +69 -0
- package/src/__tests__/django-generator.test.ts +129 -0
- package/src/__tests__/express-generator.test.ts +105 -0
- package/src/__tests__/fastapi-generator.test.ts +125 -0
- package/src/__tests__/form-scanner.test.ts +91 -0
- package/src/__tests__/generate.test.ts +89 -0
- package/src/__tests__/hono-generator.test.ts +90 -0
- package/src/__tests__/htmx-extractor.test.ts +84 -0
- package/src/__tests__/hyperscript-extractor.test.ts +89 -0
- package/src/__tests__/integration.test.ts +102 -0
- package/src/__tests__/manifest.test.ts +133 -0
- package/src/__tests__/openapi-generator.test.ts +116 -0
- package/src/__tests__/query-params.test.ts +101 -0
- package/src/__tests__/route-scanner.test.ts +47 -0
- package/src/__tests__/url-patterns.test.ts +74 -0
- package/src/__tests__/watch-command.test.ts +141 -0
- package/src/cli/config.ts +35 -0
- package/src/cli/diff-command.ts +96 -0
- package/src/cli/generate-command.ts +108 -0
- package/src/cli/index.ts +143 -0
- package/src/cli/scan-command.ts +72 -0
- package/src/cli/watch-command.ts +242 -0
- package/src/conventions/convention-engine.ts +68 -0
- package/src/conventions/url-patterns.ts +123 -0
- package/src/generate.ts +44 -0
- package/src/generators/django-generator.ts +210 -0
- package/src/generators/express-generator.ts +137 -0
- package/src/generators/fastapi-generator.ts +248 -0
- package/src/generators/hono-generator.ts +137 -0
- package/src/generators/index.ts +25 -0
- package/src/generators/manifest.ts +164 -0
- package/src/generators/openapi-generator.ts +259 -0
- package/src/generators/template-helpers.ts +77 -0
- package/src/generators/types.ts +12 -0
- package/src/index.ts +42 -0
- package/src/scanner/conflict-detector.ts +92 -0
- package/src/scanner/form-scanner.ts +126 -0
- package/src/scanner/htmx-extractor.ts +174 -0
- package/src/scanner/hyperscript-extractor.ts +184 -0
- package/src/scanner/route-scanner.ts +130 -0
- 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
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -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
|
+
}
|