@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,91 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { extractFormFields, extractFormBodies } from '../scanner/form-scanner.js';
|
|
3
|
+
|
|
4
|
+
describe('extractFormFields', () => {
|
|
5
|
+
it('extracts named input fields', () => {
|
|
6
|
+
const html = `
|
|
7
|
+
<input name="username" type="text" required>
|
|
8
|
+
<input name="email" type="email" required>
|
|
9
|
+
<input name="age" type="number">
|
|
10
|
+
`;
|
|
11
|
+
const fields = extractFormFields(html);
|
|
12
|
+
expect(fields).toHaveLength(3);
|
|
13
|
+
expect(fields[0]).toEqual({ name: 'username', type: 'string', required: true });
|
|
14
|
+
expect(fields[1]).toEqual({ name: 'email', type: 'string', required: true });
|
|
15
|
+
expect(fields[2]).toEqual({ name: 'age', type: 'number', required: false });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('handles select and textarea', () => {
|
|
19
|
+
const html = `
|
|
20
|
+
<select name="country" required></select>
|
|
21
|
+
<textarea name="bio"></textarea>
|
|
22
|
+
`;
|
|
23
|
+
const fields = extractFormFields(html);
|
|
24
|
+
expect(fields).toHaveLength(2);
|
|
25
|
+
expect(fields[0]).toEqual({ name: 'country', type: 'string', required: true });
|
|
26
|
+
expect(fields[1]).toEqual({ name: 'bio', type: 'string', required: false });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('maps checkbox to boolean and file to file', () => {
|
|
30
|
+
const html = `
|
|
31
|
+
<input name="agree" type="checkbox">
|
|
32
|
+
<input name="avatar" type="file">
|
|
33
|
+
`;
|
|
34
|
+
const fields = extractFormFields(html);
|
|
35
|
+
expect(fields[0].type).toBe('boolean');
|
|
36
|
+
expect(fields[1].type).toBe('file');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('deduplicates by name', () => {
|
|
40
|
+
const html = `
|
|
41
|
+
<input name="q" type="text">
|
|
42
|
+
<input name="q" type="search">
|
|
43
|
+
`;
|
|
44
|
+
const fields = extractFormFields(html);
|
|
45
|
+
expect(fields).toHaveLength(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('defaults to string for unknown type', () => {
|
|
49
|
+
const html = `<input name="data">`;
|
|
50
|
+
const fields = extractFormFields(html);
|
|
51
|
+
expect(fields[0].type).toBe('string');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('extractFormBodies', () => {
|
|
56
|
+
it('associates form fields with hx-post URL', () => {
|
|
57
|
+
const html = `
|
|
58
|
+
<form hx-post="/api/users">
|
|
59
|
+
<input name="name" type="text" required>
|
|
60
|
+
<input name="email" type="email" required>
|
|
61
|
+
<button type="submit">Create</button>
|
|
62
|
+
</form>
|
|
63
|
+
`;
|
|
64
|
+
const bodies = extractFormBodies(html);
|
|
65
|
+
expect(bodies.size).toBe(1);
|
|
66
|
+
const fields = bodies.get('/api/users');
|
|
67
|
+
expect(fields).toHaveLength(2);
|
|
68
|
+
expect(fields![0].name).toBe('name');
|
|
69
|
+
expect(fields![1].name).toBe('email');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('handles fx-action forms', () => {
|
|
73
|
+
const html = `
|
|
74
|
+
<form fx-action="/api/search">
|
|
75
|
+
<input name="q" type="search">
|
|
76
|
+
</form>
|
|
77
|
+
`;
|
|
78
|
+
const bodies = extractFormBodies(html);
|
|
79
|
+
expect(bodies.has('/api/search')).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('skips forms without route-implying attributes', () => {
|
|
83
|
+
const html = `
|
|
84
|
+
<form action="/submit">
|
|
85
|
+
<input name="data">
|
|
86
|
+
</form>
|
|
87
|
+
`;
|
|
88
|
+
const bodies = extractFormBodies(html);
|
|
89
|
+
expect(bodies.size).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { generate } from '../generate.js';
|
|
6
|
+
|
|
7
|
+
describe('generate()', () => {
|
|
8
|
+
const dirs: string[] = [];
|
|
9
|
+
|
|
10
|
+
async function makeTempDir(): Promise<string> {
|
|
11
|
+
const dir = await mkdtemp(join(tmpdir(), 'sb-gen-'));
|
|
12
|
+
dirs.push(dir);
|
|
13
|
+
return dir;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
for (const dir of dirs) {
|
|
18
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
19
|
+
}
|
|
20
|
+
dirs.length = 0;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('returns routes and generated files from HTML with fetch', async () => {
|
|
24
|
+
const dir = await makeTempDir();
|
|
25
|
+
await writeFile(
|
|
26
|
+
join(dir, 'index.html'),
|
|
27
|
+
`<button _="on click fetch /api/users as json">Load</button>`
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const result = await generate({ dir });
|
|
31
|
+
expect(result.routes.length).toBeGreaterThanOrEqual(1);
|
|
32
|
+
expect(result.routes[0].path).toBe('/api/users');
|
|
33
|
+
expect(result.generated.files.length).toBeGreaterThanOrEqual(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns empty results for empty directory', async () => {
|
|
37
|
+
const dir = await makeTempDir();
|
|
38
|
+
const result = await generate({ dir });
|
|
39
|
+
expect(result.routes).toEqual([]);
|
|
40
|
+
expect(result.generated.files).toHaveLength(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('selects the correct generator framework', async () => {
|
|
44
|
+
const dir = await makeTempDir();
|
|
45
|
+
await writeFile(join(dir, 'page.html'), `<div hx-get="/api/data">Load</div>`);
|
|
46
|
+
|
|
47
|
+
const frameworks = ['express', 'hono', 'openapi', 'django', 'fastapi'] as const;
|
|
48
|
+
for (const framework of frameworks) {
|
|
49
|
+
const result = await generate({ dir, framework });
|
|
50
|
+
expect(result.routes).toHaveLength(1);
|
|
51
|
+
expect(result.generated.files.length).toBeGreaterThanOrEqual(1);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('respects include/exclude patterns', async () => {
|
|
56
|
+
const dir = await makeTempDir();
|
|
57
|
+
await mkdir(join(dir, 'src'), { recursive: true });
|
|
58
|
+
await mkdir(join(dir, 'vendor'), { recursive: true });
|
|
59
|
+
|
|
60
|
+
await writeFile(join(dir, 'src', 'app.html'), `<div hx-get="/api/users">Load</div>`);
|
|
61
|
+
await writeFile(join(dir, 'vendor', 'lib.html'), `<div hx-get="/api/external">Load</div>`);
|
|
62
|
+
|
|
63
|
+
const result = await generate({
|
|
64
|
+
dir,
|
|
65
|
+
include: ['src/**/*.html'],
|
|
66
|
+
exclude: ['vendor/**'],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(result.routes).toHaveLength(1);
|
|
70
|
+
expect(result.routes[0].path).toBe('/api/users');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('respects ignore patterns for URLs', async () => {
|
|
74
|
+
const dir = await makeTempDir();
|
|
75
|
+
await writeFile(
|
|
76
|
+
join(dir, 'page.html'),
|
|
77
|
+
`<div hx-get="/api/users">Load</div>
|
|
78
|
+
<div hx-get="/external/api/data">Load</div>`
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const result = await generate({
|
|
82
|
+
dir,
|
|
83
|
+
ignore: ['/external/**'],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(result.routes).toHaveLength(1);
|
|
87
|
+
expect(result.routes[0].path).toBe('/api/users');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { HonoGenerator } from '../generators/hono-generator.js';
|
|
3
|
+
import type { RouteDescriptor } from '../types.js';
|
|
4
|
+
|
|
5
|
+
function makeRoute(overrides: Partial<RouteDescriptor> = {}): RouteDescriptor {
|
|
6
|
+
return {
|
|
7
|
+
path: '/api/users',
|
|
8
|
+
method: 'GET',
|
|
9
|
+
responseFormat: 'json',
|
|
10
|
+
source: { file: 'test.html', kind: 'fetch', raw: 'fetch /api/users' },
|
|
11
|
+
pathParams: [],
|
|
12
|
+
handlerName: 'getApiUsers',
|
|
13
|
+
notes: [],
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('HonoGenerator', () => {
|
|
19
|
+
const gen = new HonoGenerator();
|
|
20
|
+
|
|
21
|
+
it('has correct framework name', () => {
|
|
22
|
+
expect(gen.framework).toBe('hono');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('generates route files for a GET endpoint', () => {
|
|
26
|
+
const result = gen.generate([makeRoute()], { outputDir: '/out' });
|
|
27
|
+
expect(result.files.length).toBeGreaterThanOrEqual(2);
|
|
28
|
+
const routeFile = result.files.find(f => f.path.includes('api-users'));
|
|
29
|
+
expect(routeFile).toBeDefined();
|
|
30
|
+
expect(routeFile!.content).toContain("app.get('/api/users'");
|
|
31
|
+
expect(routeFile!.content).toContain("import { Hono } from 'hono'");
|
|
32
|
+
expect(routeFile!.content).toContain("return c.json({ message: 'Not implemented' })");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('generates POST route with body parsing', () => {
|
|
36
|
+
const route = makeRoute({
|
|
37
|
+
method: 'POST',
|
|
38
|
+
handlerName: 'postApiUsers',
|
|
39
|
+
requestBody: [
|
|
40
|
+
{ name: 'name', type: 'string', required: true },
|
|
41
|
+
{ name: 'email', type: 'string', required: true },
|
|
42
|
+
],
|
|
43
|
+
});
|
|
44
|
+
const result = gen.generate([route], { outputDir: '/out' });
|
|
45
|
+
const routeFile = result.files.find(f => f.path.includes('api-users'));
|
|
46
|
+
expect(routeFile!.content).toContain("app.post('/api/users'");
|
|
47
|
+
expect(routeFile!.content).toContain('const body = await c.req.json()');
|
|
48
|
+
expect(routeFile!.content).toContain('const { name, email } = body');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('generates route with path params', () => {
|
|
52
|
+
const route = makeRoute({
|
|
53
|
+
path: '/api/users/:id',
|
|
54
|
+
pathParams: ['id'],
|
|
55
|
+
});
|
|
56
|
+
const result = gen.generate([route], { outputDir: '/out' });
|
|
57
|
+
const routeFile = result.files.find(f => f.path.includes('api-users'));
|
|
58
|
+
expect(routeFile!.content).toContain("const id = c.req.param('id')");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('generates html response for html format', () => {
|
|
62
|
+
const route = makeRoute({ responseFormat: 'html' });
|
|
63
|
+
const result = gen.generate([route], { outputDir: '/out' });
|
|
64
|
+
const routeFile = result.files.find(f => f.path.includes('api-users'));
|
|
65
|
+
expect(routeFile!.content).toContain("return c.html('<div>Not implemented</div>')");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('generates query param extraction', () => {
|
|
69
|
+
const route = makeRoute({
|
|
70
|
+
path: '/api/search',
|
|
71
|
+
handlerName: 'getApiSearch',
|
|
72
|
+
queryParams: [{ name: 'q', type: 'string', required: true }],
|
|
73
|
+
});
|
|
74
|
+
const result = gen.generate([route], { outputDir: '/out' });
|
|
75
|
+
const routeFile = result.files.find(f => f.path.includes('api-search'));
|
|
76
|
+
expect(routeFile!.content).toContain("const q = c.req.query('q')");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('generates index file that composes route apps', () => {
|
|
80
|
+
const routes = [
|
|
81
|
+
makeRoute({ path: '/api/users' }),
|
|
82
|
+
makeRoute({ path: '/api/products', handlerName: 'getApiProducts' }),
|
|
83
|
+
];
|
|
84
|
+
const result = gen.generate(routes, { outputDir: '/out' });
|
|
85
|
+
const indexFile = result.files.find(f => f.path.endsWith('index.ts'));
|
|
86
|
+
expect(indexFile).toBeDefined();
|
|
87
|
+
expect(indexFile!.content).toContain("import apiUsersRoutes from './api-users.js'");
|
|
88
|
+
expect(indexFile!.content).toContain("app.route('/', apiUsersRoutes)");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { extractHtmxRoutes } from '../scanner/htmx-extractor.js';
|
|
3
|
+
|
|
4
|
+
const FILE = 'test.html';
|
|
5
|
+
|
|
6
|
+
describe('extractHtmxRoutes', () => {
|
|
7
|
+
it('extracts hx-get', () => {
|
|
8
|
+
const html = `<div hx-get="/api/users" hx-target="#list">`;
|
|
9
|
+
const routes = extractHtmxRoutes(html, { file: FILE });
|
|
10
|
+
expect(routes).toHaveLength(1);
|
|
11
|
+
expect(routes[0].path).toBe('/api/users');
|
|
12
|
+
expect(routes[0].method).toBe('GET');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('extracts hx-post', () => {
|
|
16
|
+
const html = `<form hx-post="/api/users" hx-target="#result">`;
|
|
17
|
+
const routes = extractHtmxRoutes(html, { file: FILE });
|
|
18
|
+
expect(routes).toHaveLength(1);
|
|
19
|
+
expect(routes[0].method).toBe('POST');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('extracts hx-delete', () => {
|
|
23
|
+
const html = `<button hx-delete="/api/users/:id">`;
|
|
24
|
+
const routes = extractHtmxRoutes(html, { file: FILE });
|
|
25
|
+
expect(routes).toHaveLength(1);
|
|
26
|
+
expect(routes[0].method).toBe('DELETE');
|
|
27
|
+
expect(routes[0].pathParams).toEqual(['id']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('defaults to html response format for htmx', () => {
|
|
31
|
+
const html = `<div hx-get="/api/users">`;
|
|
32
|
+
const routes = extractHtmxRoutes(html, { file: FILE });
|
|
33
|
+
expect(routes[0].responseFormat).toBe('html');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('extracts fx-action with fx-method', () => {
|
|
37
|
+
const html = `<button fx-action="/api/search" fx-method="GET" fx-target="#results">`;
|
|
38
|
+
const routes = extractHtmxRoutes(html, { file: FILE });
|
|
39
|
+
expect(routes).toHaveLength(1);
|
|
40
|
+
expect(routes[0].path).toBe('/api/search');
|
|
41
|
+
expect(routes[0].method).toBe('GET');
|
|
42
|
+
expect(routes[0].source.kind).toBe('fx-attr');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('extracts fx-action without fx-method (defaults to GET)', () => {
|
|
46
|
+
const html = `<button fx-action="/api/data">`;
|
|
47
|
+
const routes = extractHtmxRoutes(html, { file: FILE });
|
|
48
|
+
expect(routes).toHaveLength(1);
|
|
49
|
+
expect(routes[0].method).toBe('GET');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('extracts multiple htmx routes from one file', () => {
|
|
53
|
+
const html = `
|
|
54
|
+
<div hx-get="/api/users" hx-target="#users">
|
|
55
|
+
<form hx-post="/api/users" hx-target="#result">
|
|
56
|
+
<button hx-delete="/api/users/:id">
|
|
57
|
+
`;
|
|
58
|
+
const routes = extractHtmxRoutes(html, { file: FILE });
|
|
59
|
+
expect(routes).toHaveLength(3);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('skips external URLs', () => {
|
|
63
|
+
const html = `<div hx-get="https://external.com/api/data">`;
|
|
64
|
+
const routes = extractHtmxRoutes(html, { file: FILE });
|
|
65
|
+
expect(routes).toHaveLength(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('deduplicates same method+path', () => {
|
|
69
|
+
const html = `
|
|
70
|
+
<button hx-get="/api/users">Load</button>
|
|
71
|
+
<div hx-get="/api/users">Refresh</div>
|
|
72
|
+
`;
|
|
73
|
+
const routes = extractHtmxRoutes(html, { file: FILE });
|
|
74
|
+
expect(routes).toHaveLength(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('records source information', () => {
|
|
78
|
+
const html = `\n<button hx-post="/api/submit">`;
|
|
79
|
+
const routes = extractHtmxRoutes(html, { file: FILE });
|
|
80
|
+
expect(routes[0].source.file).toBe(FILE);
|
|
81
|
+
expect(routes[0].source.kind).toBe('hx-attr');
|
|
82
|
+
expect(routes[0].source.raw).toBe('hx-post="/api/submit"');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { extractHyperscriptRoutes } from '../scanner/hyperscript-extractor.js';
|
|
3
|
+
|
|
4
|
+
const FILE = 'test.html';
|
|
5
|
+
|
|
6
|
+
describe('extractHyperscriptRoutes', () => {
|
|
7
|
+
it('extracts fetch with URL and response type', () => {
|
|
8
|
+
const html = `<button _="on click fetch /api/users as json then put it into #list">`;
|
|
9
|
+
const routes = extractHyperscriptRoutes(html, { file: FILE });
|
|
10
|
+
expect(routes).toHaveLength(1);
|
|
11
|
+
expect(routes[0].path).toBe('/api/users');
|
|
12
|
+
expect(routes[0].method).toBe('GET');
|
|
13
|
+
expect(routes[0].responseFormat).toBe('json');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('extracts fetch with html response type', () => {
|
|
17
|
+
const html = `<div _="on load fetch /partials/header as html then put it into me">`;
|
|
18
|
+
const routes = extractHyperscriptRoutes(html, { file: FILE });
|
|
19
|
+
expect(routes).toHaveLength(1);
|
|
20
|
+
expect(routes[0].responseFormat).toBe('html');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('extracts fetch with explicit method via keyword', () => {
|
|
24
|
+
const html = `<button _="on click fetch /api/users via POST">`;
|
|
25
|
+
const routes = extractHyperscriptRoutes(html, { file: FILE });
|
|
26
|
+
expect(routes).toHaveLength(1);
|
|
27
|
+
expect(routes[0].method).toBe('POST');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('extracts URL with path parameters', () => {
|
|
31
|
+
const html = `<button _="on click fetch /api/users/:id as json">`;
|
|
32
|
+
const routes = extractHyperscriptRoutes(html, { file: FILE });
|
|
33
|
+
expect(routes).toHaveLength(1);
|
|
34
|
+
expect(routes[0].pathParams).toEqual(['id']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('extracts multiple fetch commands from different attributes', () => {
|
|
38
|
+
const html = `
|
|
39
|
+
<ul _="on load fetch /api/users as json then put it into me">
|
|
40
|
+
<button _="on click fetch /api/users/:id as json then put it into #detail">
|
|
41
|
+
`;
|
|
42
|
+
const routes = extractHyperscriptRoutes(html, { file: FILE });
|
|
43
|
+
expect(routes).toHaveLength(2);
|
|
44
|
+
expect(routes[0].path).toBe('/api/users');
|
|
45
|
+
expect(routes[1].path).toBe('/api/users/:id');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('extracts "send form to" as POST', () => {
|
|
49
|
+
const html = `<form _="on submit send form to /api/users as json">`;
|
|
50
|
+
const routes = extractHyperscriptRoutes(html, { file: FILE });
|
|
51
|
+
expect(routes).toHaveLength(1);
|
|
52
|
+
expect(routes[0].method).toBe('POST');
|
|
53
|
+
expect(routes[0].path).toBe('/api/users');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('extracts from script type=text/hyperscript blocks', () => {
|
|
57
|
+
const html = `
|
|
58
|
+
<script type="text/hyperscript">
|
|
59
|
+
on load fetch /api/config as json
|
|
60
|
+
</script>
|
|
61
|
+
`;
|
|
62
|
+
const routes = extractHyperscriptRoutes(html, { file: FILE });
|
|
63
|
+
expect(routes).toHaveLength(1);
|
|
64
|
+
expect(routes[0].path).toBe('/api/config');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('skips external URLs', () => {
|
|
68
|
+
const html = `<button _="on click fetch https://api.stripe.com/v1/charges as json">`;
|
|
69
|
+
const routes = extractHyperscriptRoutes(html, { file: FILE });
|
|
70
|
+
expect(routes).toHaveLength(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('deduplicates identical routes', () => {
|
|
74
|
+
const html = `
|
|
75
|
+
<button _="on click fetch /api/users as json">
|
|
76
|
+
<div _="on load fetch /api/users as json">
|
|
77
|
+
`;
|
|
78
|
+
const routes = extractHyperscriptRoutes(html, { file: FILE });
|
|
79
|
+
expect(routes).toHaveLength(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('records source information', () => {
|
|
83
|
+
const html = `\n\n<button _="on click fetch /api/data as json">`;
|
|
84
|
+
const routes = extractHyperscriptRoutes(html, { file: FILE });
|
|
85
|
+
expect(routes[0].source.file).toBe(FILE);
|
|
86
|
+
expect(routes[0].source.kind).toBe('fetch');
|
|
87
|
+
expect(routes[0].source.line).toBe(3);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { scanRoutes } from '../scanner/route-scanner.js';
|
|
3
|
+
import { ExpressGenerator } from '../generators/express-generator.js';
|
|
4
|
+
import { HonoGenerator } from '../generators/hono-generator.js';
|
|
5
|
+
|
|
6
|
+
const SAMPLE_HTML = `
|
|
7
|
+
<!DOCTYPE html>
|
|
8
|
+
<html>
|
|
9
|
+
<body>
|
|
10
|
+
<h1>User Management</h1>
|
|
11
|
+
|
|
12
|
+
<!-- Hyperscript fetch -->
|
|
13
|
+
<div _="on load fetch /api/users as json then put it into me">
|
|
14
|
+
Loading users...
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<!-- htmx GET -->
|
|
18
|
+
<button hx-get="/api/users/:id" hx-target="#detail">
|
|
19
|
+
View Details
|
|
20
|
+
</button>
|
|
21
|
+
|
|
22
|
+
<!-- htmx POST with form -->
|
|
23
|
+
<form hx-post="/api/users" hx-target="#result">
|
|
24
|
+
<input name="name" type="text" required>
|
|
25
|
+
<input name="email" type="email" required>
|
|
26
|
+
<input name="age" type="number">
|
|
27
|
+
<button type="submit">Create User</button>
|
|
28
|
+
</form>
|
|
29
|
+
|
|
30
|
+
<!-- htmx DELETE -->
|
|
31
|
+
<button hx-delete="/api/users/:id" hx-target="#user-row">
|
|
32
|
+
Delete User
|
|
33
|
+
</button>
|
|
34
|
+
|
|
35
|
+
<!-- fixi -->
|
|
36
|
+
<button fx-action="/api/search" fx-method="GET" fx-target="#results">
|
|
37
|
+
Search
|
|
38
|
+
</button>
|
|
39
|
+
|
|
40
|
+
<div id="detail"></div>
|
|
41
|
+
<div id="result"></div>
|
|
42
|
+
<div id="results"></div>
|
|
43
|
+
</body>
|
|
44
|
+
</html>
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
describe('integration: full pipeline', () => {
|
|
48
|
+
it('extracts all routes from a realistic HTML page', () => {
|
|
49
|
+
const routes = scanRoutes(SAMPLE_HTML, 'users.html');
|
|
50
|
+
|
|
51
|
+
// Should find: GET /api/users, GET /api/users/:id, POST /api/users,
|
|
52
|
+
// DELETE /api/users/:id, GET /api/search
|
|
53
|
+
expect(routes.length).toBeGreaterThanOrEqual(4);
|
|
54
|
+
|
|
55
|
+
const paths = routes.map(r => `${r.method} ${r.path}`);
|
|
56
|
+
expect(paths).toContain('GET /api/users');
|
|
57
|
+
expect(paths).toContain('POST /api/users');
|
|
58
|
+
expect(paths).toContain('DELETE /api/users/:id');
|
|
59
|
+
expect(paths).toContain('GET /api/search');
|
|
60
|
+
|
|
61
|
+
// POST route should have form body fields
|
|
62
|
+
const postRoute = routes.find(r => r.method === 'POST' && r.path === '/api/users');
|
|
63
|
+
expect(postRoute?.requestBody).toBeDefined();
|
|
64
|
+
expect(postRoute!.requestBody!.length).toBeGreaterThanOrEqual(2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('generates valid Express routes from extracted routes', () => {
|
|
68
|
+
const routes = scanRoutes(SAMPLE_HTML, 'users.html');
|
|
69
|
+
const gen = new ExpressGenerator();
|
|
70
|
+
const result = gen.generate(routes, { outputDir: '/out' });
|
|
71
|
+
|
|
72
|
+
expect(result.files.length).toBeGreaterThanOrEqual(2);
|
|
73
|
+
|
|
74
|
+
// All route files should contain Express Router setup
|
|
75
|
+
const routeFiles = result.files.filter(f => !f.path.endsWith('index.ts'));
|
|
76
|
+
for (const file of routeFiles) {
|
|
77
|
+
expect(file.content).toContain('import { Router');
|
|
78
|
+
expect(file.content).toContain('const router = Router()');
|
|
79
|
+
expect(file.content).toContain('export default router');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Index file should import and wire all routers
|
|
83
|
+
const indexFile = result.files.find(f => f.path.endsWith('index.ts'));
|
|
84
|
+
expect(indexFile).toBeDefined();
|
|
85
|
+
expect(indexFile!.content).toContain('router.use(');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('generates valid Hono routes from extracted routes', () => {
|
|
89
|
+
const routes = scanRoutes(SAMPLE_HTML, 'users.html');
|
|
90
|
+
const gen = new HonoGenerator();
|
|
91
|
+
const result = gen.generate(routes, { outputDir: '/out' });
|
|
92
|
+
|
|
93
|
+
expect(result.files.length).toBeGreaterThanOrEqual(2);
|
|
94
|
+
|
|
95
|
+
const routeFiles = result.files.filter(f => !f.path.endsWith('index.ts'));
|
|
96
|
+
for (const file of routeFiles) {
|
|
97
|
+
expect(file.content).toContain("import { Hono } from 'hono'");
|
|
98
|
+
expect(file.content).toContain('const app = new Hono()');
|
|
99
|
+
expect(file.content).toContain('export default app');
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { hashRoute, createManifest, extractUserCode, diffRoutes } from '../generators/manifest.js';
|
|
3
|
+
import type { RouteDescriptor } from '../types.js';
|
|
4
|
+
|
|
5
|
+
function makeRoute(overrides: Partial<RouteDescriptor> = {}): RouteDescriptor {
|
|
6
|
+
return {
|
|
7
|
+
path: '/api/users',
|
|
8
|
+
method: 'GET',
|
|
9
|
+
responseFormat: 'json',
|
|
10
|
+
source: { file: 'test.html', kind: 'fetch', raw: 'fetch /api/users' },
|
|
11
|
+
pathParams: [],
|
|
12
|
+
handlerName: 'getApiUsers',
|
|
13
|
+
notes: [],
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('hashRoute', () => {
|
|
19
|
+
it('produces consistent hashes', () => {
|
|
20
|
+
const route = makeRoute();
|
|
21
|
+
expect(hashRoute(route)).toBe(hashRoute(route));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('changes hash when route changes', () => {
|
|
25
|
+
const a = makeRoute({ method: 'GET' });
|
|
26
|
+
const b = makeRoute({ method: 'POST' });
|
|
27
|
+
expect(hashRoute(a)).not.toBe(hashRoute(b));
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('createManifest', () => {
|
|
32
|
+
it('creates manifest from routes', () => {
|
|
33
|
+
const routes = [
|
|
34
|
+
makeRoute(),
|
|
35
|
+
makeRoute({ path: '/api/users/:id', handlerName: 'getApiUsersById', pathParams: ['id'] }),
|
|
36
|
+
];
|
|
37
|
+
const manifest = createManifest(routes);
|
|
38
|
+
expect(manifest.version).toBe(1);
|
|
39
|
+
expect(Object.keys(manifest.routes)).toHaveLength(2);
|
|
40
|
+
expect(manifest.routes['GET:/api/users']).toBeDefined();
|
|
41
|
+
expect(manifest.routes['GET:/api/users/:id']).toBeDefined();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('extractUserCode', () => {
|
|
46
|
+
it('extracts user code between markers', () => {
|
|
47
|
+
const content = `
|
|
48
|
+
// @serverbridge-route: GET /api/users
|
|
49
|
+
router.get('/api/users', async (req, res) => {
|
|
50
|
+
// @serverbridge-user-start
|
|
51
|
+
const users = await db.query('SELECT * FROM users');
|
|
52
|
+
res.json(users);
|
|
53
|
+
// @serverbridge-user-end
|
|
54
|
+
});
|
|
55
|
+
`;
|
|
56
|
+
const userCode = extractUserCode(content);
|
|
57
|
+
expect(userCode.size).toBe(1);
|
|
58
|
+
expect(userCode.get('GET:/api/users')).toContain('db.query');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('ignores TODO stubs', () => {
|
|
62
|
+
const content = `
|
|
63
|
+
// @serverbridge-route: GET /api/users
|
|
64
|
+
router.get('/api/users', async (req, res) => {
|
|
65
|
+
// @serverbridge-user-start
|
|
66
|
+
// TODO: Implement GET /api/users
|
|
67
|
+
res.json([]);
|
|
68
|
+
// @serverbridge-user-end
|
|
69
|
+
});
|
|
70
|
+
`;
|
|
71
|
+
const userCode = extractUserCode(content);
|
|
72
|
+
expect(userCode.size).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('handles multiple routes in one file', () => {
|
|
76
|
+
const content = `
|
|
77
|
+
// @serverbridge-route: GET /api/users
|
|
78
|
+
router.get('/api/users', async (req, res) => {
|
|
79
|
+
// @serverbridge-user-start
|
|
80
|
+
res.json(await getUsers());
|
|
81
|
+
// @serverbridge-user-end
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// @serverbridge-route: POST /api/users
|
|
85
|
+
router.post('/api/users', async (req, res) => {
|
|
86
|
+
// @serverbridge-user-start
|
|
87
|
+
// TODO: Implement POST /api/users
|
|
88
|
+
res.json({});
|
|
89
|
+
// @serverbridge-user-end
|
|
90
|
+
});
|
|
91
|
+
`;
|
|
92
|
+
const userCode = extractUserCode(content);
|
|
93
|
+
// Only GET has real user code; POST still has TODO
|
|
94
|
+
expect(userCode.size).toBe(1);
|
|
95
|
+
expect(userCode.has('GET:/api/users')).toBe(true);
|
|
96
|
+
expect(userCode.has('POST:/api/users')).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('diffRoutes', () => {
|
|
101
|
+
it('detects added routes', () => {
|
|
102
|
+
const newRoutes = [makeRoute()];
|
|
103
|
+
const diff = diffRoutes(null, newRoutes);
|
|
104
|
+
expect(diff.added).toHaveLength(1);
|
|
105
|
+
expect(diff.removed).toHaveLength(0);
|
|
106
|
+
expect(diff.changed).toHaveLength(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('detects removed routes', () => {
|
|
110
|
+
const oldManifest = createManifest([makeRoute()]);
|
|
111
|
+
const diff = diffRoutes(oldManifest, []);
|
|
112
|
+
expect(diff.removed).toHaveLength(1);
|
|
113
|
+
expect(diff.added).toHaveLength(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('detects changed routes', () => {
|
|
117
|
+
const oldRoute = makeRoute({ responseFormat: 'json' });
|
|
118
|
+
const oldManifest = createManifest([oldRoute]);
|
|
119
|
+
const newRoute = makeRoute({ responseFormat: 'html' });
|
|
120
|
+
const diff = diffRoutes(oldManifest, [newRoute]);
|
|
121
|
+
expect(diff.changed).toHaveLength(1);
|
|
122
|
+
expect(diff.unchanged).toHaveLength(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('identifies unchanged routes', () => {
|
|
126
|
+
const route = makeRoute();
|
|
127
|
+
const oldManifest = createManifest([route]);
|
|
128
|
+
const diff = diffRoutes(oldManifest, [route]);
|
|
129
|
+
expect(diff.unchanged).toHaveLength(1);
|
|
130
|
+
expect(diff.added).toHaveLength(0);
|
|
131
|
+
expect(diff.changed).toHaveLength(0);
|
|
132
|
+
});
|
|
133
|
+
});
|